From d6fd0d2acaec9f53e75d82db30411f96a5bf2cc9 Mon Sep 17 00:00:00 2001
From: Timoses <timosesu@gmail.com>
Date: Wed, 1 May 2019 10:10:56 +0200
Subject: [PATCH] Enable delegating all downloads (binaries, images, kubeadm
 images) (#4420)

* Download to delegate and sync files when download_run_once

* Fail on error after saving container image

* Do not set changed status when downloaded container was up to date

* Only sync containers when they are actually required

Previously, non-required images (pull_required=false as
image existed on target host) were synced to the target
hosts. This failed as the image was not downloaded to
the download_delegate and hence was not available for
syncing.

* Sync containers when only missing on some hosts

* Consider images with multiple repo tags

* Enable kubeadm images pull/syncing with download_delegate

* Use kubeadm images list to pull/sync

'kubeadm config images pull' is replaced by collecting the images
list with 'kubeadm config images list' and using the commonly
used method of pull/syncing the images.

* Ensure containers are downloaded and synced for all hosts

* Fix download/syncing when download_delegate is a kubernetes host
---
 docs/downloads.md                             | 10 ++--
 roles/download/defaults/main.yml              |  4 +-
 roles/download/tasks/download_container.yml   |  7 ++-
 roles/download/tasks/download_file.yml        | 29 +++++++++-
 roles/download/tasks/kubeadm_images.yml       | 50 ++++++++++++++++-
 roles/download/tasks/main.yml                 | 27 ++++++----
 .../download/tasks/set_docker_image_facts.yml | 11 +++-
 roles/download/tasks/sync_container.yml       | 15 +++---
 roles/download/tasks/sync_file.yml            | 53 +++++++++++++++++++
 roles/kubernetes/master/tasks/main.yml        |  2 +-
 roles/kubernetes/node/tasks/install.yml       |  6 +--
 11 files changed, 176 insertions(+), 38 deletions(-)
 create mode 100644 roles/download/tasks/sync_file.yml

diff --git a/docs/downloads.md b/docs/downloads.md
index dc6f5cf30..21f91fb68 100644
--- a/docs/downloads.md
+++ b/docs/downloads.md
@@ -12,14 +12,14 @@ Kubespray supports several download/upload modes. The default is:
 
 There is also a "pull once, push many" mode as well:
 
-* Override the ``download_run_once: True`` to download container images only once
+* Override the ``download_run_once: True`` to download container images and binaries only once
   then push to cluster nodes in batches. The default delegate node
-  for pushing images is the first `kube-master`.
+  for pushing is the first `kube-master`.
 * If your ansible runner node (aka the admin node) have password-less sudo and
   docker enabled, you may want to define the ``download_localhost: True``, which
-  makes that node a delegate for pushing images while running the deployment with
-  ansible. This maybe the case if cluster nodes cannot access each over via ssh
-  or you want to use local docker images as a cache for multiple clusters.
+  makes that node a delegate for pushing while running the deployment with
+  ansible. This may be the case if cluster nodes cannot access each other via ssh
+  or you want to use local docker images and binaries as a cache for multiple clusters.
 
 Container images and binary files are described by the vars like ``foo_version``,
 ``foo_download_url``, ``foo_checksum`` for binaries and ``foo_image_repo``,
diff --git a/roles/download/defaults/main.yml b/roles/download/defaults/main.yml
index ae897c6e5..a661c0d00 100644
--- a/roles/download/defaults/main.yml
+++ b/roles/download/defaults/main.yml
@@ -321,7 +321,7 @@ downloads:
     enabled: true
     file: true
     version: "{{ kubeadm_version }}"
-    dest: "{{local_release_dir}}/kubeadm"
+    dest: "{{ local_release_dir }}/kubeadm-{{ kubeadm_version }}-{{ image_arch }}"
     sha256: "{{ kubeadm_binary_checksum }}"
     url: "{{ kubeadm_download_url }}"
     unarchive: false
@@ -334,7 +334,7 @@ downloads:
     enabled: true
     file: true
     version: "{{ kube_version }}"
-    dest: "{{ local_release_dir }}/hyperkube"
+    dest: "{{ local_release_dir }}/hyperkube-{{ kube_version }}-{{ image_arch }}"
     sha256: "{{ hyperkube_binary_checksum }}"
     url: "{{ hyperkube_download_url }}"
     unarchive: false
diff --git a/roles/download/tasks/download_container.yml b/roles/download/tasks/download_container.yml
index bf0c9b1cf..12cc67c3a 100644
--- a/roles/download/tasks/download_container.yml
+++ b/roles/download/tasks/download_container.yml
@@ -1,9 +1,6 @@
 ---
 - name: container_download | Make download decision if pull is required by tag or sha256
   include_tasks: set_docker_image_facts.yml
-  delegate_to: "{{ download_delegate if download_run_once or omit }}"
-  delegate_facts: yes
-  run_once: "{{ download_run_once }}"
   when:
     - download.enabled
     - download.container
@@ -18,11 +15,12 @@
   until: pull_task_result is succeeded
   retries: 4
   delay: "{{ retry_stagger | random + 3 }}"
+  changed_when: not 'up to date' in pull_task_result.stdout
   when:
     - download_run_once
     - download.enabled
     - download.container
-    - pull_required|default(download_always_pull)
+    - any_pull_required | default(download_always_pull)
   delegate_to: "{{ download_delegate }}"
   delegate_facts: yes
   run_once: yes
@@ -33,6 +31,7 @@
   until: pull_task_result is succeeded
   retries: 4
   delay: "{{ retry_stagger | random + 3 }}"
+  changed_when: not 'up to date' in pull_task_result.stdout
   when:
     - not download_run_once
     - download.enabled
diff --git a/roles/download/tasks/download_file.yml b/roles/download/tasks/download_file.yml
index b55639ffd..3993ef6b6 100644
--- a/roles/download/tasks/download_file.yml
+++ b/roles/download/tasks/download_file.yml
@@ -15,7 +15,10 @@
     - download.file
     - group_names | intersect(download.groups) | length
 
-- name: file_download | Download item
+# As in 'download_container.yml':
+#   In Ansible 2.4 omitting download delegate is broken. Move back
+#   to one task in the future.
+- name: file_download | Download item (delegate)
   get_url:
     url: "{{download.url}}"
     dest: "{{download.dest}}"
@@ -30,7 +33,31 @@
   until: "'OK' in get_url_result.msg or 'file already exists' in get_url_result.msg"
   retries: 4
   delay: "{{ retry_stagger | default(5) }}"
+  delegate_to: "{{ download_delegate }}"
   when:
+    - download_run_once
+    - download.enabled
+    - download.file
+    - group_names | intersect(download.groups) | length
+  run_once: yes
+
+- name: file_download | Download item (all)
+  get_url:
+    url: "{{download.url}}"
+    dest: "{{download.dest}}"
+    sha256sum: "{{download.sha256 | default(omit)}}"
+    owner: "{{ download.owner|default(omit) }}"
+    mode: "{{ download.mode|default(omit) }}"
+    validate_certs: "{{ download_validate_certs }}"
+    url_username: "{{ download.username|default(omit) }}"
+    url_password: "{{ download.password|default(omit) }}"
+    force_basic_auth: "{{ download.force_basic_auth|default(omit) }}"
+  register: get_url_result
+  until: "'OK' in get_url_result.msg or 'file already exists' in get_url_result.msg"
+  retries: 4
+  delay: "{{ retry_stagger | default(5) }}"
+  when:
+    - not download_run_once
     - download.enabled
     - download.file
     - group_names | intersect(download.groups) | length
diff --git a/roles/download/tasks/kubeadm_images.yml b/roles/download/tasks/kubeadm_images.yml
index a166a35ab..8257bccc1 100644
--- a/roles/download/tasks/kubeadm_images.yml
+++ b/roles/download/tasks/kubeadm_images.yml
@@ -1,4 +1,22 @@
 ---
+- name: kubeadm | Download kubeadm
+  include_tasks: "download_file.yml"
+  vars:
+    download: "{{ download_defaults | combine(downloads.kubeadm) }}"
+  when:
+    - not skip_downloads|default(false)
+    - downloads.kubeadm.enabled
+
+- name: kubeadm | Sync kubeadm
+  include_tasks: "sync_file.yml"
+  vars:
+    download: "{{ download_defaults | combine(downloads.kubeadm) }}"
+  when:
+    - not skip_downloads|default(false)
+    - downloads.kubeadm.enabled
+    - download_run_once
+    - group_names | intersect(download.groups) | length
+
 - name: kubeadm | Create kubeadm config
   template:
     src: "kubeadm-images.yaml.j2"
@@ -6,7 +24,7 @@
 
 - name: kubeadm | Copy kubeadm binary from download dir
   synchronize:
-    src: "{{ local_release_dir }}/kubeadm"
+    src: "{{ local_release_dir }}/kubeadm-{{ kubeadm_version }}-{{ image_arch }}"
     dest: "{{ bin_dir }}/kubeadm"
     compress: no
     perms: yes
@@ -22,3 +40,33 @@
 
 - name: container_download | download images for kubeadm config images
   command: "{{ bin_dir }}/kubeadm config images pull --config={{ kube_config_dir }}/kubeadm-images.yaml"
+  when: not download_run_once
+
+- name: container_download | fetch list of kubeadm config images
+  command: "{{ bin_dir }}/kubeadm config images list --config={{ kube_config_dir }}/kubeadm-images.yaml"
+  register: result
+  run_once: true
+  when: download_run_once
+  changed_when: false
+
+- vars:
+    kubeadm_images_list: "{{ result.stdout_lines }}"
+  set_fact:
+    kubeadm_image:
+      key: "kubeadm_{{ (item | regex_replace('^(?:.*\\/)*','')).split(':')[0] }}"
+      value:
+        enabled: true
+        container: true
+        repo: "{{ item.split(':')[0] }}"
+        tag: "{{ item.split(':')[1] }}"
+        groups:
+          - k8s-cluster
+  loop: "{{ kubeadm_images_list | flatten(levels=1) }}"
+  run_once: true
+  when: download_run_once
+  register: result_images
+
+- set_fact:
+    kubeadm_images: "{{ result_images.results | map(attribute='ansible_facts.kubeadm_image') | list | items2dict }}"
+  run_once: true
+  when: download_run_once
diff --git a/roles/download/tasks/main.yml b/roles/download/tasks/main.yml
index 06ad9505a..f9764ae6a 100644
--- a/roles/download/tasks/main.yml
+++ b/roles/download/tasks/main.yml
@@ -3,31 +3,36 @@
   when:
     - not skip_downloads|default(false)
 
+- include_tasks: kubeadm_images.yml
+  when:
+    - kube_version is version('v1.11.0', '>=')
+    - not skip_downloads|default(false)
+    - inventory_hostname in groups['kube-master']
+
+- set_fact:
+    kubeadm_images: {}
+  when:
+    - kubeadm_images is not defined
+
 - name: "Download items"
   include_tasks: "{{ include_file }}"
   vars:
     download: "{{ download_defaults | combine(item.value) }}"
     include_file: "download_{% if download.container %}container{% else %}file{% endif %}.yml"
-  with_dict: "{{ downloads }}"
+  with_dict: "{{ downloads | combine(kubeadm_images) }}"
   when:
     - not skip_downloads|default(false)
     - item.value.enabled
     - (not (item.value.container|default(False))) or (item.value.container and download_container)
 
-- name: "Sync container"
-  include_tasks: sync_container.yml
+- name: "Sync items"
+  include_tasks: "{{ include_file }}"
   vars:
     download: "{{ download_defaults | combine(item.value) }}"
-  with_dict: "{{ downloads }}"
+    include_file: "sync_{% if download.container %}container{% else %}file{% endif %}.yml"
+  with_dict: "{{ downloads | combine(kubeadm_images) }}"
   when:
     - not skip_downloads|default(false)
     - item.value.enabled
-    - item.value.container | default(false)
     - download_run_once
     - group_names | intersect(download.groups) | length
-
-- include_tasks: kubeadm_images.yml
-  when:
-    - kube_version is version('v1.11.0', '>=')
-    - not skip_downloads|default(false)
-    - inventory_hostname in groups['kube-master']
diff --git a/roles/download/tasks/set_docker_image_facts.yml b/roles/download/tasks/set_docker_image_facts.yml
index 935f1bf3f..84ed88760 100644
--- a/roles/download/tasks/set_docker_image_facts.yml
+++ b/roles/download/tasks/set_docker_image_facts.yml
@@ -9,7 +9,7 @@
 
 - name: Register docker images info
   shell: >-
-    {{ docker_bin_dir }}/docker images -q | xargs {{ docker_bin_dir }}/docker inspect -f "{{ '{{' }} if .RepoTags {{ '}}' }}{{ '{{' }} (index .RepoTags 0) {{ '}}' }}{{ '{{' }} end {{ '}}' }}{{ '{{' }} if .RepoDigests {{ '}}' }},{{ '{{' }} (index .RepoDigests 0) {{ '}}' }}{{ '{{' }} end {{ '}}' }}" | tr '\n' ','
+    {{ docker_bin_dir }}/docker images -q | xargs -r {{ docker_bin_dir }}/docker inspect -f "{{ '{{' }} if .RepoTags {{ '}}' }}{{ '{{' }} (index .RepoTags) {{ '}}' }}{{ '{{' }} end {{ '}}' }}{{ '{{' }} if .RepoDigests {{ '}}' }},{{ '{{' }} (index .RepoDigests) {{ '}}' }}{{ '{{' }} end {{ '}}' }}" | sed -e 's/^ *\[//g' -e 's/\] *$//g' -e 's/ /\n/g' | tr '\n' ','
   no_log: true
   register: docker_images
   failed_when: false
@@ -22,6 +22,15 @@
       {%- if pull_args in docker_images.stdout.split(',') %}false{%- else -%}true{%- endif -%}
   when: not download_always_pull
 
+- name: Does any host require container pull?
+  vars:
+    hosts_pull_required: "{{ hostvars.values() | map(attribute='pull_required') | select('defined') | list }}"
+  set_fact:
+    any_pull_required: "{{ True in hosts_pull_required }}"
+  run_once: true
+  changed_when: false
+  when: not download_always_pull
+
 - name: Check the local digest sha256 corresponds to the given image tag
   assert:
     that: "{{download.repo}}:{{download.tag}} in docker_images.stdout.split(',')"
diff --git a/roles/download/tasks/sync_container.yml b/roles/download/tasks/sync_container.yml
index 4af3bdcb6..aeac6fd15 100644
--- a/roles/download/tasks/sync_container.yml
+++ b/roles/download/tasks/sync_container.yml
@@ -1,9 +1,6 @@
 ---
 - name: container_download | Make download decision if pull is required by tag or sha256
   include: set_docker_image_facts.yml
-  delegate_to: "{{ download_delegate if download_run_once or omit }}"
-  delegate_facts: no
-  run_once: "{{ download_run_once }}"
   when:
     - download.enabled
     - download.container
@@ -54,6 +51,7 @@
     - download.enabled
     - download.container
     - download_run_once
+    - any_pull_required | default(download_always_pull)
   tags:
     - facts
 
@@ -62,11 +60,12 @@
   delegate_to: "{{ download_delegate }}"
   delegate_facts: no
   register: saved
-  run_once: true
+  failed_when: saved.stderr != ""
   when:
     - download.enabled
     - download.container
     - download_run_once
+    - any_pull_required | default(download_always_pull)
     - (ansible_os_family not in ["CoreOS", "Container Linux by CoreOS"] or download_delegate == "localhost")
     - (container_changed or not img.stat.exists)
 
@@ -82,6 +81,7 @@
     - download.enabled
     - download.container
     - download_run_once
+    - any_pull_required | default(download_always_pull)
     - ansible_os_family not in ["CoreOS", "Container Linux by CoreOS"]
     - inventory_hostname == download_delegate
     - download_delegate != "localhost"
@@ -94,9 +94,6 @@
     use_ssh_args: "{{ has_bastion | default(false) }}"
     mode: pull
     private_key: "{{ ansible_ssh_private_key_file }}"
-  delegate_to: localhost
-  delegate_facts: no
-  run_once: true
   become: false
   when:
     - download.enabled
@@ -113,8 +110,6 @@
     dest: "{{ fname }}"
     use_ssh_args: "{{ has_bastion | default(false) }}"
     mode: push
-  delegate_to: localhost
-  delegate_facts: no
   become: true
   register: get_task
   until: get_task is succeeded
@@ -124,6 +119,7 @@
     - download.enabled
     - download.container
     - download_run_once
+    - pull_required|default(download_always_pull)
     - (ansible_os_family not in ["CoreOS", "Container Linux by CoreOS"] and
       inventory_hostname != download_delegate or
       download_delegate == "localhost")
@@ -137,6 +133,7 @@
     - download.enabled
     - download.container
     - download_run_once
+    - pull_required|default(download_always_pull)
     - (ansible_os_family not in ["CoreOS", "Container Linux by CoreOS"] and
       inventory_hostname != download_delegate or download_delegate == "localhost")
   tags:
diff --git a/roles/download/tasks/sync_file.yml b/roles/download/tasks/sync_file.yml
new file mode 100644
index 000000000..530a8237d
--- /dev/null
+++ b/roles/download/tasks/sync_file.yml
@@ -0,0 +1,53 @@
+---
+- name: file_download | create local download destination directory
+  file:
+    path: "{{download.dest|dirname}}"
+    state: directory
+    recurse: yes
+    mode: 0755
+  delegate_to: localhost
+  become: false
+  run_once: true
+  when:
+    - download_delegate != "localhost"
+    - download_run_once
+    - download.enabled
+    - download.file
+
+- name: file_download | copy file to ansible host
+  synchronize:
+    src: "{{ download.dest }}"
+    dest: "{{ download.dest }}"
+    use_ssh_args: "{{ has_bastion | default(false) }}"
+    mode: pull
+  run_once: true
+  become: false
+  when:
+    - download.enabled
+    - download.file
+    - download_run_once
+    - ansible_os_family not in ["CoreOS", "Container Linux by CoreOS"]
+    - inventory_hostname == download_delegate
+    - download_delegate != "localhost"
+
+- name: file_download | upload file to nodes
+  synchronize:
+    src: "{{ download.dest }}"
+    dest: "{{ download.dest }}"
+    use_ssh_args: "{{ has_bastion | default(false) }}"
+    mode: push
+  become: true
+  register: get_task
+  until: get_task is succeeded
+  retries: 4
+  delay: "{{ retry_stagger | random + 3 }}"
+  when:
+    - download.enabled
+    - download.file
+    - download_run_once
+    - (ansible_os_family not in ["CoreOS", "Container Linux by CoreOS"] and
+      inventory_hostname != download_delegate or
+      download_delegate == "localhost")
+  tags:
+    - upload
+    - upgrade
diff --git a/roles/kubernetes/master/tasks/main.yml b/roles/kubernetes/master/tasks/main.yml
index 31627fb33..9094ab6cb 100644
--- a/roles/kubernetes/master/tasks/main.yml
+++ b/roles/kubernetes/master/tasks/main.yml
@@ -19,7 +19,7 @@
 
 - name: Install | Copy kubectl binary from download dir
   synchronize:
-    src: "{{ local_release_dir }}/hyperkube"
+    src: "{{ local_release_dir }}/hyperkube-{{ kube_version }}-{{ image_arch }}"
     dest: "{{ bin_dir }}/kubectl"
     compress: no
     perms: yes
diff --git a/roles/kubernetes/node/tasks/install.yml b/roles/kubernetes/node/tasks/install.yml
index d5ef4279a..52ad10b6a 100644
--- a/roles/kubernetes/node/tasks/install.yml
+++ b/roles/kubernetes/node/tasks/install.yml
@@ -1,7 +1,7 @@
 ---
 - name: install | Copy kubeadm binary from download dir
   synchronize:
-    src: "{{ local_release_dir }}/kubeadm"
+    src: "{{ local_release_dir }}/kubeadm-{{ kubeadm_version }}-{{ image_arch }}"
     dest: "{{ bin_dir }}/kubeadm"
     compress: no
     perms: yes
@@ -25,7 +25,7 @@
 
 - name: install | Copy kubelet binary from download dir
   synchronize:
-    src: "{{ local_release_dir }}/hyperkube"
+    src: "{{ local_release_dir }}/hyperkube-{{ kube_version }}-{{ image_arch }}"
     dest: "{{ bin_dir }}/kubelet"
     compress: no
     perms: yes
@@ -48,7 +48,7 @@
 
 - name: install | Copy hyperkube binary from download dir
   synchronize:
-    src: "{{ local_release_dir }}/hyperkube"
+    src: "{{ local_release_dir }}/hyperkube-{{ kube_version }}-{{ image_arch }}"
     dest: "{{ bin_dir }}/hyperkube"
     compress: no
     perms: yes
-- 
GitLab