Skip to content

Commit 612c59f

Browse files
authored
Improve crontab fact and operation (#1193)
- fix environment variables in cron tab entries - fix cron identification - fix handling of multiple same commands in cron tabs This is backwards compatible from a UX/operation API perspective. It is NOT backwards compatible from a strict types perspective.
1 parent a0399c9 commit 612c59f

20 files changed

+423
-273
lines changed

pyinfra/facts/crontab.py

+189
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import re
2+
from typing import Dict, List, Optional, TypedDict, Union
3+
4+
from typing_extensions import NotRequired
5+
6+
from pyinfra.api import FactBase
7+
from pyinfra.api.util import try_int
8+
9+
10+
class CrontabDict(TypedDict):
11+
command: NotRequired[str]
12+
# handles cases like CRON_TZ=UTC
13+
env: NotRequired[str]
14+
minute: NotRequired[Union[int, str]]
15+
hour: NotRequired[Union[int, str]]
16+
month: NotRequired[Union[int, str]]
17+
day_of_month: NotRequired[Union[int, str]]
18+
day_of_week: NotRequired[Union[int, str]]
19+
comments: NotRequired[List[str]]
20+
special_time: NotRequired[str]
21+
22+
23+
# for compatibility, also keeps a dict of command -> crontab dict
24+
class CrontabFile:
25+
commands: List[CrontabDict]
26+
27+
def __init__(self, input_dict: Optional[Dict[str, CrontabDict]] = None):
28+
super().__init__()
29+
self.commands = []
30+
if input_dict:
31+
for command, others in input_dict.items():
32+
val = others.copy()
33+
val["command"] = command
34+
self.add_item(val)
35+
36+
def add_item(self, item: CrontabDict):
37+
self.commands.append(item)
38+
39+
def __len__(self):
40+
return len(self.commands)
41+
42+
def __bool__(self):
43+
return len(self) > 0
44+
45+
def items(self):
46+
return {item.get("command") or item.get("env"): item for item in self.commands}
47+
48+
def get_command(
49+
self, command: Optional[str] = None, name: Optional[str] = None
50+
) -> Optional[CrontabDict]:
51+
assert command or name, "Either command or name must be provided"
52+
53+
name_comment = "# pyinfra-name={0}".format(name)
54+
for cmd in self.commands:
55+
if cmd.get("command") == command:
56+
return cmd
57+
if cmd.get("comments") and name_comment in cmd["comments"]:
58+
return cmd
59+
return None
60+
61+
def get_env(self, env: str) -> Optional[CrontabDict]:
62+
for cmd in self.commands:
63+
if cmd.get("env") == env:
64+
return cmd
65+
return None
66+
67+
def get(self, item: str) -> Optional[CrontabDict]:
68+
return self.get_command(command=item, name=item) or self.get_env(item)
69+
70+
def __getitem__(self, item) -> Optional[CrontabDict]:
71+
return self.get(item)
72+
73+
def __repr__(self):
74+
return f"CrontabResult({self.commands})"
75+
76+
# noinspection PyMethodMayBeStatic
77+
def format_item(self, item: CrontabDict):
78+
lines = []
79+
for comment in item.get("comments", []):
80+
lines.append(comment)
81+
82+
if "env" in item:
83+
lines.append(item["env"])
84+
elif "special_time" in item:
85+
lines.append(f"{item['special_time']} {item['command']}")
86+
else:
87+
lines.append(
88+
f"{item['minute']} {item['hour']} "
89+
f"{item['day_of_month']} {item['month']} {item['day_of_week']} "
90+
f"{item['command']}"
91+
)
92+
return "\n".join(lines)
93+
94+
def __str__(self):
95+
return "\n".join(self.format_item(item) for item in self.commands)
96+
97+
def to_json(self):
98+
return self.commands
99+
100+
101+
_crontab_env_re = re.compile(r"^\s*([A-Z_]+)=(.*)$")
102+
103+
104+
class Crontab(FactBase[CrontabFile]):
105+
"""
106+
Returns a dictionary of CrontabFile.
107+
108+
.. code:: python
109+
# CrontabFile.items()
110+
{
111+
"/path/to/command": {
112+
"minute": "*",
113+
"hour": "*",
114+
"month": "*",
115+
"day_of_month": "*",
116+
"day_of_week": "*",
117+
},
118+
"echo another command": {
119+
"special_time": "@daily",
120+
},
121+
}
122+
# or CrontabFile.to_json()
123+
[
124+
{
125+
command: "/path/to/command",
126+
minute: "*",
127+
hour: "*",
128+
month: "*",
129+
day_of_month: "*",
130+
day_of_week: "*",
131+
},
132+
{
133+
"command": "echo another command
134+
"special_time": "@daily",
135+
}
136+
]
137+
"""
138+
139+
default = CrontabFile
140+
141+
def requires_command(self, user=None) -> str:
142+
return "crontab"
143+
144+
def command(self, user=None):
145+
if user:
146+
return "crontab -l -u {0} || true".format(user)
147+
return "crontab -l || true"
148+
149+
def process(self, output):
150+
crons = CrontabFile()
151+
current_comments = []
152+
153+
for line in output:
154+
line = line.strip()
155+
if not line or line.startswith("#"):
156+
current_comments.append(line)
157+
continue
158+
159+
if line.startswith("@"):
160+
special_time, command = line.split(None, 1)
161+
item = CrontabDict(
162+
command=command,
163+
special_time=special_time,
164+
comments=current_comments,
165+
)
166+
crons.add_item(item)
167+
168+
elif _crontab_env_re.match(line):
169+
# handle environment variables
170+
item = CrontabDict(
171+
env=line,
172+
comments=current_comments,
173+
)
174+
crons.add_item(item)
175+
else:
176+
minute, hour, day_of_month, month, day_of_week, command = line.split(None, 5)
177+
item = CrontabDict(
178+
command=command,
179+
minute=try_int(minute),
180+
hour=try_int(hour),
181+
month=try_int(month),
182+
day_of_month=try_int(day_of_month),
183+
day_of_week=try_int(day_of_week),
184+
comments=current_comments,
185+
)
186+
crons.add_item(item)
187+
188+
current_comments = []
189+
return crons

pyinfra/facts/server.py

+6-71
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
import shutil
66
from datetime import datetime
77
from tempfile import mkdtemp
8-
from typing import Dict, List, Optional, Union
8+
from typing import Dict, List, Optional
99

1010
from dateutil.parser import parse as parse_date
1111
from distro import distro
12-
from typing_extensions import NotRequired, TypedDict
12+
from typing_extensions import TypedDict
1313

1414
from pyinfra.api import FactBase, ShortFactBase
1515
from pyinfra.api.util import try_int
16+
from pyinfra.facts import crontab
1617

1718
ISO_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
1819

@@ -407,75 +408,9 @@ def process(self, output) -> list[str]:
407408
return groups
408409

409410

410-
class CrontabDict(TypedDict):
411-
minute: NotRequired[Union[int, str]]
412-
hour: NotRequired[Union[int, str]]
413-
month: NotRequired[Union[int, str]]
414-
day_of_month: NotRequired[Union[int, str]]
415-
day_of_week: NotRequired[Union[int, str]]
416-
comments: Optional[list[str]]
417-
special_time: NotRequired[str]
418-
419-
420-
class Crontab(FactBase[Dict[str, CrontabDict]]):
421-
"""
422-
Returns a dictionary of cron command -> execution time.
423-
424-
.. code:: python
425-
426-
{
427-
"/path/to/command": {
428-
"minute": "*",
429-
"hour": "*",
430-
"month": "*",
431-
"day_of_month": "*",
432-
"day_of_week": "*",
433-
},
434-
"echo another command": {
435-
"special_time": "@daily",
436-
},
437-
}
438-
"""
439-
440-
default = dict
441-
442-
def requires_command(self, user=None) -> str:
443-
return "crontab"
444-
445-
def command(self, user=None):
446-
if user:
447-
return "crontab -l -u {0} || true".format(user)
448-
return "crontab -l || true"
449-
450-
def process(self, output):
451-
crons: dict[str, CrontabDict] = {}
452-
current_comments = []
453-
454-
for line in output:
455-
line = line.strip()
456-
if not line or line.startswith("#"):
457-
current_comments.append(line)
458-
continue
459-
460-
if line.startswith("@"):
461-
special_time, command = line.split(None, 1)
462-
crons[command] = {
463-
"special_time": special_time,
464-
"comments": current_comments,
465-
}
466-
else:
467-
minute, hour, day_of_month, month, day_of_week, command = line.split(None, 5)
468-
crons[command] = {
469-
"minute": try_int(minute),
470-
"hour": try_int(hour),
471-
"month": try_int(month),
472-
"day_of_month": try_int(day_of_month),
473-
"day_of_week": try_int(day_of_week),
474-
"comments": current_comments,
475-
}
476-
477-
current_comments = []
478-
return crons
411+
# for compatibility
412+
CrontabDict = crontab.CrontabDict
413+
Crontab = crontab.Crontab
479414

480415

481416
class Users(FactBase):

0 commit comments

Comments
 (0)