From bb73f28bf51888671fffea4b6f92d9e2eec61b75 Mon Sep 17 00:00:00 2001
From: kurokobo <kuro664@gmail.com>
Date: Sat, 18 May 2024 22:41:34 +0900
Subject: [PATCH] feat: implement timestamp callback plugin to show simple
 timestamp for each header (#8308)

* feat: add community.general.timestamp callback plugin

* feat: add minimal integration tests for timestamp callback plugin

* feat: add maintainers for timestamp callback plugin

* fix: correct license

* fix: remove type annotation for the older python environment

* fix: remove unnecessary comment

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix: add trailing period

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix: split long description into list

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix: remove default and add type

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix; add type

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix: split long description into list

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix: improve description for format_string to describe usable format codes

* fix: clarify the original codes and add copyright from that

* fix: shorten long lines

* fix: correct link format

* fix: add seealso section

* fix: add ignore entries for EOL CI

* fix: update seealso to correctly associate with related plugin

Co-authored-by: Felix Fontein <felix@fontein.de>

---------

Co-authored-by: Felix Fontein <felix@fontein.de>
---
 .github/BOTMETA.yml                           |   2 +
 plugins/callback/timestamp.py                 | 127 ++++++++++++++++++
 .../targets/callback_timestamp/aliases        |   6 +
 .../targets/callback_timestamp/tasks/main.yml |  66 +++++++++
 tests/sanity/ignore-2.13.txt                  |   1 +
 tests/sanity/ignore-2.14.txt                  |   1 +
 6 files changed, 203 insertions(+)
 create mode 100644 plugins/callback/timestamp.py
 create mode 100644 tests/integration/targets/callback_timestamp/aliases
 create mode 100644 tests/integration/targets/callback_timestamp/tasks/main.yml

diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml
index 5e674628f4..add3249355 100644
--- a/.github/BOTMETA.yml
+++ b/.github/BOTMETA.yml
@@ -91,6 +91,8 @@ files:
     maintainers: ryancurrah
   $callbacks/syslog_json.py:
     maintainers: imjoseangel
+  $callbacks/timestamp.py:
+    maintainers: kurokobo
   $callbacks/unixy.py:
     labels: unixy
     maintainers: akatch
diff --git a/plugins/callback/timestamp.py b/plugins/callback/timestamp.py
new file mode 100644
index 0000000000..07cd8d239c
--- /dev/null
+++ b/plugins/callback/timestamp.py
@@ -0,0 +1,127 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2024, kurokobo <kurokobo@protonmail.com>
+# Copyright (c) 2014, Michael DeHaan <michael.dehaan@gmail.com>
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import absolute_import, division, print_function
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+  name: timestamp
+  type: stdout
+  short_description: Adds simple timestamp for each header
+  version_added: 9.0.0
+  description:
+    - This callback adds simple timestamp for each header.
+  author: kurokobo (@kurokobo)
+  options:
+    timezone:
+      description:
+        - Timezone to use for the timestamp in IANA time zone format.
+        - For example C(America/New_York), C(Asia/Tokyo)). Ignored on Python < 3.9.
+      ini:
+        - section: callback_timestamp
+          key: timezone
+      env:
+        - name: ANSIBLE_CALLBACK_TIMESTAMP_TIMEZONE
+      type: string
+    format_string:
+      description:
+        - Format of the timestamp shown to user in 1989 C standard format.
+        - >
+          Refer to L(the Python documentation,https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)
+          for the available format codes.
+      ini:
+        - section: callback_timestamp
+          key: format_string
+      env:
+        - name: ANSIBLE_CALLBACK_TIMESTAMP_FORMAT_STRING
+      default: "%H:%M:%S"
+      type: string
+  seealso:
+    - plugin: ansible.posix.profile_tasks
+      plugin_type: callback
+      description: >
+        You can use P(ansible.posix.profile_tasks#callback) callback plugin to time individual tasks and overall execution time
+        with detailed timestamps.
+  extends_documentation_fragment:
+    - ansible.builtin.default_callback
+    - ansible.builtin.result_format_callback
+"""
+
+
+from ansible.plugins.callback.default import CallbackModule as Default
+from ansible.utils.display import get_text_width
+from ansible.module_utils.common.text.converters import to_text
+from datetime import datetime
+import types
+import sys
+
+# Store whether the zoneinfo module is available
+_ZONEINFO_AVAILABLE = sys.version_info >= (3, 9)
+
+
+def get_datetime_now(tz):
+    """
+    Returns the current timestamp with the specified timezone
+    """
+    return datetime.now(tz=tz)
+
+
+def banner(self, msg, color=None, cows=True):
+    """
+    Prints a header-looking line with cowsay or stars with length depending on terminal width (3 minimum) with trailing timestamp
+
+    Based on the banner method of Display class from ansible.utils.display
+
+    https://github.com/ansible/ansible/blob/4403519afe89138042108e237aef317fd5f09c33/lib/ansible/utils/display.py#L511
+    """
+    timestamp = get_datetime_now(self.timestamp_tzinfo).strftime(self.timestamp_format_string)
+    timestamp_len = get_text_width(timestamp) + 1  # +1 for leading space
+
+    msg = to_text(msg)
+    if self.b_cowsay and cows:
+        try:
+            self.banner_cowsay("%s @ %s" % (msg, timestamp))
+            return
+        except OSError:
+            self.warning("somebody cleverly deleted cowsay or something during the PB run.  heh.")
+
+    msg = msg.strip()
+    try:
+        star_len = self.columns - get_text_width(msg) - timestamp_len
+    except EnvironmentError:
+        star_len = self.columns - len(msg) - timestamp_len
+    if star_len <= 3:
+        star_len = 3
+    stars = "*" * star_len
+    self.display("\n%s %s %s" % (msg, stars, timestamp), color=color)
+
+
+class CallbackModule(Default):
+    CALLBACK_VERSION = 2.0
+    CALLBACK_TYPE = "stdout"
+    CALLBACK_NAME = "community.general.timestamp"
+
+    def __init__(self):
+        super(CallbackModule, self).__init__()
+
+        # Replace the banner method of the display object with the custom one
+        self._display.banner = types.MethodType(banner, self._display)
+
+    def set_options(self, task_keys=None, var_options=None, direct=None):
+        super(CallbackModule, self).set_options(task_keys=task_keys, var_options=var_options, direct=direct)
+
+        # Store zoneinfo for specified timezone if available
+        tzinfo = None
+        if _ZONEINFO_AVAILABLE and self.get_option("timezone"):
+            from zoneinfo import ZoneInfo
+
+            tzinfo = ZoneInfo(self.get_option("timezone"))
+
+        # Inject options into the display object
+        setattr(self._display, "timestamp_tzinfo", tzinfo)
+        setattr(self._display, "timestamp_format_string", self.get_option("format_string"))
diff --git a/tests/integration/targets/callback_timestamp/aliases b/tests/integration/targets/callback_timestamp/aliases
new file mode 100644
index 0000000000..124adcfb8c
--- /dev/null
+++ b/tests/integration/targets/callback_timestamp/aliases
@@ -0,0 +1,6 @@
+# Copyright (c) 2024, kurokobo <kurokobo@protonmail.com>
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or <https://www.gnu.org/licenses/gpl-3.0.txt>)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+azp/posix/1
+needs/target/callback
diff --git a/tests/integration/targets/callback_timestamp/tasks/main.yml b/tests/integration/targets/callback_timestamp/tasks/main.yml
new file mode 100644
index 0000000000..5e0acc15f0
--- /dev/null
+++ b/tests/integration/targets/callback_timestamp/tasks/main.yml
@@ -0,0 +1,66 @@
+---
+####################################################################
+# WARNING: These are designed specifically for Ansible tests       #
+# and should not be used as examples of how to write Ansible roles #
+####################################################################
+
+# Copyright (c) 2024, kurokobo <kurokobo@protonmail.com>
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+- name: Run tests
+  include_role:
+    name: callback
+  vars:
+    tests:
+      - name: Enable timestamp in the default length
+        environment:
+          ANSIBLE_NOCOLOR: 'true'
+          ANSIBLE_FORCE_COLOR: 'false'
+          ANSIBLE_STDOUT_CALLBACK: community.general.timestamp
+          ANSIBLE_CALLBACK_TIMESTAMP_FORMAT_STRING: "15:04:05"
+        playbook: |
+          - hosts: testhost
+            gather_facts: false
+            tasks:
+              - name: Sample task name
+                debug:
+                  msg: sample debug msg
+        expected_output: [
+          "",
+          "PLAY [testhost] ******************************************************* 15:04:05",
+          "",
+          "TASK [Sample task name] *********************************************** 15:04:05",
+          "ok: [testhost] => {",
+          "    \"msg\": \"sample debug msg\"",
+          "}",
+          "",
+          "PLAY RECAP ************************************************************ 15:04:05",
+          "testhost                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   "
+        ]
+
+      - name: Enable timestamp in the longer length
+        environment:
+          ANSIBLE_NOCOLOR: 'true'
+          ANSIBLE_FORCE_COLOR: 'false'
+          ANSIBLE_STDOUT_CALLBACK: community.general.timestamp
+          ANSIBLE_CALLBACK_TIMESTAMP_FORMAT_STRING: "2006-01-02T15:04:05"
+        playbook: |
+          - hosts: testhost
+            gather_facts: false
+            tasks:
+              - name: Sample task name
+                debug:
+                  msg: sample debug msg
+        expected_output: [
+          "",
+          "PLAY [testhost] ******************************************** 2006-01-02T15:04:05",
+          "",
+          "TASK [Sample task name] ************************************ 2006-01-02T15:04:05",
+          "ok: [testhost] => {",
+          "    \"msg\": \"sample debug msg\"",
+          "}",
+          "",
+          "PLAY RECAP ************************************************* 2006-01-02T15:04:05",
+          "testhost                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   "
+        ]
diff --git a/tests/sanity/ignore-2.13.txt b/tests/sanity/ignore-2.13.txt
index 954a8afebf..cfeaff7c31 100644
--- a/tests/sanity/ignore-2.13.txt
+++ b/tests/sanity/ignore-2.13.txt
@@ -1,4 +1,5 @@
 .azure-pipelines/scripts/publish-codecov.py replace-urlopen
+plugins/callback/timestamp.py validate-modules:invalid-documentation
 plugins/lookup/etcd.py validate-modules:invalid-documentation
 plugins/lookup/etcd3.py validate-modules:invalid-documentation
 plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice
diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt
index 01b195e9f5..247d43fe37 100644
--- a/tests/sanity/ignore-2.14.txt
+++ b/tests/sanity/ignore-2.14.txt
@@ -1,4 +1,5 @@
 .azure-pipelines/scripts/publish-codecov.py replace-urlopen
+plugins/callback/timestamp.py validate-modules:invalid-documentation
 plugins/lookup/etcd.py validate-modules:invalid-documentation
 plugins/lookup/etcd3.py validate-modules:invalid-documentation
 plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice