Repository: ansible-collections/community.general

Issue:
timezone module does not work on Gentoo
##### SUMMARY
Note: This was initially reported as ansible/ansible#47891.

The timezone module fails on a non-systemd Gentoo target.
The module checks for Debian and SuSE and then treats everything else as RedHat, i.e. do something to `/etc/sysconfig/clock`. This does not work on, at least, Gentoo, since `/etc/sysconfig` does not exist. The Gentoo way is described in [the Handbook](https://wiki.gentoo.org/wiki/Handbook:AMD64/Installation/Base#Timezone).

The TL;DR is:

```sh
echo 'Europe/Berlin' > /etc/timezone
emerge --config sys-libs/timezone-data
```

##### ISSUE TYPE
- Bug Report

##### COMPONENT NAME
timezone

##### ANSIBLE VERSION
```
ansible 2.9.10
  config file = None
  configured module search path = ['/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3.7/site-packages/ansible
  executable location = /usr/lib/python-exec/python3.7/ansible
  python version = 3.7.8 (default, Jul 24 2020, 18:32:17) [GCC 9.3.0]
```

##### CONFIGURATION

not relevant

##### OS / ENVIRONMENT

Target OS is Gentoo Linux.

##### STEPS TO REPRODUCE

```yaml
- tasks:
  - name: 'set time zone'
    timezone:
      name: 'Europe/Berlin'
```

##### EXPECTED RESULTS

The timezone on the target system is set correctly.

##### ACTUAL RESULTS

Ansible fails with the following error:

```
The full traceback is:
  File "/tmp/ansible_xxpihswr/ansible_module_timezone.py", line 456, in _edit_file
    file = open(filename, 'w')

fatal: [localhost]: FAILED! => {
    "changed": false,
    "invocation": {
        "module_args": {
            "hwclock": null,
            "name": "Europe/Berlin"
        }
    },
    "msg": "Error message:\ntried to configure name using a file \"/etc/sysconfig/clock\", but could not write to it"
}
```



Discussion:
Files identified in the description:
* [`plugins/modules/system/timezone.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/system/timezone.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @MorrisA @bcoca @d-little @flynn1973 @gforster @indrajitr @jasperla @kairoaraujo @marvin-sinister @mator @molekuul @ramooncamacho @tmshn @wtcross
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
This issue also happens on Alpine, for the same reasons: Lack of `/etc/sysconfig`.

Alpine is running in a VM, if that contributes to anything.
i failed with installing gentoo, but quickly installed alpine (like 5-10 minutes)... ansible current timezone code with my alpine installation is: 
```
$ ansible -m timezone alpine
alpine | FAILED! => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.8"
    },
    "changed": false,
    "msg": "one of the following is required: hwclock, name"
}
```
but hwclock is installed
```
alpine:~$ hwclock 
hwclock: Cannot access the Hardware Clock via any known method.
```
> i failed with installing gentoo

I did not try it, but you might be able to reproduce the issue in a [Gentoo container](https://hub.docker.com/u/gentoo/). Try `gentoo/stage3-amd64` or something like that.
@mator 

> but hwclock is installed

That is not a reproducer. Your command didn't actually invoke the module timezone detection code because you failed to pass any mandatory arguments (that is at least one of `hwclock` or `name` — as the error message says). It didn't go past the module helper instance constructor: https://github.com/ansible-collections/community.general/blob/8afdd23/plugins/modules/system/timezone.py#L872-L874.

The correct syntax would be
```console
$ ansible -m timezone -a 'name=Europe/Berlin' alpine
```
Here's a container-based repro demo:
[![asciicast](https://asciinema.org/a/388463.svg)](https://asciinema.org/a/388463)
> That is not a reproducer. Your command didn't actually invoke the module timezone detection code because you failed to pass any mandatory arguments (that is at least one of `hwclock` or `name` — as the error message says). 

1. the error message is so confusing for me, i thought that i miss some packages installed. We probably need to change it to  add "missing arguments", so it would be clearer.
2. i was under impression that if there's no arguments, timezone module would return current server timezone


running with arguments:
```
$ ansible -m timezone alpine -a 'name=europe/moscow'
alpine | FAILED! => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.8"
    },
    "changed": false,
    "msg": "Error message:\ngiven timezone \"europe/moscow\" is not available"
}

$ ansible -m timezone alpine -a 'name=Europe/Moscow'
alpine | FAILED! => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.8"
    },
    "changed": false,
    "msg": "Error message:\ntried to configure name using a file \"/etc/sysconfig/clock\", but could not write to it"
} 
```

The error message comes from here: https://github.com/ansible/ansible/blob/devel/lib/ansible/module_utils/common/validation.py#L95
tried the following diff for Alpine:
```
$ git diff main
diff --git a/plugins/modules/system/timezone.py b/plugins/modules/system/timezone.py
index d10dd9bb..cda0ab82 100644
--- a/plugins/modules/system/timezone.py
+++ b/plugins/modules/system/timezone.py
@@ -360,6 +360,7 @@ class NosystemdTimezone(Timezone):
             self.update_timezone = ['%s --remove-destination %s /etc/localtime' % (self.module.get_bin_path('cp', required=True), tzfile)]
         self.update_hwclock = self.module.get_bin_path('hwclock', required=True)
         # Distribution-specific configurations
+        distribution = get_distribution()
         if self.module.get_bin_path('dpkg-reconfigure') is not None:
             # Debian/Ubuntu
             if 'name' in self.value:
@@ -369,6 +370,11 @@ class NosystemdTimezone(Timezone):
             self.conf_files['hwclock'] = '/etc/default/rcS'
             self.regexps['name'] = re.compile(r'^([^\s]+)', re.MULTILINE)
             self.tzline_format = '%s\n'
+        elif distribution == 'Alpine':
+            self.conf_files['name'] = '/etc/timezone'
+            self.conf_files['hwclock'] = '/etc/conf.d/hwclock'
+            self.regexps['name'] = re.compile(r'^([^\s]+)', re.MULTILINE)
+            self.update_timezone = ['%s -z %s' % (self.module.get_bin_path('setup-timezone', required=True), tzfile)
         else:
             # RHEL/CentOS/SUSE
             if self.module.get_bin_path('tzdata-update') is not None:
@@ -386,7 +392,6 @@ class NosystemdTimezone(Timezone):
             except IOError as err:
                 if self._allow_ioerror(err, 'name'):
                     # If the config file doesn't exist detect the distribution and set regexps.
-                    distribution = get_distribution()
                     if distribution == 'SuSE':
                         # For SUSE
                         self.regexps['name'] = self.dist_regexps['SuSE']
```
but it returns: 
```
$ ansible -C -m timezone alpine -a "name=Europe/Moscow"
alpine | FAILED! => {
    "msg": "Unable to import timezone due to invalid syntax"
}
```
1. https://wiki.alpinelinux.org/wiki/Setting_the_timezone
2. https://wiki.alpinelinux.org/wiki/Alpine_setup_scripts#setup-timezone
```
+            self.update_timezone = ['%s -z %s' % (self.module.get_bin_path('setup-timezone', required=True), tzfile)
```
That line is missing a `]`, that's why you get a Python syntax error.
works now, but reports failed:
```
$ ansible  -m timezone alpine -a "name=Europe/Moscow" -b        
alpine | FAILED! => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "msg": "Error message:\nstill not desired state, though changes have made - planned: {'name': 'Europe/Moscow'}, after: {'name': 'n/a'}\nOther message(s):\nAdded 1 line and deleted 1 line(s) on /etc/timezone"
}
```
on the server:
```
alpine:~# cat /etc/timezone
Europe/Moscow
# ls -l /etc/localtime
lrwxrwxrwx    1 root     root            27 Feb  3 13:50 /etc/localtime -> /etc/zoneinfo/Europe/Moscow
#  ls -l /etc/zoneinfo/Europe/Moscow
-rw-r--r--    1 root     root          1535 Feb  3 13:50 /etc/zoneinfo/Europe/Moscow
# date
Wed Feb  3 13:53:02 MSK 2021
```
seems valid...

[code](https://github.com/ansible-collections/community.general/compare/main...mator:timezone):
```
$ git diff main | wc -l
39
```
You are trying to see if `/etc/localtime` is symlinked to current timezone, but that file does not exist in Alpine in this line:
https://github.com/ansible-collections/community.general/blob/b7368b9802f29b3b19d95991a28b0221a44aedf2/plugins/modules/system/timezone.py#L555

It's very conveniently wrapped in `try/except` block so you get no error here.
thanks @marvin-sinister
right, as well there's hardcoded `/usr/share/zoneinfo` in re.search in code:
https://github.com/ansible-collections/community.general/blob/2297f2f802f286abdc76bbae0c9d3aae44255554/plugins/modules/system/timezone.py#L538-L539
while `/etc/localtime` in Alpine points to `/etc/zoneinfo/...`
My alpine doesn't even have `/etc/localtime`:
```console
alpine01:~# ls /etc/localtime
ls: /etc/localtime: No such file or directory
```
ok, updated [code](https://github.com/ansible-collections/community.general/compare/main...mator:timezone?expand=1), now works correctly on Alpine linux:
before running ansible (after install, not used to setup timezone):
```
alpine:~# rm /etc/timezone /etc/localtime 
```
run:
```
$ ansible alpine -m timezone -b -a 'name=Europe/Berlin'
alpine | CHANGED => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": true,
    "msg": "Added 1 line and deleted 0 line(s) on /etc/timezone"
}
```
check with the server:
```
alpine:~# ls -l /etc/localtime 
lrwxrwxrwx    1 root     root            27 Feb  3 18:39 /etc/localtime -> /etc/zoneinfo/Europe/Berlin
alpine:~# cat /etc/timezone 
Europe/Berlin
alpine:~# date
Wed Feb  3 18:40:22 CET 2021
```
works correctly
tested with alpine:
```
alpine:~# cat /etc/os-release 
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.13.1
PRETTY_NAME="Alpine Linux v3.13"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://bugs.alpinelinux.org/"
```
@webknjaz thank you, your video (asci) of container gentoo really helped!

Can someone please test? Thanks.

gentoo linux test (in a docker container, image "gentoo/stage3-amd64", "Created": "2020-09-04T01:23:42.695649013Z"):
```
# docker run -it gentoo/stage3-amd64 /bin/bash
c1e5fca09e27 / # python -m venv ansible
c1e5fca09e27 / # source ansible/bin/activate
(ansible) c1e5fca09e27 / # pip install ansible
(ansible) c1e5fca09e27 / # ansible-galaxy collection  install community.general

>patch timezone.py here<

(ansible) c1e5fca09e27 / # ansible -m timezone localhost -a "name=Europe/Moscow"
[WARNING]: No inventory was parsed, only implicit localhost is available
localhost | CHANGED => {
    "changed": true,
    "msg": "Added 1 line and deleted 1 line(s) on /etc/timezone"
}
(ansible) c1e5fca09e27 / # cat /etc/timezone
Europe/Moscow
(ansible) c1e5fca09e27 / # ls -l /etc/localtime
-rw-r--r-- 1 root root 1535 Feb  4 00:10 /etc/localtime
(ansible) c1e5fca09e27 / # date
Thu Feb  4 00:10:48 MSK 2021
@mator created a PR to fix this: #1722 Please take a look and review and/or test it!
I tested on gentoo and alpine: https://github.com/ansible-collections/community.general/pull/1722#issuecomment-774335598

Resolving patch:
diff --git a/plugins/modules/system/timezone.py b/plugins/modules/system/timezone.py
--- a/plugins/modules/system/timezone.py
+++ b/plugins/modules/system/timezone.py
@@ -355,20 +355,26 @@ def __init__(self, module):
         # Validate given timezone
         if 'name' in self.value:
             tzfile = self._verify_timezone()
+            planned_tz = self.value['name']['planned']
             # `--remove-destination` is needed if /etc/localtime is a symlink so
             # that it overwrites it instead of following it.
             self.update_timezone = ['%s --remove-destination %s /etc/localtime' % (self.module.get_bin_path('cp', required=True), tzfile)]
         self.update_hwclock = self.module.get_bin_path('hwclock', required=True)
+        distribution = get_distribution()
+        self.conf_files['name'] = '/etc/timezone'
+        self.regexps['name'] = re.compile(r'^([^\s]+)', re.MULTILINE)
+        self.tzline_format = '%s\n'
         # Distribution-specific configurations
         if self.module.get_bin_path('dpkg-reconfigure') is not None:
             # Debian/Ubuntu
             if 'name' in self.value:
                 self.update_timezone = ['%s -sf %s /etc/localtime' % (self.module.get_bin_path('ln', required=True), tzfile),
                                         '%s --frontend noninteractive tzdata' % self.module.get_bin_path('dpkg-reconfigure', required=True)]
-            self.conf_files['name'] = '/etc/timezone'
             self.conf_files['hwclock'] = '/etc/default/rcS'
-            self.regexps['name'] = re.compile(r'^([^\s]+)', re.MULTILINE)
-            self.tzline_format = '%s\n'
+        elif distribution == 'Alpine' or distribution == 'Gentoo':
+            self.conf_files['hwclock'] = '/etc/conf.d/hwclock'
+            if distribution == 'Alpine':
+                self.update_timezone = ['%s -z %s' % (self.module.get_bin_path('setup-timezone', required=True), planned_tz)]
         else:
             # RHEL/CentOS/SUSE
             if self.module.get_bin_path('tzdata-update') is not None:
@@ -386,7 +392,6 @@ def __init__(self, module):
             except IOError as err:
                 if self._allow_ioerror(err, 'name'):
                     # If the config file doesn't exist detect the distribution and set regexps.
-                    distribution = get_distribution()
                     if distribution == 'SuSE':
                         # For SUSE
                         self.regexps['name'] = self.dist_regexps['SuSE']
@@ -536,7 +541,9 @@ def get(self, key, phase):
                         # to other zone files, so it's hard to get which TZ is actually set
                         # if we follow the symlink.
                         path = os.readlink('/etc/localtime')
-                        linktz = re.search(r'/usr/share/zoneinfo/(.*)', path, re.MULTILINE)
+                        # most linuxes has it in /usr/share/zoneinfo
+                        # alpine linux links under /etc/zoneinfo
+                        linktz = re.search(r'(?:/(?:usr/share|etc)/zoneinfo/)(.*)', path, re.MULTILINE)
                         if linktz:
                             valuelink = linktz.group(1)
                             if valuelink != planned:


Test patch: