#!/usr/bin/env bash # # Copyright 2020 WoozyMasta # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. # # shellcheck disable=SC2015 set -e # Messages log () { printf '%s [%s] %s\n' "$(date '+%Y/%m/%d %H:%M:%S')" "$1" "${@:2}" } msg-start () { [ "$silent" == 'true' ] && return; if [ -t 1 ]; then printf '\e[1;33m%-15s\e[m%-30s%s\n' 'Processing' "$1" "${@:2}" else log INFO "Processing dump $*"; fi } msg-end () { [ "$silent" == 'true' ] && return; if [ -t 1 ]; then printf '\e[1A\e[1;32m%-15s\e[m%-30s%s\n' 'Success' "$1" "${@:2}" else log INFO "Successfully dumped $*"; fi } msg-fail () { [ "$silent" == 'true' ] && return; if [ -t 1 ]; then printf '\e[1A\e[1;31m%-15s\e[m%-30s%s\n' 'Fail' "$1" "${@:2}" else log WARNING "Failed dump $*"; fi } success () { [ "$silent" == 'true' ] && return; if [ -t 1 ]; then printf '%s \e[1;36m%s\e[m %s\n' "$1" "$2" "${@:3}" else log INFO "$*"; fi score=$((score+1)) } heading () { [ "$silent" == 'true' ] && return; if [ -t 1 ]; then printf '%s \e[1;34m%s\e[m %s\n%-15s%-30s%s\n' \ "$1" "$2" 'started' 'STATE' 'RESOURCE' 'NAME' else log INFO "$*"; fi } warn () { if [ -t 1 ]; then >&2 printf '\e[1;31m%-10s\e[m%s\n' 'Warning:' "$*" else log WARNING "$*"; fi } fail () { if [ -t 1 ]; then >&2 printf '\n\e[1;31m%-10s\e[m%s\n' 'Error:' "$*"; exit 1 else log ERROR "$*"; exit 1; fi } # Check command is exist require () { for command in "$@"; do if ! [ -x "$(command -v "$command")" ]; then fail "'$command' util not found, please install it first" fi done } # Usage message usage () { cat <<-EOF Dump kubernetes cluster resources Usage: ${0##*/} [command] [[flags]] Available Commands: all, dump Dump all kubernetes resources ns, dump-namespaces Dump namespaced kubernetes resources cls, dump-cluster Dump cluster wide kubernetes resources The command can also be passed through the environment variable MODE. All flags presented below have a similar variable in uppercase, with underscores For example: --destination-dir == DESTINATION_DIR Flags: -h, --help This help -s, --silent Execute silently, suppress all stdout messages -d, --destination-dir Path to dir for store dumps, default ./data -f, --force-remove Delete resources in data directory before launch Kubernetes flags: -n, --namespaces List of kubernetes namespaces -r, --namespaced-resources List of namespaced resources -k, --cluster-resources List of cluster resources --kube-config Path to kubeconfig file --kube-context The name of the kubeconfig context to use --kube-cluster The name of the kubeconfig cluster to use --kube-insecure-tls Skip check server's certificate for validity Git commit flags: -c, --git-commit Commit changes -p, --git-push Commit changes and push to origin -b, --git-branch Branch name --git-commit-user Commit author username --git-commit-email Commit author email --git-remote-name Remote repo name, defualt is origin --git-remote-url Remote repo URL Archivate flags: -a, --archivate Create archive of data dir --archive-rotate-days Rotate archives older than N days --archive-type Archive type xz, gz or bz2, default is tar Example of use: ${0##*/} dump-namespaces -n default,dev -d /mnt/dump -spa --archive-type gz Report bugs at: https://github.com/WoozyMasta/kube-dump/issues EOF exit 0 } # Set common vars working_dir="$(pwd)" timestamp="$(date '+%Y.%m.%d_%H-%M')" # Read vars from env # shellcheck disable=SC1090,SC1091 [ -f "$working_dir/.env" ] && . "$working_dir/.env" # Parse args commands if [[ "${1:-$MODE}" =~ ^(dump|all|dump-namespaces|ns|dump-cluster|cls)$ ]]; then mode="${1:-$MODE}"; else usage; fi # Parse args flags args=$( getopt \ -l "namespaces:,namespaced-resources:,cluster-resources:" \ -l "kube-config:,kube-context:,kube-cluster:,kube-insecure-tls" \ -l "help,silent,destination:,force-remove," \ -l "git-commit,git-push,git-branch:,git-commit-user:,git-commit-email:" \ -l "git-remote-name:,git-remote-url:" \ -l "archivate,archive-rotate-days:,archive-type:" \ -o "n:,r:,k:,h,s,d:,f,c,p,b:,a" -- "${@:2}" ) eval set -- "$args" while [ $# -ge 1 ]; do case "$1" in # Resources -n|--namespaces) namespaces+="$2,"; shift; shift;; -r|--namespaced-resources) namespaced_resources+="$2,"; shift; shift;; -k|--cluster-resources) cluster_resources+="$2,"; shift; shift;; # Kubectl opts --kube-config) kube_config="$2"; shift; shift;; --kube-context) kube_context="$2"; shift; shift;; --kube-cluster) kube_cluster="$2"; shift; shift;; --kube-insecure-tls) kube_insecure_tls='true'; shift;; # Common opts -h|--help) usage;; -s|--silent) silent='true'; shift;; -d|--destination-dir) destination_dir="$2"; shift; shift;; # Dump opts -f|--force-remove) force_remove='true'; shift;; # Commit opts -c|--git-commit) git_commit='true'; shift;; -p|--git-push) git_push='true'; shift;; -b|--git-branch) git_branch="$2"; shift; shift;; --git-commit-user) git_commit_user="$2"; shift; shift;; --git-commit-email) git_commit_email="$2"; shift; shift;; --git-remote-name) git_remote_name="$2"; shift; shift;; --git-remote-url) git_remote_url="$2"; shift; shift;; # Archivate opts -a|--archivate) archivate='true'; shift;; --archive-rotate-days) archive_rotate="$2"; shift; shift;; --archive-type) archive_type="$2"; shift; shift;; # Final --) shift; break;; -*) fail "invalid option $1";; esac done if [[ -n "$*" && "$OSTYPE" != "darwin"* ]]; then fail "extra arguments $*" fi # Set vars silent="${silent:-$SILENT}" kube_config="${kube_config:-$KUBE_CONFIG}" kube_context="${kube_context:-$KUBE_CONTEXT}" kube_cluster="${kube_cluster:-$KUBE_CLUSTER}" kube_insecure_tls="${kube_insecure_tls:-$KUBE_INSECURE_TLS}" git_commit="${git_commit:-$GIT_COMMIT}" git_branch="${git_branch:-$GIT_BRANCH}" git_commit_user="${git_commit_user:-$GIT_COMMIT_USER}" git_commit_email="${git_commit_email:-$GIT_COMMIT_EMAIL}" git_remote_name="${git_remote_name:-$GIT_REMOTE_NAME}" git_remote_url="${git_remote_url:-$GIT_REMOTE_URL}" git_push="${git_push:-$GIT_PUSH}" archivate="${archivate:-$ARCHIVATE}" archive_rotate="${archive_rotate:-$ARCHIVE_ROTATE}" archive_type="${archive_type:-$ARCHIVE_TYPE}" # Check dependency require kubectl jq yq [ "$git_commit" == 'true' ] && \ require git [ "$archivate" == 'true' ] && [ "$archive_type" == 'xz' ] && \ require tar xz [ "$archivate" == 'true' ] && [ "$archive_type" == 'gzip' ] && \ require tar gzip [ "$archivate" == 'true' ] && [ "$archive_type" == 'bzip2' ] && \ require tar bzip2 # Kubectl args [ -n "$kube_config" ] && k_args+=("--kubeconfig=$kube_config") [ -n "$kube_context" ] && k_args+=("--context=$kube_context") [ -n "$kube_cluster" ] && k_args+=("--cluster=$kube_cluster") [ "$kube_insecure_tls" == 'true' ] && \ k_args+=("--insecure-skip-tls-verify=true") # Use serviceaccount if [ -n "$KUBERNETES_SERVICE_HOST" ] && \ [ -n "$KUBERNETES_SERVICE_PORT" ] && \ [ -z "$kube_config" ] then require curl kube_api="$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" kube_api_token=$(/dev/null kubectl config set-credentials "${kube_user:-kube-dump}" \ --token="$kube_api_token" >/dev/null kubectl config set-context "${kube_context:-k8s}" \ --cluster "${kube_cluster:-k8s}" \ --user "${kube_user:-kube-dump}" >/dev/null kubectl config use-context "${kube_context:-k8s}" >/dev/null else fail 'Found running on kubernetes cluster but attempting connect' \ "to API $kube_api failed with code $_api_code" fi fi # Check kube config context if [ -n "$kube_context" ]; then kubectl config view \ --kubeconfig="${kube_config:-$HOME/.kube/config}" --output='json' | \ jq --exit-status --monochrome-output --raw-output '.contexts[].name' | \ grep --quiet "^$kube_context$" && \ context="$kube_context" || \ fail "Context $kube_context not exist in kubeconfig" elif kubectl config current-context "${k_args[@]}" >/dev/null 2>&1; then context=$(kubectl config current-context "${k_args[@]}") else fail "Current context not configured in kubeconfig" fi # Check kube config cluster if [ -n "$kube_cluster" ]; then kubectl config view \ --kubeconfig="${kube_config:-$HOME/.kube/config}" --output='json' | \ jq --exit-status --monochrome-output --raw-output '.clusters[].name' | \ grep --quiet "^$kube_cluster$" || \ fail "Cluster $kube_cluster not exist in kubeconfig" fi # Try get cluster info kubectl cluster-info "${k_args[@]}" >/dev/null || \ fail "Cluster $kube_api not accessible" # Set namespaces list if [ -z "${namespaces:-$NAMESPACES}" ]; then if ! namespaces=$(kubectl get namespaces \ --output=jsonpath=\{.items[*].metadata.name\} "${k_args[@]}") then fail 'Cant get namespaces from cluster' fi else namespaces=${namespaces:-$NAMESPACES} fi # Set namespaced resources # https://kubernetes.io/docs/reference/kubectl/overview/#resource-types if [ -z "${namespaced_resources:-$NAMESPACED_RESOURCES}" ]; then namespaced_resources="$( kubectl api-resources --namespaced=true --output=name "${k_args[@]}" | \ tr '\n' ' ' )" else namespaced_resources=${namespaced_resources:-$NAMESPACED_RESOURCES} fi # Set cluster resources if [ -z "${cluster_resources:-$CLUSTER_RESOURCES}" ]; then cluster_resources="$( kubectl api-resources --namespaced=false --output=name "${k_args[@]}" | \ tr '\n' ' ' )" else cluster_resources=${cluster_resources:-$CLUSTER_RESOURCES} fi # Dump dir destination_dir="${destination_dir:-${DESTINATION_DIR:-$working_dir/data}}" destination_dir="$(realpath "$destination_dir" --canonicalize-missing)" if [ ! -d "$destination_dir" ]; then mkdir -p "$destination_dir" success 'Dump data directory' "$destination_dir" 'created' elif [ "${force_remove:-$FORCE_REMOVE}" == 'true' ]; then warn "Destination $destination_dir directory will be removed" sleep 3 find "$destination_dir" -mindepth 1 -maxdepth 1 -type d ! -name '.git' \ -exec rm -fr "{}" \; fi # Git try to clone if [ "$git_push" == 'true' ] && [ ! -d "$destination_dir/.git/" ]; then if ! grep --quiet --extended-regexp 'https?:\/\/' <<< "$git_remote_url"; then _remote_url=$(sed -r 's|.*@([A-Za-z0-9\-\.]+):.*|\1|' <<< "$git_remote_url") ! grep --quiet "^$_remote_url " ~/.ssh/known_hosts >/dev/null 2>&1 && \ ssh-keyscan "$_remote_url" >> ~/.ssh/known_hosts 2>/dev/null git clone --branch "$git_branch" --single-branch --depth 1 \ --quiet "$_remote_url" "$destination_dir" >/dev/null 2>&1 && \ success 'The remote repository is cloned at' "$destination_dir" || \ warn 'Unable to clone remote repository at' "$destination_dir" else git clone --branch "$git_branch" --single-branch --depth 1 \ --quiet "$git_remote_url" "$destination_dir" >/dev/null 2>&1 && \ success 'The remote repository is cloned at' "$destination_dir" || \ warn 'Unable to clone remote repository at' "$destination_dir" fi fi success 'Dump data in' "$destination_dir" 'directory' '' score=0 # Work with namespaced reosurces if [[ "$mode" =~ ^(dump|all|dump-namespaces|ns)$ ]]; then for ns in ${namespaces//,/ }; do # Check namespace exist if ! kubectl get ns "$ns" "${k_args[@]}" >/dev/null 2>&1; then warn "Namespace \"$ns\" not found" continue fi # Create namespace dir [ -d "$destination_dir/$ns" ] || mkdir -p "$destination_dir/$ns" heading 'Dump namespace' "$ns" # Iterate over resources for resource in ${namespaced_resources//,/ }; do # Iterate over only accessible resources while read -r name; do [ -z "$name" ] && continue # Skip service-account-token secrets if [ "$resource" == 'secret' ]; then type=$( kubectl get --namespace="${ns}" --output=jsonpath="{.type}" \ secret "$name" "${k_args[@]}" ) [ "$type" == 'kubernetes.io/service-account-token' ] && continue unset type fi msg-start "$resource" "$name" # Save resource to file kubectl --namespace="${ns}" get \ --output='json' "$resource" "$name" "${k_args[@]}" 2>/dev/null | \ jq --exit-status --compact-output --monochrome-output \ --raw-output --sort-keys 2>/dev/null \ 'del( .metadata.annotations."autoscaling.alpha.kubernetes.io/conditions", .metadata.annotations."autoscaling.alpha.kubernetes.io/current-metrics", .metadata.annotations."control-plane.alpha.kubernetes.io/leader", .metadata.annotations."deployment.kubernetes.io/revision", .metadata.annotations."kubectl.kubernetes.io/last-applied-configuration", .metadata.annotations."kubernetes.io/service-account.uid", .metadata.annotations."pv.kubernetes.io/bind-completed", .metadata.annotations."pv.kubernetes.io/bound-by-controller", .metadata.finalizers, .metadata.managedFields, .metadata.creationTimestamp, .metadata.generation, .metadata.resourceVersion, .metadata.selfLink, .metadata.uid, .spec.clusterIP, .spec.progressDeadlineSeconds, .spec.revisionHistoryLimit, .spec.template.metadata.annotations."kubectl.kubernetes.io/restartedAt", .spec.template.metadata.creationTimestamp, .spec.volumeName, .spec.volumeMode, .status )' | \ yq eval --prettyPrint --no-colors --exit-status - \ >"$destination_dir/$ns/${name//:/-}_$resource".yaml 2>/dev/null && \ msg-end "$resource" "$name" || msg-fail "$resource" "$name" done < <( kubectl --namespace="${ns}" get "$resource" \ --output='custom-columns=NAME:.metadata.name' \ --no-headers "${k_args[@]}" 2>/dev/null ) done success 'Namespace' "$ns" 'resources dump completed' '' done fi # Work with cluster resources if [[ "$mode" =~ ^(dump|all|dump-cluster|cls)$ ]]; then heading 'Dump cluster data' "$context" # Iterate over resources for resource in ${cluster_resources//,/ }; do # Iterate over only accessible resources while read -r name; do [ -d "$destination_dir/cluster" ] || mkdir -p "$destination_dir/cluster" msg-start "$resource" "$name" # Save resource to file kubectl get --output='json' "$resource" "$name" "${k_args[@]}" | \ jq --exit-status --compact-output --monochrome-output \ --raw-output --sort-keys 2>/dev/null \ 'del( .metadata.annotations."kubectl.kubernetes.io/last-applied-configuration", .metadata.annotations."control-plane.alpha.kubernetes.io/leader", .metadata.uid, .metadata.selfLink, .metadata.resourceVersion, .metadata.creationTimestamp, .metadata.generation )' | \ yq eval --prettyPrint --no-colors --exit-status - \ >"$destination_dir/cluster/${name//:/-}_$resource".yaml 2>/dev/null && \ msg-end "$resource" "$name" || msg-fail "$resource" "$name" done < <( kubectl get "$resource" \ --output='custom-columns=NAME:.metadata.name' \ --no-headers "${k_args[@]}" 2>/dev/null ) done success 'Cluster' "$context" 'resources dump completed' '' fi # Git if [ "$git_commit" == 'true' ] || [ "$git_push" == 'true' ]; then cd "$destination_dir" # Init repo if not exist if [ ! -d "$destination_dir/.git/" ]; then git init . --quiet success 'The repository is created in the' "$destination_dir/.git/" \ 'directory' fi # Set branch if [ -z "$git_branch" ]; then git_branch="$(git symbolic-ref --short HEAD)" else git checkout -b "$git_branch" >/dev/null 2>&1 || true fi # Set git username if need if [ -z "$git_commit_user" ] && ! git config user.name >/dev/null 2>&1; then git config --local user.name 'Kube-dump' elif [ -n "$git_commit_user" ]; then git config --local user.name "$git_commit_user" fi # Set git email if need if [ -z "$git_commit_email" ] && ! git config user.email >/dev/null 2>&1; then git config --local user.email "$context" elif [ -n "$git_commit_email" ]; then git config --local user.email "$git_commit_email" fi # Ignore archives [ ! -f "$destination_dir/.gitignore" ] && printf '%s\n' \ '*.tar.xz' '*.tar.gz' '*.tar.bz2' '*.tar' > "$destination_dir/.gitignore" # Commit all if [ -n "$(git status --porcelain)" ]; then _commit_message="Kubernetes $context cluster data dumped at $timestamp" git add . git add . --all git commit --all --quiet --message="$_commit_message" success 'Changes commited with message:' "$_commit_message" else success 'No changes,' 'nothing to commit' 'in git repository' fi if [ "$git_push" == 'true' ]; then # Set git repository url if need if ! git ls-remote >/dev/null 2>&1 && [ -z "$git_remote_url" ]; then warn "Remote git repository url not set, cannot push" elif [ -n "$git_remote_url" ]; then git remote add "${git_remote_name:-origin}" \ "$git_remote_url" > /dev/null 2>&1 || true fi git pull --ff-only "$git_remote_url" "$git_branch" || true git push --quiet --set-upstream "${git_remote_name:-origin}" "$git_branch" fi fi # Archivate if [ "$archivate" == 'true' ]; then # Set compression flag and archive name [ "$archive_type" == 'xz' ] && _compress='--xz' [ "$archive_type" == 'gz' ] && _compress='--gzip' [ "$archive_type" == 'bz2' ] && _compress='--bzip2' if [ -n "$_compress" ]; then _archive="${destination_dir}/dump_$timestamp.tar.$archive_type" else _archive="${destination_dir}/dump_$timestamp.tar" fi # Create archive tar --create --file="$_archive" --absolute-names $_compress \ --exclude-vcs --exclude='*.tar' --exclude='*.tar.xz' \ --exclude='*.tar.gz' --exclude='*.tar.bz2' \ --directory="${destination_dir%/*}" "${destination_dir##*/}" success 'Archive' "$_archive" 'created' # Rotate archives if [ -n "$archive_rotate" ]; then find "${destination_dir}" -mindepth 1 -maxdepth 1 -type f -name "*.tar" \ -o -name "*.tar.xz" -o -name "*.tar.gz" -o -name "*.tar.bz2" \ -mtime +"$archive_rotate" -delete success 'Rotatinon for older than' "$archive_rotate days" \ "*.tar.${archive_type:-xz} archives removed" fi fi # Done if [ "$score" -ge 0 ]; then success 'Done!' "$score" 'task completed' exit 0 else fail 'No task has been completed' fi