Allow policy parameters from pillar

This commit is contained in:
jeanluc 2022-09-13 14:28:16 +02:00
parent d56d6f7025
commit 477c2c5ad1
No known key found for this signature in database
GPG key ID: 3EB52D4C754CD898
9 changed files with 582 additions and 31 deletions

1
changelog/43287.added Normal file
View file

@ -0,0 +1 @@
Added pillar templating to vault policies

View file

@ -130,6 +130,12 @@ Functions to interact with Hashicorp Vault.
values, eg ``my-policies/{grains[os]}``. ``{minion}`` is shorthand for
``grains[id]``, eg ``saltstack/minion/{minion}``.
.. versionadded:: 3006
Policies can be templated with pillar values as well: ``salt_role_{pillar[roles]}``
Make sure to only reference pillars that are not sourced from Vault since the latter
ones might be unavailable during policy rendering.
.. important::
See :ref:`Is Targeting using Grain Data Secure?
@ -160,6 +166,29 @@ Functions to interact with Hashicorp Vault.
Optional. If policies is not configured, ``saltstack/minions`` and
``saltstack/{minion}`` are used as defaults.
policies_refresh_pillar
Whether to refresh the pillar data when rendering templated policies.
When unset (=null/None), will only refresh when the cached data
is unavailable, boolean values force one behavior always.
.. note::
Using cached pillar data only (policies_refresh_pillar=False)
might cause the policies to be out of sync. If there is no cached pillar
data available for the minion, pillar templates will fail to render at all.
If you use pillar values for templating policies and do not disable
refreshing pillar data, make sure the relevant values are not sourced
from Vault (ext_pillar, sdb) or from a pillar sls file that uses the vault
execution module. Although this will often work when cached pillar data is
available, if the master needs to compile the pillar data during policy rendering,
all Vault modules will be broken to prevent an infinite loop.
policies_cache_time
Policy computation can be heavy in case pillar data is used in templated policies and
it has not been cached. Therefore, a short-lived cache specifically for rendered policies
is used. This specifies the expiration timeout in seconds. Defaults to 60.
keys
List of keys to use to unseal vault server with the vault.unseal runner.

View file

@ -170,10 +170,16 @@ def ext_pillar(
nesting_key=None,
merge_strategy=None,
merge_lists=None,
extra_minion_data=None,
):
"""
Get pillar data from Vault for the configuration ``conf``.
"""
extra_minion_data = extra_minion_data or {}
if extra_minion_data.get("_vault_runner_is_compiling_pillar_templates"):
# Disable vault ext_pillar while compiling pillar for vault policy templates
return {}
comps = conf.split()
paths = [comp for comp in comps if comp.startswith("path=")]

View file

@ -8,14 +8,20 @@ documented in the execution module docs.
"""
import base64
import copy
import json
import logging
import time
from collections.abc import Mapping
import requests
import salt.cache
import salt.crypt
import salt.exceptions
import salt.pillar
from salt.defaults import NOT_SET
from salt.exceptions import SaltRunnerError
log = logging.getLogger(__name__)
@ -62,6 +68,8 @@ def generate_token(
if not allow_minion_override or ttl is None:
ttl = config["auth"].get("ttl", None)
storage_type = config["auth"].get("token_backend", "session")
policies_refresh_pillar = config.get("policies_refresh_pillar", None)
policies_cache_time = config.get("policies_cache_time", 60)
if config["auth"]["method"] == "approle":
if _selftoken_expired():
@ -92,7 +100,12 @@ def generate_token(
"saltstack-user": globals().get("__user__", "<no user set>"),
}
payload = {
"policies": _get_policies(minion_id, config),
"policies": _get_policies_cached(
minion_id,
config,
refresh_pillar=policies_refresh_pillar,
expire=policies_cache_time,
),
"num_uses": uses,
"meta": audit_data,
}
@ -160,12 +173,24 @@ def unseal():
return False
def show_policies(minion_id):
def show_policies(minion_id, refresh_pillar=NOT_SET, expire=None):
"""
Show the Vault policies that are applied to tokens for the given minion
Show the Vault policies that are applied to tokens for the given minion.
minion_id
The minions id
The minion's id.
refresh_pillar
Whether to refresh the pillar data when rendering templated policies.
None will only refresh when the cached data is unavailable, boolean values
force one behavior always.
Defaults to config value ``policies_refresh_pillar`` or None.
expire
Policy computation can be heavy in case pillar data is used in templated policies and
it has not been cached. Therefore, a short-lived cache specifically for rendered policies
is used. This specifies the expiration timeout in seconds.
Defaults to config value ``policies_cache_time`` or 60.
CLI Example:
@ -173,8 +198,13 @@ def show_policies(minion_id):
salt-run vault.show_policies myminion
"""
config = __opts__["vault"]
return _get_policies(minion_id, config)
config = __opts__.get("vault", {})
if refresh_pillar == NOT_SET:
refresh_pillar = config.get("policies_refresh_pillar")
expire = expire if expire is not None else config.get("policies_cache_time", 60)
return _get_policies_cached(
minion_id, config, refresh_pillar=refresh_pillar, expire=expire
)
def _validate_signature(minion_id, signature, impersonated_by_master):
@ -197,15 +227,18 @@ def _validate_signature(minion_id, signature, impersonated_by_master):
log.trace("Signature ok")
def _get_policies(minion_id, config):
# **kwargs because salt.cache.Cache does not pop "expire" from kwargs
def _get_policies(
minion_id, config, refresh_pillar=None, **kwargs
): # pylint: disable=unused-argument
"""
Get the policies that should be applied to a token for minion_id
"""
_, grains, _ = salt.utils.minions.get_minion_data(minion_id, __opts__)
grains, pillar = _get_minion_data(minion_id, refresh_pillar)
policy_patterns = config.get(
"policies", ["saltstack/minion/{minion}", "saltstack/minions"]
)
mappings = {"minion": minion_id, "grains": grains or {}}
mappings = {"minion": minion_id, "grains": grains, "pillar": pillar}
policies = []
for pattern in policy_patterns:
@ -217,12 +250,84 @@ def _get_policies(minion_id, config):
expanded_pattern.format(**mappings).lower() # Vault requirement
)
except KeyError:
log.warning("Could not resolve policy pattern %s", pattern)
log.warning(
"Could not resolve policy pattern %s for minion %s", pattern, minion_id
)
log.debug("%s policies: %s", minion_id, policies)
return policies
def _get_policies_cached(minion_id, config, refresh_pillar=None, expire=60):
# expiration of 0 disables cache
if not expire:
return _get_policies(minion_id, config, refresh_pillar=refresh_pillar)
cbank = f"minions/{minion_id}/vault"
ckey = "policies"
cache = salt.cache.factory(__opts__)
policies = cache.cache(
cbank,
ckey,
_get_policies,
expire=expire,
minion_id=minion_id,
config=config,
refresh_pillar=refresh_pillar,
)
if not isinstance(policies, list):
log.warning("Cached vault policies were not formed as a list. Refreshing.")
cache.flush(cbank, ckey)
policies = cache.cache(
cbank,
ckey,
_get_policies,
expire=expire,
minion_id=minion_id,
config=config,
refresh_pillar=refresh_pillar,
)
return policies
def _get_minion_data(minion_id, refresh_pillar=None):
_, grains, pillar = salt.utils.minions.get_minion_data(minion_id, __opts__)
if grains is None:
grains = {}
# To properly refresh minion grains, something like this could be used:
# __salt__["salt.execute"](minion_id, "saltutil.refresh_grains", refresh_pillar=False)
# This is deliberately not done since grains should not be used to target
# secrets anyways.
# salt.utils.minions.get_minion_data only returns data from cache or None.
# To make sure the correct policies are available, the pillar needs to be
# refreshed. This can cause an infinite loop if the pillar data itself
# depends on the vault execution module, which relies on this function.
# By default, only refresh when necessary. Boolean values force one way.
if refresh_pillar is True or (refresh_pillar is None and pillar is None):
if __opts__.get("_vault_runner_is_compiling_pillar_templates"):
raise SaltRunnerError(
"Cyclic dependency detected while refreshing pillar for vault policy templating. "
"This is caused by some pillar value relying on the vault execution module. "
"Either remove the dependency from your pillar, disable refreshing pillar data "
"for policy templating or do not use pillar values in policy templates."
)
local_opts = copy.deepcopy(__opts__)
# Relying on opts for ext_pillars does not work properly (only the first one runs
# correctly).
extra_minion_data = {"_vault_runner_is_compiling_pillar_templates": True}
local_opts.update(extra_minion_data)
pillar = LazyPillar(
local_opts, grains, minion_id, extra_minion_data=extra_minion_data
)
elif pillar is None:
# Make sure pillar is a dict. Necessary because a check on LazyPillar would
# refresh it unconditionally (even when no pillar values are used)
pillar = {}
return grains, pillar
def _selftoken_expired():
"""
Validate the current token exists and is still valid
@ -256,3 +361,41 @@ def _get_token_create_url(config):
auth_path = "/v1/auth/token/create"
base_url = config["url"]
return "/".join(x.strip("/") for x in (base_url, auth_path, role_name) if x)
class LazyPillar(Mapping):
"""
Simulates a pillar dictionary. Only compiles the pillar
once an item is requested.
"""
def __init__(self, opts, grains, minion_id, extra_minion_data=None):
self.opts = opts
self.grains = grains
self.minion_id = minion_id
self.extra_minion_data = extra_minion_data or {}
self._pillar = None
def _load(self):
log.info("Refreshing pillar for vault templating.")
self._pillar = salt.pillar.get_pillar(
self.opts,
self.grains,
self.minion_id,
extra_minion_data=self.extra_minion_data,
).compile_pillar()
def __getitem__(self, key):
if self._pillar is None:
self._load()
return self._pillar[key]
def __iter__(self):
if self._pillar is None:
self._load()
yield from self._pillar
def __len__(self):
if self._pillar is None:
self._load()
return len(self._pillar)

View file

@ -80,6 +80,8 @@ def get(key, profile=None):
"""
Get a value from the vault service
"""
if __opts__.get("_vault_runner_is_compiling_pillar_templates"):
return None
if "?" in key:
path, key = key.split("?")
else:

View file

@ -0,0 +1,285 @@
"""
Tests for the Vault runner
"""
import logging
import shutil
import pytest
log = logging.getLogger(__name__)
pytestmark = [
pytest.mark.slow_test,
]
@pytest.fixture(scope="module")
def pillar_state_tree(tmp_path_factory):
_pillar_state_tree = tmp_path_factory.mktemp("pillar")
try:
yield _pillar_state_tree
finally:
shutil.rmtree(str(_pillar_state_tree), ignore_errors=True)
@pytest.fixture(scope="module")
def pillar_salt_master(salt_factories, pillar_state_tree):
config_defaults = {
"pillar_roots": {"base": [str(pillar_state_tree)]},
"open_mode": True,
"ext_pillar": [{"vault": "path=does/not/matter"}],
"sdbvault": {
"driver": "vault",
},
"vault": {
"auth": {"token": "testsecret"},
"policies": [
"salt_minion",
"salt_minion_{minion}",
"salt_role_{pillar[roles]}",
"salt_unsafe_{grains[foo]}",
],
},
}
factory = salt_factories.salt_master_daemon(
"vault-pillarpolicy-functional-master", defaults=config_defaults
)
with factory.started():
yield factory
@pytest.fixture(scope="module")
def pillar_salt_minion(pillar_salt_master):
assert pillar_salt_master.is_running()
factory = pillar_salt_master.salt_minion_daemon(
"vault-pillarpolicy-functional-minion-1",
defaults={"open_mode": True, "grains": {"foo": "bar"}},
)
with factory.started():
# Sync All
salt_call_cli = factory.salt_call_cli()
ret = salt_call_cli.run("saltutil.sync_all", _timeout=120)
assert ret.returncode == 0, ret
yield factory
@pytest.fixture(scope="module")
def pillar_salt_run_cli(pillar_salt_master):
return pillar_salt_master.salt_run_cli()
@pytest.fixture(scope="module")
def pillar_salt_call_cli(pillar_salt_minion):
return pillar_salt_minion.salt_call_cli()
@pytest.fixture()
def pillar_policy_tree(pillar_state_tree, pillar_salt_minion, pillar_salt_call_cli):
top_file = """
base:
'{}':
- roles
- sdb_loop
""".format(
pillar_salt_minion.id
)
roles_pillar = """
roles:
- minion
- web
"""
sdb_loop_pillar = """
foo: {{ salt["sdb.get"]("sdb://sdbvault/does/not/matter/val") }}
"""
top_tempfile = pytest.helpers.temp_file("top.sls", top_file, pillar_state_tree)
roles_tempfile = pytest.helpers.temp_file(
"roles.sls", roles_pillar, pillar_state_tree
)
sdb_loop_tempfile = pytest.helpers.temp_file(
"sdb_loop.sls", sdb_loop_pillar, pillar_state_tree
)
with top_tempfile, roles_tempfile, sdb_loop_tempfile:
yield
@pytest.fixture()
def pillar_loop(
pillar_state_tree, pillar_policy_tree, pillar_salt_minion, pillar_salt_call_cli
):
top_file = """
base:
'{}':
- roles
- sdb_loop
- exe_loop
""".format(
pillar_salt_minion.id
)
exe_loop_pillar = r"""
bar: {{ salt["vault.read_secret"]("does/not/matter") }}
"""
top_tempfile = pytest.helpers.temp_file("top.sls", top_file, pillar_state_tree)
exe_loop_tempfile = pytest.helpers.temp_file(
"exe_loop.sls", exe_loop_pillar, pillar_state_tree
)
with top_tempfile, exe_loop_tempfile:
yield
class TestVaultPillarPolicyTemplatesWithoutCache:
@pytest.fixture()
def minion_data_cache_absent(
self, pillar_salt_run_cli, pillar_salt_minion, pillar_policy_tree
):
ret = pillar_salt_run_cli.run(
"cache.flush", f"minions/{pillar_salt_minion.id}", "data"
)
assert ret.returncode == 0
cached = pillar_salt_run_cli.run(
"cache.fetch", f"minions/{pillar_salt_minion.id}", "data"
)
assert cached.returncode == 0
assert not cached.data
yield
def test_show_policies(
self, pillar_salt_run_cli, pillar_salt_minion, minion_data_cache_absent
):
"""
Test that pillar data is refreshed correctly before rendering policies when necessary.
This test includes the prevention of loop exceptions by sdb/ext_pillar modules
This refresh does not include grains and pillar data targeted by these grains (unsafe anyways!).
"""
ret = pillar_salt_run_cli.run(
"vault.show_policies", pillar_salt_minion.id, expire=0
)
assert ret.data == [
"salt_minion",
f"salt_minion_{pillar_salt_minion.id}",
"salt_role_minion",
"salt_role_web",
]
assert "Pillar render error: Failed to load ext_pillar vault" not in ret.stderr
assert "Pillar render error: Rendering SLS 'sdb_loop' failed" not in ret.stderr
def test_show_policies_uncached_data_no_pillar_refresh(
self, pillar_salt_run_cli, pillar_salt_minion, minion_data_cache_absent
):
"""
Test that pillar is not refreshed when explicitly disabled
"""
ret = pillar_salt_run_cli.run(
"vault.show_policies", pillar_salt_minion.id, refresh_pillar=False, expire=0
)
assert ret.data == ["salt_minion", f"salt_minion_{pillar_salt_minion.id}"]
def test_policy_compilation_prevents_loop(
self,
pillar_salt_run_cli,
pillar_salt_minion,
pillar_loop,
minion_data_cache_absent,
):
"""
Test that the runner prevents a recursive cycle from happening
"""
ret = pillar_salt_run_cli.run(
"vault.show_policies", pillar_salt_minion.id, refresh_pillar=True, expire=0
)
assert ret.data == [
"salt_minion",
f"salt_minion_{pillar_salt_minion.id}",
"salt_role_minion",
"salt_role_web",
]
assert "Pillar render error: Rendering SLS 'exe_loop' failed" in ret.stderr
class TestVaultPillarPolicyTemplatesWithCache:
@pytest.fixture()
def minion_data_cache_present(
self,
pillar_salt_call_cli,
pillar_salt_run_cli,
pillar_salt_minion,
pillar_policy_tree,
):
ret = pillar_salt_call_cli.run("saltutil.refresh_pillar", wait=True)
assert ret.returncode == 0
assert ret.data is True
yield
@pytest.fixture()
def minion_data_cache_outdated(
self,
minion_data_cache_present,
pillar_salt_run_cli,
pillar_salt_call_cli,
pillar_policy_tree,
pillar_state_tree,
pillar_salt_minion,
):
roles_pillar = """
roles:
- minion
- web
- fresh
"""
roles_tempfile = pytest.helpers.temp_file(
"roles.sls", roles_pillar, pillar_state_tree
)
cached = pillar_salt_run_cli.run(
"cache.fetch", f"minions/{pillar_salt_minion.id}", "data"
)
assert cached.returncode == 0
assert cached.data
assert "pillar" in cached.data
assert "grains" in cached.data
assert "foo" in cached.data["grains"]
assert "roles" in cached.data["pillar"]
assert ["minion", "web"] == cached.data["pillar"]["roles"]
with roles_tempfile:
yield
def test_show_policies_cached_data_no_pillar_refresh(
self, pillar_salt_run_cli, pillar_salt_minion, minion_data_cache_outdated
):
"""
Test that pillar data from cache is used when it is available
"""
ret = pillar_salt_run_cli.run(
"vault.show_policies", pillar_salt_minion.id, expire=0
)
assert ret.data == [
"salt_minion",
f"salt_minion_{pillar_salt_minion.id}",
"salt_role_minion",
"salt_role_web",
"salt_unsafe_bar",
]
def test_show_policies_refresh_pillar(
self, pillar_salt_run_cli, pillar_salt_minion, minion_data_cache_outdated
):
"""
Test that pillar data is always refreshed when requested.
This test includes the prevention of loops by sdb/ext_pillar modules
This refresh does not include grains and pillar data targeted by these grains (unsafe anyways!).
"""
ret = pillar_salt_run_cli.run(
"vault.show_policies", pillar_salt_minion.id, refresh_pillar=True, expire=0
)
assert ret.data == [
"salt_minion",
f"salt_minion_{pillar_salt_minion.id}",
"salt_role_minion",
"salt_role_web",
"salt_role_fresh",
"salt_unsafe_bar",
]
assert "Pillar render error: Failed to load ext_pillar vault" not in ret.stderr
assert "Pillar render error: Rendering SLS 'sdb_loop' failed" not in ret.stderr

View file

@ -182,3 +182,18 @@ def test_ext_pillar_merging(is_v2_false):
merge_lists=False,
)
assert ext_pillar == expected
def text_ext_pillar_disabled_during_policy_pillar_rendering():
mock_version = Mock()
mock_vault = Mock()
extra = {"_vault_runner_is_compiling_pillar_templates": True}
with patch.dict(
vault.__utils__, {"vault.make_request": mock_vault, "vault.is_v2": mock_version}
):
assert {} == vault.ext_pillar(
"test-minion", {}, conf="path=secret/path", extra_minion_data=extra
)
assert mock_version.call_count == 0
assert mock_vault.call_count == 0

View file

@ -30,8 +30,13 @@ class VaultTest(TestCase, LoaderModuleMockMixin):
"mixedcase": "UP-low-UP",
}
self.pillar = {
"role": "test",
}
def tearDown(self):
del self.grains
del self.pillar
def test_get_policies_for_nonexisting_minions(self):
minion_id = "salt_master"
@ -45,7 +50,6 @@ class VaultTest(TestCase, LoaderModuleMockMixin):
"salt.utils.minions.get_minion_data",
MagicMock(return_value=(None, None, None)),
):
for case, correct_output in cases.items():
with patch.dict(
vault.__utils__,
{
@ -54,6 +58,7 @@ class VaultTest(TestCase, LoaderModuleMockMixin):
)
},
):
for case, correct_output in cases.items():
test_config = {"policies": [case]}
output = vault._get_policies(
minion_id, test_config
@ -77,13 +82,13 @@ class VaultTest(TestCase, LoaderModuleMockMixin):
"Case-Should-Be-Lowered:{grains[mixedcase]}": [
"case-should-be-lowered:up-low-up"
],
"pillar-rendering:{pillar[role]}": ["pillar-rendering:test"],
}
with patch(
"salt.utils.minions.get_minion_data",
MagicMock(return_value=(None, self.grains, None)),
MagicMock(return_value=(None, self.grains, self.pillar)),
):
for case, correct_output in cases.items():
with patch.dict(
vault.__utils__,
{
@ -92,6 +97,7 @@ class VaultTest(TestCase, LoaderModuleMockMixin):
)
},
):
for case, correct_output in cases.items():
test_config = {"policies": [case]}
output = vault._get_policies(
"test-minion", test_config
@ -103,6 +109,39 @@ class VaultTest(TestCase, LoaderModuleMockMixin):
log.debug("Difference:\n\t%s", diff)
self.assertEqual(output, correct_output)
def test_get_policies_does_not_render_pillar_unnecessarily(self):
"""
The pillar data should only be refreshed in case items are accessed.
"""
get_pillar = Mock(name="g")
get_pillar.return_value.compile_pillar.return_value = self.pillar
cases = [
("salt_minion_{minion}", 0),
("salt_grain_{grains[id]}", 0),
("unset_{foo}", 0),
("salt_pillar_{pillar[role]}", 1),
]
with patch(
"salt.utils.minions.get_minion_data",
Mock(return_value=(None, self.grains, None)),
), patch("salt.pillar.get_pillar", get_pillar):
with patch.dict(
vault.__utils__,
{
"vault.expand_pattern_lists": Mock(
side_effect=lambda x, *args, **kwargs: [x]
)
},
):
for case, expected in cases:
test_config = {"policies": [case]}
vault._get_policies(
"test-minion", test_config, refresh_pillar=True
) # pylint: disable=protected-access
assert get_pillar.call_count == expected
def test_get_token_create_url(self):
"""
Ensure _get_token_create_url parses config correctly
@ -170,6 +209,10 @@ class VaultTokenAuthTest(TestCase, LoaderModuleMockMixin):
"salt.runners.vault._get_token_create_url",
MagicMock(return_value="http://fake_url"),
)
@patch(
"salt.runners.vault._get_policies_cached",
Mock(return_value=["saltstack/minion/test-minion", "saltstack/minions"]),
)
def test_generate_token(self):
"""
Basic tests for test_generate_token: all exits
@ -231,7 +274,7 @@ class VaultTokenAuthTest(TestCase, LoaderModuleMockMixin):
self.assertTrue("error" in result)
self.assertEqual(result["error"], "no reason")
with patch("salt.runners.vault._get_policies", MagicMock(return_value=[])):
with patch("salt.runners.vault._get_policies_cached", Mock(return_value=[])):
result = vault.generate_token("test-minion", "signature")
self.assertTrue(isinstance(result, dict))
self.assertTrue("error" in result)
@ -250,6 +293,10 @@ class VaultTokenAuthTest(TestCase, LoaderModuleMockMixin):
"salt.runners.vault._get_token_create_url",
MagicMock(return_value="http://fake_url"),
)
@patch(
"salt.runners.vault._get_policies_cached",
Mock(return_value=["saltstack/minion/test-minion", "saltstack/minions"]),
)
def test_generate_token_with_namespace(self):
"""
Basic tests for test_generate_token: all exits
@ -303,6 +350,10 @@ class VaultAppRoleAuthTest(TestCase, LoaderModuleMockMixin):
"salt.runners.vault._get_token_create_url",
MagicMock(return_value="http://fake_url"),
)
@patch(
"salt.runners.vault._get_policies_cached",
Mock(return_value=["saltstack/minion/test-minion", "saltstack/minions"]),
)
def test_generate_token(self):
"""
Basic test for test_generate_token with approle (two vault calls)

View file

@ -5,7 +5,7 @@ Test case for the vault SDB module
import salt.sdb.vault as vault
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.mock import MagicMock, call, patch
from tests.support.mock import MagicMock, Mock, call, patch
from tests.support.unit import TestCase
@ -201,3 +201,22 @@ class TestVaultSDB(LoaderModuleMockMixin, TestCase):
assert mock_vault.call_args_list == [
call("GET", "v1/sdb://myvault/path/to/foo")
]
def test_get_disabled_during_policy_pillar_rendering(self):
"""
Ensure that during pillar rendering for templated policies,
salt.sdb.vault.get function does not query vault to prevent
a cyclic dependency.
"""
mock_version = Mock()
mock_vault = Mock()
with patch.dict(
vault.__utils__,
{"vault.make_request": mock_vault, "vault.is_v2": mock_version},
):
with patch.dict(
vault.__opts__, {"_vault_runner_is_compiling_pillar_templates": True}
):
self.assertIsNone(vault.get("sdb://myvault/path/to/foo/foo"))
assert mock_version.call_count == 0
assert mock_vault.call_count == 0