test_pyobjects.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. import logging
  2. import pathlib
  3. import shutil
  4. import tempfile
  5. import textwrap
  6. import uuid
  7. import jinja2
  8. import salt.config
  9. import salt.state
  10. import salt.utils.files
  11. import salt.utils.yaml
  12. from salt.template import compile_template
  13. from salt.utils.odict import OrderedDict
  14. from salt.utils.pyobjects import (
  15. DuplicateState,
  16. InvalidFunction,
  17. Registry,
  18. SaltObject,
  19. State,
  20. StateFactory,
  21. )
  22. from tests.support.helpers import slowTest
  23. from tests.support.runtests import RUNTIME_VARS
  24. from tests.support.unit import TestCase
  25. log = logging.getLogger(__name__)
  26. class MapBuilder:
  27. def build_map(self, template=None):
  28. """
  29. Build from a specific template or just use a default if no template
  30. is passed to this function.
  31. """
  32. map_prefix = textwrap.dedent(
  33. """\
  34. #!pyobjects
  35. from salt.utils.pyobjects import StateFactory
  36. Service = StateFactory('service')
  37. {% macro priority(value) %}
  38. priority = {{ value }}
  39. {% endmacro %}
  40. class Samba(Map):
  41. """
  42. )
  43. map_suffix = textwrap.dedent(
  44. """\
  45. with Pkg.installed("samba", names=[Samba.server, Samba.client]):
  46. Service.running("samba", name=Samba.service)
  47. """
  48. )
  49. map_data = {
  50. "debian": " class Debian:\n"
  51. " server = 'samba'\n"
  52. " client = 'samba-client'\n"
  53. " service = 'samba'\n",
  54. "centos": " class RougeChapeau:\n"
  55. " __match__ = 'RedHat'\n"
  56. " server = 'samba'\n"
  57. " client = 'samba'\n"
  58. " service = 'smb'\n",
  59. "ubuntu": " class Ubuntu:\n"
  60. " __grain__ = 'os'\n"
  61. " service = 'smbd'\n",
  62. }
  63. if template is None:
  64. template = textwrap.dedent(
  65. """\
  66. {{ ubuntu }}
  67. {{ centos }}
  68. {{ debian }}
  69. """
  70. )
  71. full_template = map_prefix + template + map_suffix
  72. ret = jinja2.Template(full_template).render(**map_data)
  73. log.debug("built map: \n%s", ret)
  74. return ret
  75. class StateTests(TestCase):
  76. @classmethod
  77. def setUpClass(cls):
  78. cls.File = StateFactory("file")
  79. @classmethod
  80. def tearDownClass(cls):
  81. cls.File = None
  82. def setUp(self):
  83. Registry.empty()
  84. self.pydmesg_expected = {
  85. "file.managed": [
  86. {"group": "root"},
  87. {"mode": "0755"},
  88. {"require": [{"file": "/usr/local/bin"}]},
  89. {"source": "salt://debian/files/pydmesg.py"},
  90. {"user": "root"},
  91. ]
  92. }
  93. self.pydmesg_salt_expected = OrderedDict(
  94. [("/usr/local/bin/pydmesg", self.pydmesg_expected)]
  95. )
  96. self.pydmesg_kwargs = dict(
  97. user="root",
  98. group="root",
  99. mode="0755",
  100. source="salt://debian/files/pydmesg.py",
  101. )
  102. def tearDown(self):
  103. self.pydmesg_expected = self.pydmesg_salt_expected = self.pydmesg_kwargs = None
  104. def test_serialization(self):
  105. f = State(
  106. "/usr/local/bin/pydmesg",
  107. "file",
  108. "managed",
  109. require=self.File("/usr/local/bin"),
  110. **self.pydmesg_kwargs
  111. )
  112. self.assertEqual(f(), self.pydmesg_expected)
  113. def test_factory_serialization(self):
  114. self.File.managed(
  115. "/usr/local/bin/pydmesg",
  116. require=self.File("/usr/local/bin"),
  117. **self.pydmesg_kwargs
  118. )
  119. self.assertEqual(
  120. Registry.states["/usr/local/bin/pydmesg"], self.pydmesg_expected
  121. )
  122. def test_context_manager(self):
  123. with self.File("/usr/local/bin"):
  124. pydmesg = self.File.managed("/usr/local/bin/pydmesg", **self.pydmesg_kwargs)
  125. self.assertEqual(
  126. Registry.states["/usr/local/bin/pydmesg"], self.pydmesg_expected
  127. )
  128. with pydmesg:
  129. self.File.managed("/tmp/something", owner="root")
  130. self.assertEqual(
  131. Registry.states["/tmp/something"],
  132. {
  133. "file.managed": [
  134. {"owner": "root"},
  135. {
  136. "require": [
  137. {"file": "/usr/local/bin"},
  138. {"file": "/usr/local/bin/pydmesg"},
  139. ]
  140. },
  141. ]
  142. },
  143. )
  144. def test_salt_data(self):
  145. self.File.managed(
  146. "/usr/local/bin/pydmesg",
  147. require=self.File("/usr/local/bin"),
  148. **self.pydmesg_kwargs
  149. )
  150. self.assertEqual(
  151. Registry.states["/usr/local/bin/pydmesg"], self.pydmesg_expected
  152. )
  153. self.assertEqual(Registry.salt_data(), self.pydmesg_salt_expected)
  154. self.assertEqual(Registry.states, OrderedDict())
  155. def test_duplicates(self):
  156. def add_dup():
  157. self.File.managed("dup", name="/dup")
  158. add_dup()
  159. self.assertRaises(DuplicateState, add_dup)
  160. Service = StateFactory("service")
  161. Service.running("dup", name="dup-service")
  162. self.assertEqual(
  163. Registry.states,
  164. OrderedDict(
  165. [
  166. (
  167. "dup",
  168. OrderedDict(
  169. [
  170. ("file.managed", [{"name": "/dup"}]),
  171. ("service.running", [{"name": "dup-service"}]),
  172. ]
  173. ),
  174. )
  175. ]
  176. ),
  177. )
  178. class RendererMixin:
  179. """
  180. This is a mixin that adds a ``.render()`` method to render a template
  181. It must come BEFORE ``TestCase`` in the declaration of your test case
  182. class so that our setUp & tearDown get invoked first, and super can
  183. trigger the methods in the ``TestCase`` class.
  184. """
  185. @classmethod
  186. def setUpClass(cls):
  187. cls.root_dir = pathlib.Path(tempfile.mkdtemp("pyobjects", dir=RUNTIME_VARS.TMP))
  188. cls.state_tree_dir = cls.root_dir / "state_tree"
  189. cls.state_tree_dir.mkdir()
  190. cls.cache_dir = cls.root_dir / "cachedir"
  191. cls.cache_dir.mkdir()
  192. conf_dir = cls.root_dir / "conf"
  193. conf_dir.mkdir()
  194. config_defaults = {
  195. "id": "match",
  196. "root_dir": str(cls.root_dir),
  197. "cachedir": str(cls.cache_dir),
  198. "file_client": "local",
  199. "file_roots": {"base": [str(cls.state_tree_dir)]},
  200. "pidfile": "run/minion.pid",
  201. "pki_dir": "pki",
  202. "sock_dir": "run/minion",
  203. "log_file": "logs/minion.log",
  204. "state_events": False,
  205. "test": False,
  206. }
  207. conf_file = str(conf_dir / "minion")
  208. with salt.utils.files.fopen(conf_file, "w") as wfh:
  209. salt.utils.yaml.safe_dump(config_defaults, wfh, default_flow_style=False)
  210. cls._config = salt.config.minion_config(
  211. conf_file, minion_id="match", cache_minion_id=True
  212. )
  213. @classmethod
  214. def tearDownClass(cls):
  215. cls.root_dir = cls.state_tree_dir = cls.cache_dir = cls._config = None
  216. def setUp(self, *args, **kwargs):
  217. super().setUp(*args, **kwargs)
  218. self.root_dir.mkdir(exist_ok=True)
  219. self.addCleanup(shutil.rmtree, str(self.root_dir), ignore_errors=True)
  220. self.state_tree_dir.mkdir(exist_ok=True)
  221. self.cache_dir.mkdir(exist_ok=True)
  222. self.config = self._config.copy()
  223. self.addCleanup(delattr, self, "config")
  224. def write_template_file(self, filename, content):
  225. full_path = str(self.state_tree_dir / filename)
  226. with salt.utils.files.fopen(full_path, "w") as f:
  227. log.debug(
  228. "Writting template %r. Contents:\n%s\n%s\n%s",
  229. full_path,
  230. ">" * 80,
  231. content,
  232. "<" * 80,
  233. )
  234. f.write(content)
  235. return full_path
  236. def render(self, template, opts=None, filename=None):
  237. if opts:
  238. self.config.update(opts)
  239. if not filename:
  240. filename = ".".join([str(uuid.uuid4()), "sls"])
  241. full_path = self.write_template_file(filename, template)
  242. state = salt.state.State(self.config)
  243. return compile_template(
  244. full_path,
  245. state.rend,
  246. state.opts["renderer"],
  247. state.opts["renderer_blacklist"],
  248. state.opts["renderer_whitelist"],
  249. )
  250. class RendererTests(RendererMixin, StateTests, MapBuilder):
  251. @classmethod
  252. def setUpClass(cls):
  253. super().setUpClass()
  254. cls.recursive_map_template = textwrap.dedent(
  255. """\
  256. #!pyobjects
  257. from salt://map.sls import Samba
  258. class CustomSamba(Samba):
  259. pass
  260. """
  261. )
  262. cls.recursive_import_template = textwrap.dedent(
  263. """\
  264. #!pyobjects
  265. from salt://recursive_map.sls import CustomSamba
  266. Pkg.removed("samba-imported", names=[CustomSamba.server, CustomSamba.client])"""
  267. )
  268. cls.File = StateFactory("file")
  269. @classmethod
  270. def tearDownClass(cls):
  271. super().tearDownClass()
  272. cls.File = None
  273. @slowTest
  274. def test_basic(self):
  275. basic_template = textwrap.dedent(
  276. """\
  277. #!pyobjects
  278. File.directory('/tmp', mode='1777', owner='root', group='root')
  279. """
  280. )
  281. ret = self.render(basic_template)
  282. self.assertEqual(
  283. ret,
  284. OrderedDict(
  285. [
  286. (
  287. "/tmp",
  288. {
  289. "file.directory": [
  290. {"group": "root"},
  291. {"mode": "1777"},
  292. {"owner": "root"},
  293. ]
  294. },
  295. ),
  296. ]
  297. ),
  298. )
  299. self.assertEqual(Registry.states, OrderedDict())
  300. @slowTest
  301. def test_invalid_function(self):
  302. def _test():
  303. invalid_template = textwrap.dedent(
  304. """\
  305. #!pyobjects
  306. File.fail('/tmp')
  307. """
  308. )
  309. self.render(invalid_template)
  310. self.assertRaises(InvalidFunction, _test)
  311. @slowTest
  312. def test_include(self):
  313. include_template = textwrap.dedent(
  314. """\
  315. #!pyobjects
  316. include('http')
  317. """
  318. )
  319. ret = self.render(include_template)
  320. self.assertEqual(ret, OrderedDict([("include", ["http"])]))
  321. @slowTest
  322. def test_extend(self):
  323. extend_template = textwrap.dedent(
  324. """\
  325. #!pyobjects
  326. include('http')
  327. from salt.utils.pyobjects import StateFactory
  328. Service = StateFactory('service')
  329. Service.running(extend('apache'), watch=[{'file': '/etc/file'}])
  330. """
  331. )
  332. ret = self.render(
  333. extend_template, {"grains": {"os_family": "Debian", "os": "Debian"}}
  334. )
  335. self.assertEqual(
  336. ret,
  337. OrderedDict(
  338. [
  339. ("include", ["http"]),
  340. (
  341. "extend",
  342. OrderedDict(
  343. [
  344. (
  345. "apache",
  346. {
  347. "service.running": [
  348. {"watch": [{"file": "/etc/file"}]}
  349. ]
  350. },
  351. ),
  352. ]
  353. ),
  354. ),
  355. ]
  356. ),
  357. )
  358. @slowTest
  359. def test_sls_imports(self):
  360. def render_and_assert(template):
  361. ret = self.render(
  362. template, {"grains": {"os_family": "Debian", "os": "Debian"}}
  363. )
  364. self.assertEqual(
  365. ret,
  366. OrderedDict(
  367. [
  368. (
  369. "samba-imported",
  370. {"pkg.removed": [{"names": ["samba", "samba-client"]}]},
  371. )
  372. ]
  373. ),
  374. )
  375. self.write_template_file("map.sls", self.build_map())
  376. import_template = textwrap.dedent(
  377. """\
  378. #!pyobjects
  379. import salt://map.sls
  380. Pkg.removed("samba-imported", names=[map.Samba.server, map.Samba.client])
  381. """
  382. )
  383. render_and_assert(import_template)
  384. from_import_template = textwrap.dedent(
  385. """\
  386. #!pyobjects
  387. # this spacing is like this on purpose to ensure it's stripped properly
  388. from salt://map.sls import Samba
  389. Pkg.removed("samba-imported", names=[Samba.server, Samba.client])
  390. """
  391. )
  392. render_and_assert(from_import_template)
  393. import_as_template = textwrap.dedent(
  394. """\
  395. #!pyobjects
  396. from salt://map.sls import Samba as Other
  397. Pkg.removed("samba-imported", names=[Other.server, Other.client])
  398. """
  399. )
  400. render_and_assert(import_as_template)
  401. self.write_template_file("recursive_map.sls", self.recursive_map_template)
  402. render_and_assert(self.recursive_import_template)
  403. @slowTest
  404. def test_import_scope(self):
  405. self.write_template_file("map.sls", self.build_map())
  406. self.write_template_file("recursive_map.sls", self.recursive_map_template)
  407. def do_render():
  408. scope_test_import_template = textwrap.dedent(
  409. """\
  410. #!pyobjects
  411. from salt://recursive_map.sls import CustomSamba
  412. # since we import CustomSamba we should shouldn't be able to see Samba
  413. Pkg.removed("samba-imported", names=[Samba.server, Samba.client])"""
  414. )
  415. ret = self.render(
  416. scope_test_import_template,
  417. {"grains": {"os_family": "Debian", "os": "Debian"}},
  418. )
  419. self.assertRaises(NameError, do_render)
  420. @slowTest
  421. def test_random_password(self):
  422. """Test for https://github.com/saltstack/salt/issues/21796"""
  423. random_password_template = textwrap.dedent(
  424. """\
  425. #!pyobjects
  426. import random, string
  427. password = ''.join([random.SystemRandom().choice(
  428. string.ascii_letters + string.digits) for _ in range(20)])
  429. """
  430. )
  431. ret = self.render(random_password_template)
  432. @slowTest
  433. def test_import_random_password(self):
  434. """Import test for https://github.com/saltstack/salt/issues/21796"""
  435. random_password_template = textwrap.dedent(
  436. """\
  437. #!pyobjects
  438. import random, string
  439. password = ''.join([random.SystemRandom().choice(
  440. string.ascii_letters + string.digits) for _ in range(20)])
  441. """
  442. )
  443. self.write_template_file("password.sls", random_password_template)
  444. random_password_import_template = textwrap.dedent(
  445. """\
  446. #!pyobjects
  447. from salt://password.sls import password
  448. """
  449. )
  450. ret = self.render(random_password_import_template)
  451. @slowTest
  452. def test_requisite_implicit_list(self):
  453. """Ensure that the implicit list characteristic works as expected"""
  454. requisite_implicit_list_template = textwrap.dedent(
  455. """\
  456. #!pyobjects
  457. from salt.utils.pyobjects import StateFactory
  458. Service = StateFactory('service')
  459. with Pkg.installed("pkg"):
  460. Service.running("service", watch=File("file"), require=Cmd("cmd"))
  461. """
  462. )
  463. ret = self.render(
  464. requisite_implicit_list_template,
  465. {"grains": {"os_family": "Debian", "os": "Debian"}},
  466. )
  467. self.assertEqual(
  468. ret,
  469. OrderedDict(
  470. [
  471. ("pkg", OrderedDict([("pkg.installed", [])])),
  472. (
  473. "service",
  474. OrderedDict(
  475. [
  476. (
  477. "service.running",
  478. [
  479. {"require": [{"cmd": "cmd"}, {"pkg": "pkg"}]},
  480. {"watch": [{"file": "file"}]},
  481. ],
  482. )
  483. ]
  484. ),
  485. ),
  486. ]
  487. ),
  488. )
  489. class MapTests(RendererMixin, TestCase, MapBuilder):
  490. maxDiff = None
  491. debian_grains = {"os_family": "Debian", "os": "Debian"}
  492. ubuntu_grains = {"os_family": "Debian", "os": "Ubuntu"}
  493. centos_grains = {"os_family": "RedHat", "os": "CentOS"}
  494. debian_attrs = ("samba", "samba-client", "samba")
  495. ubuntu_attrs = ("samba", "samba-client", "smbd")
  496. centos_attrs = ("samba", "samba", "smb")
  497. def samba_with_grains(self, template, grains):
  498. return self.render(template, {"grains": grains})
  499. def assert_equal(self, ret, server, client, service):
  500. self.assertDictEqual(
  501. ret,
  502. OrderedDict(
  503. [
  504. (
  505. "samba",
  506. OrderedDict(
  507. [
  508. ("pkg.installed", [{"names": [server, client]}]),
  509. (
  510. "service.running",
  511. [
  512. {"name": service},
  513. {"require": [{"pkg": "samba"}]},
  514. ],
  515. ),
  516. ]
  517. ),
  518. )
  519. ]
  520. ),
  521. )
  522. def assert_not_equal(self, ret, server, client, service):
  523. try:
  524. self.assert_equal(ret, server, client, service)
  525. except AssertionError:
  526. pass
  527. else:
  528. raise AssertionError("both dicts are equal")
  529. @slowTest
  530. def test_map(self):
  531. """
  532. Test declarative ordering
  533. """
  534. # With declarative ordering, the ubuntu-specific service name should
  535. # override the one inherited from debian.
  536. template = self.build_map(
  537. textwrap.dedent(
  538. """\
  539. {{ debian }}
  540. {{ centos }}
  541. {{ ubuntu }}
  542. """
  543. )
  544. )
  545. ret = self.samba_with_grains(template, self.debian_grains)
  546. self.assert_equal(ret, *self.debian_attrs)
  547. ret = self.samba_with_grains(template, self.ubuntu_grains)
  548. self.assert_equal(ret, *self.ubuntu_attrs)
  549. ret = self.samba_with_grains(template, self.centos_grains)
  550. self.assert_equal(ret, *self.centos_attrs)
  551. # Switching the order, debian should still work fine but ubuntu should
  552. # no longer match, since the debian service name should override the
  553. # ubuntu one.
  554. template = self.build_map(
  555. textwrap.dedent(
  556. """\
  557. {{ ubuntu }}
  558. {{ debian }}
  559. """
  560. )
  561. )
  562. ret = self.samba_with_grains(template, self.debian_grains)
  563. self.assert_equal(ret, *self.debian_attrs)
  564. ret = self.samba_with_grains(template, self.ubuntu_grains)
  565. self.assert_not_equal(ret, *self.ubuntu_attrs)
  566. @slowTest
  567. def test_map_with_priority(self):
  568. """
  569. With declarative ordering, the debian service name would override the
  570. ubuntu one since debian comes second. This will test overriding this
  571. behavior using the priority attribute.
  572. """
  573. template = self.build_map(
  574. textwrap.dedent(
  575. """\
  576. {{ priority(('os_family', 'os')) }}
  577. {{ ubuntu }}
  578. {{ centos }}
  579. {{ debian }}
  580. """
  581. )
  582. )
  583. ret = self.samba_with_grains(template, self.debian_grains)
  584. self.assert_equal(ret, *self.debian_attrs)
  585. ret = self.samba_with_grains(template, self.ubuntu_grains)
  586. self.assert_equal(ret, *self.ubuntu_attrs)
  587. ret = self.samba_with_grains(template, self.centos_grains)
  588. self.assert_equal(ret, *self.centos_attrs)
  589. class SaltObjectTests(TestCase):
  590. def test_salt_object(self):
  591. def attr_fail():
  592. Salt.fail.blah()
  593. def times2(x):
  594. return x * 2
  595. Salt = SaltObject({"math.times2": times2})
  596. self.assertRaises(AttributeError, attr_fail)
  597. self.assertEqual(Salt.math.times2, times2)
  598. self.assertEqual(Salt.math.times2(2), 4)