Merge ~dashmage/charm-logrotated:bug-2019990/random-time-cronjob into charm-logrotated:master

Proposed by Ashley James
Status: Merged
Approved by: Eric Chen
Approved revision: af917de4e101a494ca13550fbe29e9f882cde377
Merged at revision: b5d0cd927b51e2741af3b967b7cae8fd0393f8ef
Proposed branch: ~dashmage/charm-logrotated:bug-2019990/random-time-cronjob
Merge into: charm-logrotated:master
Diff against target: 421 lines (+190/-58)
4 files modified
src/config.yaml (+5/-2)
src/lib/lib_cron.py (+77/-34)
src/tests/unit/conftest.py (+2/-7)
src/tests/unit/test_logrotate.py (+106/-15)
Reviewer Review Type Date Requested Status
🤖 prod-jenkaas-bootstack (community) continuous-integration Approve
Eric Chen Approve
JamesLin Approve
BootStack Reviewers Pending
Review via email: mp+443391@code.launchpad.net

Commit message

Implement random time functionality for daily cronjob.

Description of the change

This branch contains changes to fix the random time setting for the `update-cron-daily-schedule`. It does this by calculating the total minutes between the start and end time and generating a random number in that range. This is then added to the start time to obtain a random time within the provided time ranges.

It also fixes the unit tests that have been written assuming that the start, end times were directly added to the crontab file.

To post a comment you must log in.
Revision history for this message
Ashley James (dashmage) wrote :

I found out that the existing logic [1] to validate the random time is also incorrect.

For example, "update-cron-daily-schedule=random,08:40,09:20" would be treated as an invalid entry but it is actually correct since end_time > start_time.

I will be fixing that and pushing it along with this MR since it's also related to the random time functionality.

[1]: https://git.launchpad.net/charm-logrotated/tree/src/lib/lib_cron.py#n198

Revision history for this message
Mert Kirpici (mertkirpici) :
Revision history for this message
Eric Chen (eric-chen) wrote :

As we discovered last time, cronhelper.install_cronjob and update_cron_daily_schedule will be triggered daily. Will it change the random time every day? I am thinking we should avoid this situation. What do you think?

review: Needs Information
Revision history for this message
Eric Chen (eric-chen) wrote (last edit ):

About test_random_time. Sorry that I do not have specific answer here but just leave some questions.

1. Should we verify "randominess" here?
2. If yes, should we execute it over 100000+ times to approve it? or how to approve it?
3. If no, mock the randInt function?

Please share your finding and conclusion. thanks!

review: Needs Information
Revision history for this message
JamesLin (jneo8) wrote :

Please see my inline comments.

review: Needs Fixing
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Ashley James (dashmage) wrote (last edit ):

> As we discovered last time, cronhelper.install_cronjob and update_cron_daily_schedule will be triggered daily. Will it change the random time every day? I am thinking we should avoid this situation. What do you think?

- Edited response -
The charm's cronjob will be triggered according to the schedule provided in `logrotate-cronjob-frequency` config option (hourly, daily, weekly, monthly). This cronjob would be triggering CronHelper.install_cronjob and update_cron_daily_schedule. And each time update_cron_daily_schedule is run, this will generate a new random time in case `update-cron-daily-schedule` config option is set with the "random".

In order to resolve this, we could remove the `install_cronjob` function call from the `main` function in lib_cron.py so that the charm's cronjob wouldn't set another random time except for install/config changed hook event or running juju action. As per what I understand, the charm's cronjob would only need to override the logrotate configs at a regular time, and it wouldn't need to install the cronjob as well.

But otherwise as well, even if we leave things as it is, the cron.daily jobs would still run only once a day (at a random time which might change) and I feel it's not a big problem.

---
> About test_random_time. Sorry that I do not have specific answer here but just leave some questions.

1. Should we verify "randominess" here?
2. If yes, should we execute it over 100000+ times to approve it? or how to approve it?
3. If no, mock the randInt function?

So in the unit test for the `get_random_time` method, I'm not actually verifying the "randomness" because we can safely assume that `random.randint` will generate a random number for us. Instead I'm testing whether the random time generated with a variety of inputs is actually within the time range (between start and end time).

For the other unit test `test_cron_daily_schedule` I'm mocking `random.randint`.

Revision history for this message
Mert Kirpici (mertkirpici) :
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
JamesLin (jneo8) wrote :

Please see my inline comments.

review: Needs Fixing
Revision history for this message
Eric Chen (eric-chen) wrote :

> ---
> > About test_random_time. Sorry that I do not have specific answer here but
> just leave some questions.
>
> 1. Should we verify "randominess" here?
> 2. If yes, should we execute it over 100000+ times to approve it? or how to
> approve it?
> 3. If no, mock the randInt function?
>
>
> So in the unit test for the `get_random_time` method, I'm not actually
> verifying the "randomness" because we can safely assume that `random.randint`
> will generate a random number for us. Instead I'm testing whether the random
> time generated with a variety of inputs is actually within the time range
> (between start and end time).

From my prospective, it's testing the "randomness".
Or May I ask another question from another direction:
After the unit test pass, can we 100% confirm the return value is actually within the time range? I don't think it was been proofed. We only test the "get_random_time" less than 10 times.

Revision history for this message
Eric Chen (eric-chen) wrote :

I prefer to remove "install_cronjob" in the charm's cronjob if it doesn't voliate the intention in this issue.
https://bugs.launchpad.net/charm-logrotated/+bug/1832296

Please review the code and confirm the impact again.
Thanks!

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Ashley James (dashmage) wrote :

> From my prospective, it's testing the "randomness".
Or May I ask another question from another direction:
After the unit test pass, can we 100% confirm the return value is actually within the time range? I don't think it was been proofed. We only test the "get_random_time" less than 10 times.

In the logic that I've written, the random number is generated between 0 and the total number of minutes within the provided time range and added to the start time. We can trust that random.randint will only return a number between 0 and the total minutes within the range. This will 100% return a random time between the start and end time.

But if you're asking about how "random" is the random.randint function that ships with Python -- I believe we wouldn't need to test that and we can assume that the randomness provided by the random module in Python is good enough for our purposes.

I was only testing it with a handful of test cases just for sanity. I can add few more test cases if you'd like but I'm not sure how else we can test the `get_random_time` method. I'm open to any suggestions for this.

---
> I prefer to remove "install_cronjob" in the charm's cronjob if it doesn't voliate the intention in this issue.
https://bugs.launchpad.net/charm-logrotated/+bug/1832296

Thanks for sharing the bug report. After taking some time to review it and thinking about the overall intention for the charm, I agree in removing the "install_cronjob". I've pushed another commit for this and provided ample reasoning as part of the commit message.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Eric Chen (eric-chen) wrote :

> In the logic that I've written, the random number is generated between 0 and the
> total number of minutes within the provided time range and added to the start
> time. We can trust that random.randint will only return a number between 0 and
> the total minutes within the range. This will 100% return a random time between
> the start and end time.

When imeplemnting unit test, we should not have assumption how will the function been implemented. Because the implementation may be changed one day but the unit test should still work.

For example, I can pass current unit test very easily by this implementation

+ def get_random_time(self, time_range):
+ # Convert start and end times to integers
+ start_hour, start_minute, end_hour, end_minute = [int(t) for t in time_range]
+ return str(start_hour), str(start_minute)+1

Therefore, I don't agree the unit test of "get_random_time" proof the function work here.

review: Needs Fixing
Revision history for this message
Ashley James (dashmage) wrote :

In the latest commit, I've modified the unit test for "get_random_time" so that it executes the function 5 times and captures the output each time in a set. Each output is checked for whether it lies within the provided time range and after that, the set is checked for whether there is atleast 2 distinct elements so we can make sure the "get_random_time" function isn't returning some static output.

True, we can have a better random check with all outputs of the function but that would not be fruitful since we aren't trying to test `random.randint` here but only:
a. The function outputs a time within the provided range
b. The time is random enough for the logrotate charm's purposes

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Eric Chen (eric-chen) wrote (last edit ):

Please check my inline comment.

I also put my thought in summary:

1. unit test of a function should cover the corner case, no matter it is possible or impossible under current program logic. We should clear define the expected behavior under normal situation and abnormal situation.

2. prove "all the possibility values could be generated from the functions" is valuable and the implementation is not too complicated compare to current version

review: Needs Fixing
Revision history for this message
Ashley James (dashmage) wrote :

> 1. unit test of a function should cover the corner case, no matter it is possible or impossible under current program logic. We should clear define the expected behavior under normal situation and abnormal situation.

> I suggest to add corner cases. What is the expected behavior under error parameters in this function?

1) start_time = end_time
2) start_time < end_time
3) any wrong format of time_range

All the three mentioned "corner cases" or invalid inputs are addressed in the `validate_cron_daily_schedule_conf` method. If you firmly believe the `get_random_time` function should also check these invalid inputs, then I can add a call to the same validate method at the beginning.

But considering the code logic, this validation is already done beforehand and only then does the code execution reach the `get_random_time` method. So I strongly feel that the unit test for `get_random_time` does not need to have test cases for invalid inputs and there are already other unit tests (test_{valid,invalid}_cron_daily_schedule) which test this validation of the input.

The unit tests for `CronHelper.write_to_crontab` and `CronHelper.update_cron_daily_schedule` also does not have invalid inputs as part of its test cases for the similar reason that the validation happens beforehand in `CronHelper.install_cronjob` and these invalid inputs will be caught already.

---
> 2. prove "all the possibility values could be generated from the functions" is valuable and the implementation is not too complicated compare to current version

I have implemented this.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Eric Chen (eric-chen) wrote :

1.
_check_time_in_range is no longer needed in test_get_random_time. Because the validation of the full set already implies all the values are correct.

2.
How long will test_get_random_time run? If it took too much time, we can reduce the range between start_time and end_time. But if the range is too small, then I prefer to separate it into two unit cases for different purpose.

- validate the return value is valid under different start_time / end_time.
- validate all possible datetime would be generated from the fucntion.

3. The corner case is mandatory for each function. We never know how the code logic would be implemented. In ideal situation, the unit test should be finished before writing the function. I mentioned this concept several times. Therefore, we should avoid to decide the unit test case scope based on the logic in real code.

4.
In get_random_time , it’s okay to skip the parameter validation and error handling. (Even though I think it’s better to have error handling). But we still need to know the expected behavior under invalid parameters and add unit tests.
https://github.com/python/cpython/blob/9a68ff12c3e647a4f8dd935919ae296593770a6b/Lib/test/test_random.py#L502

5.
Thanks for the reminder. Yes, we need the corner case for write_to_crontab and update_cron_daily_schedule too. That is a technical debt that is not covered in this MR.

review: Needs Fixing
Revision history for this message
Ashley James (dashmage) wrote :

1. I've removed _check_time_in_range. Since we're already generating and validating the entire set of minutes within the range, it makes sense that the check wouldn't be needed.

2. `test_get_random_time` does not take too much extra time to run. Maybe another extra second or 2. So I feel like the existing test case is OK.

3. Alright, I understand the concept. Will implement this.

4. For `get_random_time`, I've added another unit test to check for the invalid parameters as well now.

Revision history for this message
Eric Chen (eric-chen) wrote (last edit ):

LGTM for the code.

But I just realize these three functions can be staticmethod.

get_random_time
_validate_random_schedule
_valid_timestamp

review: Needs Fixing
Revision history for this message
Ashley James (dashmage) wrote (last edit ):

>
> LGTM for the code.
>
> But I just realize these three functions can be staticmethod.
>
> get_random_time
> _validate_random_schedule
> _valid_timestamp

For some reason, making the methods as staticmethod was breaking many of the unit tests and I wasn't able to find a way to fix it for pytest. I've used classmethods instead. So "self.<method>" is replaced with "self.__class__.<method>" in the regular instance methods when they are calling the class methods. The unit test also does not work if I try to refer to them as "CronHelper.<method>".

Revision history for this message
Eric Chen (eric-chen) wrote :

There is no sufficient reason to use classmethod.

review: Needs Fixing
Revision history for this message
JamesLin (jneo8) wrote :

> For some reason, making the methods as staticmethod was breaking many of the unit tests and I wasn't able to find a way to fix it for pytest. I've used classmethods instead. So "self.<method>" is replaced with "self.__class__.<method>" in the regular instance methods when they are calling the class methods. The unit test also does not work if I try to refer to them as "CronHelper.<method>".

I believe the major use case of classmethod is not for access the other methods in the same class. I mean it's workable but not that suitable. The purpose of get_random_time is very simple and it's make sense to become a static like method. You can try to find a way to resolve this function dependency issue and fix the unit tests.

review: Needs Fixing
Revision history for this message
Ashley James (dashmage) wrote (last edit ):

I've replaced the class methods with static methods now.

The unit tests were breaking because of the monkeypatching of CronHelper done in conftest.py. Instead of monkeypatching the Class, I'm now having it return the Class itself and this will allow for accessing the static methods of the class. The monkeypatching is unnecessary since we want to call the actual methods of the class anyway and if we want to refer to the static methods through the class name, the monkeypatch will not work.

The static methods were breaking while monkeypatching CronHelper because any calls to the static method, for example,`CronHelper._validate_random_schedule()` in the method being tested returned an error saying "AttributeError: 'function' object has no attribute '_validate_random_schedule'".

Revision history for this message
Eric Chen (eric-chen) wrote :

Please check my inline comment for the validation function

review: Needs Fixing
Revision history for this message
Ashley James (dashmage) wrote (last edit ):

Latest changes:

- Modified the arguments being used by `_validate_random_schedule`, `get_random_time`, `_validate_set_schedule` to be more coherent. They all now accept either a single timestamp or two timestamps (for the methods dealing with random) in the format "08:30".

- The `_validate_random_schedule` is not being called again in the `get_random_time` method. Instead, the test for the invalid timestamps for get_random_time are just compared with their expected output which might not be correct according to the code logic. For example, if start_time = end_time say 07:30, according to the code logic it will raise a ValueError. But the test_get_random_time checks only the functionality of the get_random_time method and checks if the output is the expected 07:30 (since there is no other time within the range).

Revision history for this message
Eric Chen (eric-chen) wrote :

Overall it's good. Two small things in inline comment

review: Needs Fixing
Revision history for this message
Ashley James (dashmage) wrote :

Addressed both inline comments!

Revision history for this message
JamesLin (jneo8) wrote :

LGTM now.

review: Approve
Revision history for this message
Eric Chen (eric-chen) wrote :

LGTM

review: Approve
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision b5d0cd927b51e2741af3b967b7cae8fd0393f8ef

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/config.yaml b/src/config.yaml
2index 2a14f0b..def9295 100644
3--- a/src/config.yaml
4+++ b/src/config.yaml
5@@ -29,13 +29,16 @@ options:
6 type: string
7 default: 'unset'
8 description: |
9- If logrotate-cronjob-frequency is set to 'daily' then this value determines whether
10- to update the cron.daily schedule.
11+ This value determines the time schedule with which the cron.daily job in
12+ /etc/crontab runs. This is done in order to control the execution time for
13+ the `/etc/cron.daily/logrotate` job.
14 Valid options:
15 'unset': No change to default cron.daily schedule.
16 'set,HOUR:MINUTE': cron.daily schedule will be set to timestamp HOUR:MINUTE (24H) daily.
17 'random,START_HOUR:START_MINUTE,END_HOUR:END_MINUTE': cron.daily schedule will be set
18 to some random value between START_HOUR:START_MINUTE,END_HOUR:END_MINUTE (24H) daily.
19+ Note that all cron.daily jobs are affected by this config. They still run once a
20+ day but according to the time specified here.
21 override:
22 type: string
23 default: '[]'
24diff --git a/src/lib/lib_cron.py b/src/lib/lib_cron.py
25index f2383cc..94352d0 100644
26--- a/src/lib/lib_cron.py
27+++ b/src/lib/lib_cron.py
28@@ -1,6 +1,8 @@
29 """Cron helper module."""
30 import os
31+import random
32 import re
33+from datetime import datetime
34
35 from charmhelpers.core import hookenv
36
37@@ -70,8 +72,7 @@ class CronHelper:
38 cron_file.write(cron_job)
39 os.chmod(cron_file_path, 0o755)
40
41- # update cron.daily schedule if logrotate-cronjob-frequency set to "daily"
42- if self.cronjob_frequency == 1 and self.validate_cron_conf():
43+ if self.validate_cron_daily_schedule_conf():
44 self.update_cron_daily_schedule()
45 else:
46 self.cleanup_etc_config()
47@@ -108,11 +109,9 @@ class CronHelper:
48
49 elif schedule_type == "random":
50 cron_daily_start, _, cron_daily_end = schedule_value.partition(",")
51- cron_end_hour, _, cron_end_minute = cron_daily_end.partition(":")
52- cron_start_hour, _, cron_start_minute = cron_daily_start.partition(":")
53-
54- cron_daily_hour = cron_start_hour + "~" + cron_end_hour
55- cron_daily_minute = cron_start_minute + "~" + cron_end_minute
56+ cron_daily_hour, cron_daily_minute = CronHelper.get_random_time(
57+ cron_daily_start, cron_daily_end
58+ )
59
60 elif schedule_type == "unset":
61 # Revert to default ubuntu/debian values for daily cron job
62@@ -126,6 +125,31 @@ class CronHelper:
63
64 return cron_daily_timestamp
65
66+ @staticmethod
67+ def get_random_time(start_time, end_time):
68+ """Return a random time between the provided time range.
69+
70+ start_time and end_time are both timestamps in the format "08:30".
71+ A random time between start_time and end_time is returned as
72+ two values for the random_hour and random_minute strings.
73+ """
74+ # Convert start and end times to integers
75+ start_hour, start_minute = [int(t) for t in start_time.split(":")]
76+ end_hour, end_minute = [int(t) for t in end_time.split(":")]
77+
78+ # Calculate the total number of minutes between the start and end times
79+ total_start_minutes = start_hour * 60 + start_minute
80+ total_end_minutes = end_hour * 60 + end_minute
81+ total_minutes_range = total_end_minutes - total_start_minutes
82+
83+ # Generate a random number of minutes within the range
84+ random_minutes = random.randint(0, total_minutes_range)
85+
86+ # Calculate the hour and minute components of the random time
87+ random_hour = (total_start_minutes + random_minutes) // 60
88+ random_minute = (total_start_minutes + random_minutes) % 60
89+ return str(random_hour), str(random_minute)
90+
91 def write_to_crontab(self, cron_daily_timestamp):
92 """Write daily cronjob with provided timestamp to /etc/crontab."""
93 cron_pattern = re.compile(r".*\/etc\/cron.daily.*")
94@@ -136,7 +160,7 @@ class CronHelper:
95 updated_cron_daily = ""
96 if cron_daily:
97 updated_cron_daily = re.sub(
98- r"\d?\d(~\d\d)?\s\d?\d(~\d\d)?\t",
99+ r"\d?\d\s\d?\d\t",
100 cron_daily_timestamp + "\t",
101 cron_daily[0],
102 )
103@@ -144,8 +168,11 @@ class CronHelper:
104 with open(r"/etc/crontab", "w") as crontab:
105 crontab.write(updated_data)
106
107- def validate_cron_conf(self):
108- """Block the unit and exit the hook if there is invalid configuration."""
109+ def validate_cron_daily_schedule_conf(self):
110+ """Validate configuration for update-cron-daily-schedule.
111+
112+ Block the unit and exit the hook in case of invalid configuration.
113+ """
114 try:
115 conf = self.cron_daily_schedule
116 conf = conf.split(",")
117@@ -158,9 +185,9 @@ class CronHelper:
118 result = True
119 # run additional validation functions
120 if operation == "set":
121- result = self._validate_set_schedule(conf)
122+ result = CronHelper._validate_set_schedule(conf[1])
123 elif operation == "random":
124- result = self._validate_random_schedule(conf)
125+ result = CronHelper._validate_random_schedule(conf[1], conf[2])
126
127 return result
128
129@@ -174,40 +201,58 @@ class CronHelper:
130 )
131 raise self.InvalidCronConfig(err)
132
133- def _validate_set_schedule(self, conf):
134- """Validate update-cron-daily-schedule when the "set" keyword exists."""
135- cron_daily_time = conf[1].split(":")
136- if not self._valid_timestamp(cron_daily_time):
137+ @staticmethod
138+ def _validate_set_schedule(cron_daily_time):
139+ """Validate update-cron-daily-schedule when the "set" keyword exists.
140+
141+ cron_daily_time is a time in the format "08:30".
142+ """
143+ if not CronHelper._valid_timestamp(cron_daily_time):
144 raise ValueError(
145 "Invalid value for update-cron-daily-schedule: \
146 {}".format(
147- conf[1]
148+ cron_daily_time
149 )
150 )
151 else:
152 return True
153
154- def _validate_random_schedule(self, conf):
155- """Validate update-cron-daily-schedule when the "random" keyword exists."""
156- cron_daily_start_time = conf[1].split(":")
157- cron_daily_end_time = conf[2].split(":")
158+ @staticmethod
159+ def _validate_random_schedule(start_time, end_time):
160+ """Validate update-cron-daily-schedule when the "random" keyword exists.
161+
162+ start_time and end_time are timestamps in the format "08:30".
163+ """
164 if not (
165- self._valid_timestamp(cron_daily_start_time)
166- and self._valid_timestamp(cron_daily_end_time)
167- and int(cron_daily_start_time[0]) <= int(cron_daily_end_time[0])
168- and int(cron_daily_start_time[1]) <= int(cron_daily_end_time[1])
169+ CronHelper._valid_timestamp(start_time)
170+ and CronHelper._valid_timestamp(end_time)
171 ):
172+ raise ValueError("Invalid time provided for random schedule.")
173+
174+ start_time_datetime = datetime.strptime(start_time, "%H:%M")
175+ end_time_datetime = datetime.strptime(end_time, "%H:%M")
176+ if start_time_datetime < end_time_datetime:
177+ return True
178+ elif start_time_datetime == end_time_datetime:
179 raise ValueError(
180- "Invalid value for update-cron-daily-schedule: \
181- {},{}".format(
182- conf[1], conf[2]
183- )
184+ f"Random time range is invalid."
185+ f"Start time ({start_time}) and "
186+ f"end time ({end_time}) can't be equal."
187 )
188 else:
189- return True
190+ raise ValueError(
191+ f"Random time range is invalid."
192+ f"Start time ({start_time}) should not be "
193+ f"after end time ({end_time})."
194+ )
195
196- def _valid_timestamp(self, timestamp):
197- """Validate the timestamp."""
198+ @staticmethod
199+ def _valid_timestamp(timestamp):
200+ """Validate the timestamp.
201+
202+ timestamp is in the format "08:30".
203+ """
204+ timestamp = timestamp.split(":")
205 return int(timestamp[0]) in range(24) and int(timestamp[1]) in range(60)
206
207
208@@ -216,9 +261,7 @@ def main():
209 hookenv.log("Executing cron job.", level=hookenv.INFO)
210 hookenv.status_set("maintenance", "Executing cron job.")
211 cronhelper = CronHelper()
212- cronhelper.read_config()
213 cronhelper.update_logrotate_etc()
214- cronhelper.install_cronjob()
215 hookenv.log("Cron job completed.", level=hookenv.INFO)
216 hookenv.status_set("active", "Unit is ready.")
217
218diff --git a/src/tests/unit/conftest.py b/src/tests/unit/conftest.py
219index 9eff063..d6bbe2e 100644
220--- a/src/tests/unit/conftest.py
221+++ b/src/tests/unit/conftest.py
222@@ -80,13 +80,8 @@ def logrotate(tmpdir, mock_hookenv_config, mock_charm_dir, monkeypatch):
223
224
225 @pytest.fixture
226-def cron(monkeypatch):
227+def cron():
228 """Cron fixture."""
229 from lib_cron import CronHelper
230
231- helper = CronHelper
232-
233- # Any other functions that load helper will get this version
234- monkeypatch.setattr("lib_cron.CronHelper", lambda: helper)
235-
236- return helper
237+ return CronHelper
238diff --git a/src/tests/unit/test_logrotate.py b/src/tests/unit/test_logrotate.py
239index 3af2c13..207661f 100644
240--- a/src/tests/unit/test_logrotate.py
241+++ b/src/tests/unit/test_logrotate.py
242@@ -233,35 +233,98 @@ class TestCronHelper:
243 cron_config.cronjob_frequency = 1
244 cron_config.cron_daily_schedule = "unset"
245
246- assert cron_config.validate_cron_conf()
247+ assert cron_config.validate_cron_daily_schedule_conf()
248
249 @pytest.mark.parametrize(
250- ("cron_schedule, exp_pattern"),
251+ ("cron_schedule, random_hour, random_minute, exp_pattern"),
252 [
253- ("random,06:00,07:50", "00~50 06~07"),
254- ("random,06:00,07:00", "00~00 06~07"),
255- ("random,07:00,07:45", "00~45 07~07"),
256- ("set,08:00", "00 08"),
257- ("unset", "25 6"),
258+ ("random,06:00,07:50", "6", "30", "30 6"),
259+ ("random,07:00,09:00", "9", "5", "5 9"),
260+ ("random,07:10,10:45", "10", "10", "10 10"),
261+ ("set,08:00", "0", "0", "00 08"),
262+ ("unset", "0", "0", "25 6"),
263 ],
264 )
265- def test_cron_daily_schedule(self, cron, cron_schedule, exp_pattern, mocker):
266+ def test_update_cron_daily_schedule(
267+ self, cron, cron_schedule, random_hour, random_minute, exp_pattern, mocker
268+ ):
269 """Test the validate and update random cron.daily schedule."""
270 cron_config = cron()
271 cron_config.cronjob_enabled = True
272 cron_config.cronjob_frequency = 1
273 cron_config.cron_daily_schedule = cron_schedule
274
275- mock_method = mocker.Mock()
276- mocker.patch.object(cron, "write_to_crontab", new=mock_method)
277+ mock_write_to_crontab = mocker.Mock()
278+ mocker.patch.object(cron, "write_to_crontab", new=mock_write_to_crontab)
279+
280+ mock_get_random_time = mocker.Mock()
281+ mocker.patch.object(cron, "get_random_time", new=mock_get_random_time)
282+ mock_get_random_time.return_value = random_hour, random_minute
283
284 updated_cron_daily = cron_config.update_cron_daily_schedule()
285
286- assert cron_config.validate_cron_conf()
287+ assert cron_config.validate_cron_daily_schedule_conf()
288 assert updated_cron_daily.split("\t")[0] == exp_pattern
289- mock_method.assert_called_once_with(exp_pattern)
290+ mock_write_to_crontab.assert_called_once_with(exp_pattern)
291+
292+ @pytest.mark.parametrize(
293+ ("start_time", "end_time"),
294+ [
295+ ("06:30", "09:45"),
296+ ("7:42", "12:35"),
297+ ("18:10", "21:45"),
298+ ("2:8", "4:6"),
299+ ("7:30", "7:30"),
300+ ("30:25", "32:40"),
301+ ],
302+ )
303+ def test_get_random_time(self, cron, start_time, end_time):
304+ """Test random time setting for update-cron-daily-schedule."""
305+ cron_config = cron()
306
307- @pytest.mark.parametrize("cron_daily_timestamp", ["00 08", "25 6", "00~50 06~07"])
308+ # generate set containing all possible minutes within time_range
309+ start_hour, start_minute = [int(t) for t in start_time.split(":")]
310+ end_hour, end_minute = [int(t) for t in end_time.split(":")]
311+ all_minutes_in_range = set()
312+ current_hour = start_hour
313+ current_minute = start_minute
314+ while current_hour < end_hour or (
315+ current_hour == end_hour and current_minute <= end_minute
316+ ):
317+ all_minutes_in_range.add(f"{current_hour}:{current_minute}")
318+ current_minute += 1
319+ if current_minute == 60:
320+ current_minute = 0
321+ current_hour += 1
322+
323+ # run `get_random_time` multiple times, collect output and
324+ # see if we're able to obtain all possible minutes in range.
325+ random_time_set = set()
326+ for _ in range(5000):
327+ random_hour, random_minute = cron_config.get_random_time(
328+ start_time, end_time
329+ )
330+ random_time = f"{random_hour}:{random_minute}"
331+ random_time_set.add(random_time)
332+
333+ assert all_minutes_in_range == random_time_set
334+
335+ @pytest.mark.parametrize(
336+ ("start_time", "end_time"),
337+ [
338+ ("09:30", "06:45"), # start_time > end_time
339+ ("130:45", "18:125"), # invalid time
340+ ],
341+ )
342+ def test_invalid_get_random_time(self, cron, start_time, end_time):
343+ """Test invalid time range arguments for get_random_time."""
344+ cron_config = cron()
345+ with pytest.raises(ValueError):
346+ cron_config.get_random_time(start_time, end_time)
347+
348+ @pytest.mark.parametrize(
349+ "cron_daily_timestamp", ["00 08", "25 6", "40 18", "5 7", "20 4", "5 35"]
350+ )
351 def test_write_to_crontab(self, cron, cron_daily_timestamp, mocker):
352 """Test function that writes updated data to /etc/crontab."""
353 cron_config = cron()
354@@ -297,23 +360,48 @@ class TestCronHelper:
355 @pytest.mark.parametrize(
356 ("cron_schedule"),
357 [
358+ ("random,06:00,07:00"),
359+ ("random,7:50,9:30"),
360+ ("random,15:00,19:30"),
361+ ("random,08:40,09:20"),
362+ ("set,8:00"),
363+ ("set,12:10"),
364+ ("unset"),
365+ ],
366+ )
367+ def test_valid_cron_daily_schedule(self, cron, cron_schedule):
368+ """Test valid configuration for cron.daily schedule."""
369+ cron_config = cron()
370+ cron_config.cronjob_enabled = True
371+ cron_config.cronjob_frequency = 1
372+ cron_config.cron_daily_schedule = cron_schedule
373+ try:
374+ cron_config.validate_cron_daily_schedule_conf()
375+ except cron_config.InvalidCronConfig:
376+ pytest.fail("InvalidCronConfig should not be raised.")
377+
378+ @pytest.mark.parametrize(
379+ ("cron_schedule"),
380+ [
381 ("random,07:00,06:50"),
382 ("random,07:50,07:00"),
383 ("random,59:50,07:00"),
384 ("random,07:00,39:00"),
385+ ("random,09:20,08:40"),
386 ("set,28:00"),
387 ("set,02:80"),
388+ ("invalid_setting"),
389 ],
390 )
391 def test_invalid_cron_daily_schedule(self, cron, cron_schedule):
392- """Test the validate and update random cron.daily schedule."""
393+ """Test invalid configuration for cron.daily schedule."""
394 cron_config = cron()
395 cron_config.cronjob_enabled = True
396 cron_config.cronjob_frequency = 1
397 cron_config.cron_daily_schedule = cron_schedule
398
399 with pytest.raises(cron_config.InvalidCronConfig) as err:
400- cron_config.validate_cron_conf()
401+ cron_config.validate_cron_daily_schedule_conf()
402
403 assert err.type == cron_config.InvalidCronConfig
404
405@@ -330,6 +418,8 @@ class TestCronHelper:
406 mocker.patch("lib_cron.os.getcwd", return_value=mock_charm_dir)
407 mock_open = mocker.patch("lib_cron.open")
408 mock_handle = mock_open.return_value.__enter__.return_value
409+ mock_write_to_crontab = mocker.Mock()
410+ mocker.patch.object(cron, "write_to_crontab", new=mock_write_to_crontab)
411 expected_files_to_be_removed = [
412 "/etc/cron.hourly/charm-logrotate",
413 "/etc/cron.daily/charm-logrotate",
414@@ -340,6 +430,7 @@ class TestCronHelper:
415 cron_config = cron()
416 cron_config.cronjob_enabled = True
417 cron_config.cronjob_frequency = 2
418+ cron_config.cron_daily_schedule = "unset"
419 cron_config.install_cronjob()
420
421 mock_exists.assert_has_calls(

Subscribers

People subscribed via source and target branches

to all changes: