pychrony 2.0.2__tar.gz → 2.0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pychrony-2.0.2 → pychrony-2.0.3}/.github/workflows/ci.yml +9 -7
- {pychrony-2.0.2 → pychrony-2.0.3}/.github/workflows/docs.yml +1 -1
- {pychrony-2.0.2 → pychrony-2.0.3}/.github/workflows/release.yml +6 -6
- {pychrony-2.0.2/src/pychrony.egg-info → pychrony-2.0.3}/PKG-INFO +1 -1
- {pychrony-2.0.2 → pychrony-2.0.3}/TESTING.md +26 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/docker/Dockerfile.test +6 -1
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/_core/_bindings.py +80 -25
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/_core/_build_bindings.py +1 -0
- {pychrony-2.0.2 → pychrony-2.0.3/src/pychrony.egg-info}/PKG-INFO +1 -1
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/SOURCES.txt +1 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/scm_file_list.json +1 -0
- pychrony-2.0.3/src/pychrony.egg-info/scm_version.json +8 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/config.py +4 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/session.py +64 -24
- pychrony-2.0.3/tests/unit/test_source_list_changes.py +143 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/uv.lock +41 -41
- pychrony-2.0.2/src/pychrony.egg-info/scm_version.json +0 -8
- {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.analyze.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.checklist.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.clarify.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.constitution.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.implement.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.plan.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.specify.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.tasks.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.taskstoissues.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.dockerignore +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.github/dependabot.yml +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.gitignore +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.gitmodules +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.python-version +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/memory/constitution.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/check-prerequisites.sh +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/common.sh +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/create-new-feature.sh +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/execute-task-generation.sh +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/setup-plan.sh +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/update-agent-context.sh +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/agent-file-template.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/checklist-template.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/plan-template.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/spec-template.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/tasks-template.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/CLAUDE.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/LICENSE +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/LICENSES/LGPL-2.1-or-later.txt +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/README.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/docker/docker-compose.test.yml +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/docs/api/index.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/docs/index.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/mkdocs.yml +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/pyproject.toml +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/setup.cfg +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/setup.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/checklists/requirements.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/contracts/package.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/data-model.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/plan.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/quickstart.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/research.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/spec.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/tasks.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/checklists/requirements.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/contracts/python-api.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/data-model.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/plan.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/quickstart.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/research.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/spec.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/tasks.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/checklists/requirements.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/contracts/api.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/contracts/python-api.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/data-model.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/plan.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/quickstart.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/research.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/spec.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/tasks.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/checklists/requirements.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/data-model.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/plan.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/quickstart.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/research.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/spec.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/tasks.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/checklists/requirements.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/plan.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/quickstart.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/research.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/spec.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/tasks.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/analysis-report.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/checklists/requirements.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/contracts/mock-api.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/data-model.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/plan.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/quickstart.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/research.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/spec.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/tasks.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/specs/007-api-docs-github-pages/tasks.md +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/__init__.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/_core/__init__.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/_core/_fields.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/exceptions.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/models.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/py.typed +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/testing.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/dependency_links.txt +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/entry_points.txt +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/requires.txt +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/top_level.txt +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/__init__.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/conftest.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/contract/__init__.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/contract/test_api.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/contract/test_api_stability.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/__init__.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/conftest.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_connection.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_enums.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_rtcdata.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_sources.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_sourcestats.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_tracking.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/__init__.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/context.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/scenarios.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/test_import.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/__init__.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_connection.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_enums.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_exceptions.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_leap_scenarios.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_mock_config.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_mock_scenarios.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_models.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_rtc_scenarios.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_source_scenarios.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_testing.py +0 -0
- {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_validation.py +0 -0
|
@@ -36,7 +36,7 @@ jobs:
|
|
|
36
36
|
persist-credentials: false
|
|
37
37
|
|
|
38
38
|
- name: Install uv
|
|
39
|
-
uses: astral-sh/setup-uv@
|
|
39
|
+
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
|
40
40
|
with:
|
|
41
41
|
enable-cache: true
|
|
42
42
|
python-version: ${{ matrix.python-version }}
|
|
@@ -59,7 +59,7 @@ jobs:
|
|
|
59
59
|
persist-credentials: false
|
|
60
60
|
|
|
61
61
|
- name: Install uv
|
|
62
|
-
uses: astral-sh/setup-uv@
|
|
62
|
+
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
|
63
63
|
with:
|
|
64
64
|
enable-cache: true
|
|
65
65
|
python-version: "3.12"
|
|
@@ -87,7 +87,7 @@ jobs:
|
|
|
87
87
|
persist-credentials: false
|
|
88
88
|
|
|
89
89
|
- name: Install uv
|
|
90
|
-
uses: astral-sh/setup-uv@
|
|
90
|
+
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
|
91
91
|
with:
|
|
92
92
|
enable-cache: true
|
|
93
93
|
python-version: "3.12"
|
|
@@ -127,10 +127,12 @@ jobs:
|
|
|
127
127
|
cache-from: type=gha
|
|
128
128
|
cache-to: type=gha,mode=max
|
|
129
129
|
|
|
130
|
-
# chronyd answers commands before it has resolved its
|
|
131
|
-
#
|
|
130
|
+
# chronyd answers commands before it has resolved its server name, and
|
|
131
|
+
# the source list changes as the name resolves. Tests that
|
|
132
132
|
# walk the source list race that growth, so wait until no source has an
|
|
133
133
|
# unresolved address. chronyc also fails until the command socket is up.
|
|
134
|
+
# The 40s limit covers one failed DNS lookup: chronyd retries unresolved
|
|
135
|
+
# names 28s after its first attempt.
|
|
134
136
|
- name: Run tests
|
|
135
137
|
run: |
|
|
136
138
|
docker run --rm --cap-add=SYS_TIME pychrony-test:latest sh -c '
|
|
@@ -138,8 +140,8 @@ jobs:
|
|
|
138
140
|
i=0
|
|
139
141
|
until chronyc -n activity 2>/dev/null | grep -q "^0 sources with unknown address"; do
|
|
140
142
|
i=$((i + 1))
|
|
141
|
-
if [ "$i" -ge
|
|
142
|
-
echo "chronyd sources not resolved after
|
|
143
|
+
if [ "$i" -ge 80 ]; then
|
|
144
|
+
echo "chronyd sources not resolved after 40s"
|
|
143
145
|
chronyc -n activity
|
|
144
146
|
exit 1
|
|
145
147
|
fi
|
|
@@ -29,7 +29,7 @@ jobs:
|
|
|
29
29
|
persist-credentials: true
|
|
30
30
|
|
|
31
31
|
- name: Install UV
|
|
32
|
-
uses: astral-sh/setup-uv@
|
|
32
|
+
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
|
33
33
|
with:
|
|
34
34
|
# A deploy job must not restore a cache that a PR run could have written.
|
|
35
35
|
enable-cache: false
|
|
@@ -35,14 +35,14 @@ jobs:
|
|
|
35
35
|
persist-credentials: false
|
|
36
36
|
|
|
37
37
|
- name: Build wheels with cibuildwheel
|
|
38
|
-
uses: pypa/cibuildwheel@
|
|
38
|
+
uses: pypa/cibuildwheel@adba99c781d3746d38b3a09b1cf797965bcb7fe6 # v4.3.0
|
|
39
39
|
with:
|
|
40
40
|
extras: uv
|
|
41
41
|
env:
|
|
42
42
|
CIBW_ARCHS_LINUX: ${{ matrix.arch }}
|
|
43
43
|
|
|
44
44
|
- name: Upload wheel artifacts
|
|
45
|
-
uses: actions/upload-artifact@
|
|
45
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
46
46
|
with:
|
|
47
47
|
name: wheels-${{ matrix.arch }}
|
|
48
48
|
path: wheelhouse/*.whl
|
|
@@ -59,7 +59,7 @@ jobs:
|
|
|
59
59
|
persist-credentials: false
|
|
60
60
|
|
|
61
61
|
- name: Install UV
|
|
62
|
-
uses: astral-sh/setup-uv@
|
|
62
|
+
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
|
63
63
|
with:
|
|
64
64
|
# The sdist ships to PyPI, so it must not build from a cache that a PR
|
|
65
65
|
# run could have written.
|
|
@@ -69,7 +69,7 @@ jobs:
|
|
|
69
69
|
run: uv build --sdist
|
|
70
70
|
|
|
71
71
|
- name: Upload sdist artifact
|
|
72
|
-
uses: actions/upload-artifact@
|
|
72
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
73
73
|
with:
|
|
74
74
|
name: sdist
|
|
75
75
|
path: dist/*.tar.gz
|
|
@@ -86,7 +86,7 @@ jobs:
|
|
|
86
86
|
|
|
87
87
|
steps:
|
|
88
88
|
- name: Download all artifacts
|
|
89
|
-
uses: actions/download-artifact@
|
|
89
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
90
90
|
with:
|
|
91
91
|
path: dist/
|
|
92
92
|
merge-multiple: true
|
|
@@ -169,7 +169,7 @@ jobs:
|
|
|
169
169
|
|
|
170
170
|
steps:
|
|
171
171
|
- name: Download all artifacts
|
|
172
|
-
uses: actions/download-artifact@
|
|
172
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
173
173
|
with:
|
|
174
174
|
path: dist/
|
|
175
175
|
merge-multiple: true
|
|
@@ -238,6 +238,7 @@ Key fields:
|
|
|
238
238
|
- `reference_id`: Reference identifier (uint32)
|
|
239
239
|
- `leap_status`: `LeapStatus` enum value
|
|
240
240
|
- `sources`: List of `SourceConfig` instances
|
|
241
|
+
- `source_list_changes`: Source lists chronyd moves to, one per source count it answers (see [Source List Changes](#source-list-changes))
|
|
241
242
|
- `rtc`: Optional `RTCConfig` for RTC data
|
|
242
243
|
- `error_injection`: Dict for injecting errors
|
|
243
244
|
|
|
@@ -341,6 +342,31 @@ def test_permission_error():
|
|
|
341
342
|
pass
|
|
342
343
|
```
|
|
343
344
|
|
|
345
|
+
### Source List Changes
|
|
346
|
+
|
|
347
|
+
chronyd adds and removes sources while it resolves `pool` and `server` names,
|
|
348
|
+
and when it replaces unreachable pool servers. A record count can then be
|
|
349
|
+
stale by the time a record is requested. The mock models this the way libchrony
|
|
350
|
+
behaves: the count is fixed when its response arrives, and a record request past
|
|
351
|
+
the end of the current list fails with `CHRONY_UNEXPECTED_STATUS`.
|
|
352
|
+
|
|
353
|
+
`source_list_changes` holds the lists chronyd moves to, in order. The mock takes
|
|
354
|
+
the next one right after it answers each `sources` or `sourcestats` count:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
config = ChronyStateConfig(
|
|
358
|
+
sources=[SourceConfig(address=a) for a in ("10.0.0.1", "10.0.0.2", "10.0.0.3")],
|
|
359
|
+
# After the first count (3), one source is gone: record 2 no longer exists.
|
|
360
|
+
source_list_changes=[[SourceConfig(address=a) for a in ("10.0.0.1", "10.0.0.2")]],
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
with patched_chrony_connection(config) as conn:
|
|
364
|
+
assert len(conn.get_sources()) == 2
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
The mock copies `sources` and `source_list_changes` per connection, so shared
|
|
368
|
+
scenarios are never modified.
|
|
369
|
+
|
|
344
370
|
### Custom Scenarios
|
|
345
371
|
|
|
346
372
|
Create custom scenarios for specific test needs:
|
|
@@ -46,7 +46,12 @@ RUN mkdir -p /run/chrony && chown chrony:chrony /run/chrony && chmod 750 /run/ch
|
|
|
46
46
|
# The command port stays at its default (323 on localhost) so the auto-detect
|
|
47
47
|
# fallback from the Unix socket can be exercised; "cmdport 0" would make that
|
|
48
48
|
# path untestable.
|
|
49
|
-
|
|
49
|
+
# Fedora's stock "pool" line is removed so the only NTP source is the single
|
|
50
|
+
# "server" below. A pool starts as 8 placeholders that need several DNS
|
|
51
|
+
# answers to fill, and chronyd waits 28s before retrying a partial fill,
|
|
52
|
+
# which outlasts the CI readiness gate.
|
|
53
|
+
RUN sed -i '/^pool /d' /etc/chrony.conf && \
|
|
54
|
+
echo "local stratum 10" >> /etc/chrony.conf && \
|
|
50
55
|
echo "allow 127.0.0.1" >> /etc/chrony.conf && \
|
|
51
56
|
echo "cmdallow 127.0.0.1" >> /etc/chrony.conf && \
|
|
52
57
|
echo "bindcmdaddress /run/chrony/chronyd.sock" >> /etc/chrony.conf && \
|
|
@@ -8,8 +8,9 @@ Internal implementation - use pychrony.ChronyConnection instead.
|
|
|
8
8
|
|
|
9
9
|
import math
|
|
10
10
|
import os
|
|
11
|
+
from collections.abc import Callable
|
|
11
12
|
from types import TracebackType
|
|
12
|
-
from typing import Any, NoReturn
|
|
13
|
+
from typing import Any, NoReturn, TypeVar
|
|
13
14
|
|
|
14
15
|
from ..exceptions import (
|
|
15
16
|
ChronyConnectionError,
|
|
@@ -29,6 +30,8 @@ from ..models import (
|
|
|
29
30
|
_ref_id_to_name,
|
|
30
31
|
)
|
|
31
32
|
|
|
33
|
+
_T = TypeVar("_T")
|
|
34
|
+
|
|
32
35
|
# Unix socket paths tried during auto-detect, in order. chrony documents its
|
|
33
36
|
# compiled-in default as /run/... or /var/run/... depending on release; the two
|
|
34
37
|
# are the same file wherever /var/run symlinks to /run.
|
|
@@ -74,15 +77,27 @@ except ImportError:
|
|
|
74
77
|
# rather than its data being bad. Taken from the compiled bindings so that an
|
|
75
78
|
# enumerator added upstream cannot silently shift them into misclassifying
|
|
76
79
|
# errors. The literals apply only with no bindings, where nothing is classified.
|
|
80
|
+
#
|
|
81
|
+
# CHRONY_UNEXPECTED_STATUS is how a request for a source index past the end of
|
|
82
|
+
# chronyd's list comes back: chronyd answers STT_NOSUCHSOURCE, which libchrony
|
|
83
|
+
# has no case for.
|
|
77
84
|
if _LIBRARY_AVAILABLE:
|
|
78
85
|
CHRONY_SEND_FAILED = _lib.CHRONY_SEND_FAILED
|
|
79
86
|
CHRONY_RECV_FAILED = _lib.CHRONY_RECV_FAILED
|
|
87
|
+
CHRONY_UNEXPECTED_STATUS = _lib.CHRONY_UNEXPECTED_STATUS
|
|
80
88
|
else:
|
|
81
89
|
CHRONY_SEND_FAILED = 5
|
|
82
90
|
CHRONY_RECV_FAILED = 6
|
|
91
|
+
CHRONY_UNEXPECTED_STATUS = 11
|
|
83
92
|
|
|
84
93
|
TRANSPORT_ERRORS = frozenset({CHRONY_SEND_FAILED, CHRONY_RECV_FAILED})
|
|
85
94
|
|
|
95
|
+
# Reads of a per-source report before the last failure is raised. chronyd adds
|
|
96
|
+
# and removes sources while it resolves pool and server names, and when it
|
|
97
|
+
# replaces unreachable pool servers, so the record count can be stale by the
|
|
98
|
+
# time a record is requested. Each read starts over from a fresh count.
|
|
99
|
+
SOURCE_LIST_READ_ATTEMPTS = 3
|
|
100
|
+
|
|
86
101
|
|
|
87
102
|
def _is_unix_socket_address(address: str) -> bool:
|
|
88
103
|
"""Return True if an address names a Unix socket path.
|
|
@@ -539,6 +554,58 @@ class ChronyConnection:
|
|
|
539
554
|
f"Failed to process {report_name.decode()} record {index}", err
|
|
540
555
|
)
|
|
541
556
|
|
|
557
|
+
def _read_records(
|
|
558
|
+
self, report_name: bytes, read_record: Callable[[], _T]
|
|
559
|
+
) -> list[_T]:
|
|
560
|
+
"""Read every record of a report, one request per record.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
report_name: Report name (e.g., b"sources")
|
|
564
|
+
read_record: Extracts and validates the record the session holds
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
One read_record result per record, in index order
|
|
568
|
+
|
|
569
|
+
Raises:
|
|
570
|
+
ChronyConnectionError: If chronyd could not be reached
|
|
571
|
+
ChronyDataError: If a record could not be retrieved or is invalid
|
|
572
|
+
"""
|
|
573
|
+
records = []
|
|
574
|
+
for index in range(self._request_report(report_name)):
|
|
575
|
+
self._request_record(report_name, index)
|
|
576
|
+
records.append(read_record())
|
|
577
|
+
return records
|
|
578
|
+
|
|
579
|
+
def _read_source_list(
|
|
580
|
+
self, report_name: bytes, read_record: Callable[[], _T]
|
|
581
|
+
) -> list[_T]:
|
|
582
|
+
"""Read a per-source report, starting over if the source list changes.
|
|
583
|
+
|
|
584
|
+
A shrinking list shows up as CHRONY_UNEXPECTED_STATUS on a record
|
|
585
|
+
request. libchrony uses that code for every chronyd status it does not
|
|
586
|
+
know, so a failure that is not a list change also gets retried, and
|
|
587
|
+
then raised once the attempts run out.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
report_name: Report name (b"sources" or b"sourcestats")
|
|
591
|
+
read_record: Extracts and validates the record the session holds
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
One read_record result per source, in index order
|
|
595
|
+
|
|
596
|
+
Raises:
|
|
597
|
+
ChronyConnectionError: If chronyd could not be reached
|
|
598
|
+
ChronyDataError: If a record is invalid, or could not be retrieved
|
|
599
|
+
on any of SOURCE_LIST_READ_ATTEMPTS reads
|
|
600
|
+
"""
|
|
601
|
+
for _ in range(SOURCE_LIST_READ_ATTEMPTS - 1):
|
|
602
|
+
try:
|
|
603
|
+
return self._read_records(report_name, read_record)
|
|
604
|
+
except ChronyDataError as e:
|
|
605
|
+
if e.error_code != CHRONY_UNEXPECTED_STATUS:
|
|
606
|
+
raise
|
|
607
|
+
return self._read_records(report_name, read_record)
|
|
608
|
+
|
|
542
609
|
def get_tracking(self) -> TrackingStatus:
|
|
543
610
|
"""Get current tracking status from chronyd.
|
|
544
611
|
|
|
@@ -650,19 +717,13 @@ class ChronyConnection:
|
|
|
650
717
|
... print(f"{src.address}: stratum {src.stratum}")
|
|
651
718
|
"""
|
|
652
719
|
self._ensure_context()
|
|
720
|
+
return self._read_source_list(b"sources", self._read_source)
|
|
653
721
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
for i in range(num_records):
|
|
660
|
-
self._request_record(b"sources", i)
|
|
661
|
-
data = self._extract_source()
|
|
662
|
-
self._validate_source(data)
|
|
663
|
-
sources.append(Source(**data))
|
|
664
|
-
|
|
665
|
-
return sources
|
|
722
|
+
def _read_source(self) -> Source:
|
|
723
|
+
"""Build a Source from the current session record."""
|
|
724
|
+
data = self._extract_source()
|
|
725
|
+
self._validate_source(data)
|
|
726
|
+
return Source(**data)
|
|
666
727
|
|
|
667
728
|
def _extract_source(self) -> dict:
|
|
668
729
|
"""Extract source fields from the current session record."""
|
|
@@ -745,19 +806,13 @@ class ChronyConnection:
|
|
|
745
806
|
... print(f"{s.address}: {s.samples} samples")
|
|
746
807
|
"""
|
|
747
808
|
self._ensure_context()
|
|
809
|
+
return self._read_source_list(b"sourcestats", self._read_sourcestats)
|
|
748
810
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
for i in range(num_records):
|
|
755
|
-
self._request_record(b"sourcestats", i)
|
|
756
|
-
data = self._extract_sourcestats()
|
|
757
|
-
self._validate_sourcestats(data)
|
|
758
|
-
stats.append(SourceStats(**data))
|
|
759
|
-
|
|
760
|
-
return stats
|
|
811
|
+
def _read_sourcestats(self) -> SourceStats:
|
|
812
|
+
"""Build a SourceStats from the current session record."""
|
|
813
|
+
data = self._extract_sourcestats()
|
|
814
|
+
self._validate_sourcestats(data)
|
|
815
|
+
return SourceStats(**data)
|
|
761
816
|
|
|
762
817
|
def _extract_sourcestats(self) -> dict:
|
|
763
818
|
"""Extract sourcestats fields from the current session record."""
|
|
@@ -133,6 +133,7 @@ tests/unit/test_mock_config.py
|
|
|
133
133
|
tests/unit/test_mock_scenarios.py
|
|
134
134
|
tests/unit/test_models.py
|
|
135
135
|
tests/unit/test_rtc_scenarios.py
|
|
136
|
+
tests/unit/test_source_list_changes.py
|
|
136
137
|
tests/unit/test_source_scenarios.py
|
|
137
138
|
tests/unit/test_testing.py
|
|
138
139
|
tests/unit/test_validation.py
|
|
@@ -126,6 +126,7 @@
|
|
|
126
126
|
"tests/unit/test_mock_scenarios.py",
|
|
127
127
|
"tests/unit/test_models.py",
|
|
128
128
|
"tests/unit/test_rtc_scenarios.py",
|
|
129
|
+
"tests/unit/test_source_list_changes.py",
|
|
129
130
|
"tests/unit/test_source_scenarios.py",
|
|
130
131
|
"tests/unit/test_testing.py",
|
|
131
132
|
"tests/unit/test_validation.py",
|
|
@@ -212,6 +212,9 @@ class ChronyStateConfig:
|
|
|
212
212
|
root_dispersion: Root dispersion in seconds
|
|
213
213
|
update_interval: Update interval in seconds
|
|
214
214
|
sources: List of source configurations
|
|
215
|
+
source_list_changes: Source lists chronyd moves to, in order, one
|
|
216
|
+
each time it answers a sources or sourcestats count. Simulates
|
|
217
|
+
sources added or removed between the count and the record requests.
|
|
215
218
|
rtc: RTC configuration (None if not available)
|
|
216
219
|
error_injection: Map of operation names to error codes
|
|
217
220
|
"""
|
|
@@ -233,6 +236,7 @@ class ChronyStateConfig:
|
|
|
233
236
|
|
|
234
237
|
# Sub-configurations
|
|
235
238
|
sources: list[SourceConfig] = field(default_factory=list)
|
|
239
|
+
source_list_changes: list[list[SourceConfig]] = field(default_factory=list)
|
|
236
240
|
rtc: RTCConfig | None = None
|
|
237
241
|
|
|
238
242
|
# Error injection
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from typing import TYPE_CHECKING, Any
|
|
11
11
|
|
|
12
|
+
from pychrony._core._bindings import CHRONY_UNEXPECTED_STATUS
|
|
12
13
|
from pychrony._core._fields import (
|
|
13
14
|
RTC_FIELDS,
|
|
14
15
|
SOURCE_FIELDS,
|
|
@@ -18,9 +19,12 @@ from pychrony._core._fields import (
|
|
|
18
19
|
)
|
|
19
20
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
21
|
-
from tests.mocks.config import ChronyStateConfig
|
|
22
|
+
from tests.mocks.config import ChronyStateConfig, SourceConfig
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
# Reports whose records are chronyd's sources, one record per source.
|
|
26
|
+
SOURCE_REPORTS = frozenset({"sources", "sourcestats"})
|
|
27
|
+
|
|
24
28
|
__all__ = [
|
|
25
29
|
"MockTimespec",
|
|
26
30
|
"MockFFI",
|
|
@@ -153,6 +157,7 @@ class MockLib:
|
|
|
153
157
|
return error
|
|
154
158
|
|
|
155
159
|
self._session.current_report = report_name.decode("utf-8")
|
|
160
|
+
self._session.count_requested = True
|
|
156
161
|
self._session.pending_responses = 1
|
|
157
162
|
return 0
|
|
158
163
|
|
|
@@ -161,29 +166,35 @@ class MockLib:
|
|
|
161
166
|
return self._session.pending_responses > 0
|
|
162
167
|
|
|
163
168
|
def chrony_process_response(self, session: Any) -> int:
|
|
164
|
-
"""Mock chrony_process_response.
|
|
169
|
+
"""Mock chrony_process_response.
|
|
170
|
+
|
|
171
|
+
Like libchrony, the record count is fixed when the count response
|
|
172
|
+
arrives, and a record request past the end of the source list fails
|
|
173
|
+
here with CHRONY_UNEXPECTED_STATUS.
|
|
174
|
+
"""
|
|
165
175
|
error = self._session.config.error_injection.get("chrony_process_response")
|
|
166
176
|
if error is not None:
|
|
167
177
|
return error
|
|
168
178
|
|
|
169
|
-
|
|
170
|
-
|
|
179
|
+
session_state = self._session
|
|
180
|
+
if session_state.pending_responses == 0:
|
|
181
|
+
return 0
|
|
182
|
+
session_state.pending_responses -= 1
|
|
183
|
+
|
|
184
|
+
if session_state.count_requested:
|
|
185
|
+
session_state.num_records = session_state.count_records()
|
|
186
|
+
if session_state.current_report in SOURCE_REPORTS:
|
|
187
|
+
session_state.advance_source_list()
|
|
188
|
+
elif (
|
|
189
|
+
session_state.current_report in SOURCE_REPORTS
|
|
190
|
+
and session_state.current_record_index >= len(session_state.sources)
|
|
191
|
+
):
|
|
192
|
+
return CHRONY_UNEXPECTED_STATUS
|
|
171
193
|
return 0
|
|
172
194
|
|
|
173
195
|
def chrony_get_report_number_records(self, session: Any) -> int:
|
|
174
196
|
"""Mock chrony_get_report_number_records."""
|
|
175
|
-
|
|
176
|
-
config = self._session.config
|
|
177
|
-
|
|
178
|
-
if report == "tracking":
|
|
179
|
-
return 1
|
|
180
|
-
elif report == "sources" or report == "sourcestats":
|
|
181
|
-
return len(config.sources)
|
|
182
|
-
elif report == "rtcdata":
|
|
183
|
-
if config.rtc is not None and config.rtc.available:
|
|
184
|
-
return 1
|
|
185
|
-
return 0
|
|
186
|
-
return 0
|
|
197
|
+
return self._session.num_records
|
|
187
198
|
|
|
188
199
|
def chrony_request_record(
|
|
189
200
|
self, session: Any, report_name: bytes, index: int
|
|
@@ -194,6 +205,7 @@ class MockLib:
|
|
|
194
205
|
return error
|
|
195
206
|
|
|
196
207
|
self._session.current_record_index = index
|
|
208
|
+
self._session.count_requested = False
|
|
197
209
|
self._session.pending_responses = 1
|
|
198
210
|
return 0
|
|
199
211
|
|
|
@@ -216,8 +228,8 @@ class MockLib:
|
|
|
216
228
|
# which dynamically resolves to "address" (mode != 2) or "reference ID" (mode == 2)
|
|
217
229
|
if name == "address":
|
|
218
230
|
record_idx = self._session.current_record_index
|
|
219
|
-
if record_idx < len(self._session.
|
|
220
|
-
source = self._session.
|
|
231
|
+
if record_idx < len(self._session.sources):
|
|
232
|
+
source = self._session.sources[record_idx]
|
|
221
233
|
# Import SourceMode here to avoid circular import
|
|
222
234
|
from pychrony.models import SourceMode
|
|
223
235
|
|
|
@@ -266,8 +278,12 @@ class MockChronySession:
|
|
|
266
278
|
|
|
267
279
|
Attributes:
|
|
268
280
|
config: Configuration driving mock behavior
|
|
281
|
+
sources: chronyd's current source list. Starts as config.sources and
|
|
282
|
+
steps through config.source_list_changes.
|
|
269
283
|
current_report: Currently active report name
|
|
270
284
|
current_record_index: Current record being accessed
|
|
285
|
+
count_requested: Whether the outstanding request is a record count
|
|
286
|
+
num_records: Record count from the last count response
|
|
271
287
|
pending_responses: Number of responses pending
|
|
272
288
|
lib: Mock _lib object
|
|
273
289
|
ffi: Mock _ffi object
|
|
@@ -275,12 +291,38 @@ class MockChronySession:
|
|
|
275
291
|
|
|
276
292
|
def __init__(self, config: ChronyStateConfig) -> None:
|
|
277
293
|
self.config = config
|
|
294
|
+
# Copies, so that stepping through source list changes leaves the
|
|
295
|
+
# config untouched: scenarios are shared module-level instances.
|
|
296
|
+
self.sources: list[SourceConfig] = list(config.sources)
|
|
297
|
+
self._source_list_changes = list(config.source_list_changes)
|
|
278
298
|
self.current_report: str | None = None
|
|
279
299
|
self.current_record_index: int = 0
|
|
300
|
+
self.count_requested: bool = False
|
|
301
|
+
self.num_records: int = 0
|
|
280
302
|
self.pending_responses: int = 0
|
|
281
303
|
self.lib = MockLib(self)
|
|
282
304
|
self.ffi = MockFFI()
|
|
283
305
|
|
|
306
|
+
def count_records(self) -> int:
|
|
307
|
+
"""Return the record count chronyd reports for the current report."""
|
|
308
|
+
report = self.current_report
|
|
309
|
+
|
|
310
|
+
if report == "tracking":
|
|
311
|
+
return 1
|
|
312
|
+
elif report in SOURCE_REPORTS:
|
|
313
|
+
return len(self.sources)
|
|
314
|
+
elif report == "rtcdata":
|
|
315
|
+
rtc = self.config.rtc
|
|
316
|
+
if rtc is not None and rtc.available:
|
|
317
|
+
return 1
|
|
318
|
+
return 0
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
def advance_source_list(self) -> None:
|
|
322
|
+
"""Move chronyd's source list to the next configured change, if any."""
|
|
323
|
+
if self._source_list_changes:
|
|
324
|
+
self.sources = list(self._source_list_changes.pop(0))
|
|
325
|
+
|
|
284
326
|
def get_field_value(self, index: int, field_type: FieldType) -> Any:
|
|
285
327
|
"""Get field value for current report and record.
|
|
286
328
|
|
|
@@ -335,13 +377,12 @@ class MockChronySession:
|
|
|
335
377
|
|
|
336
378
|
def _get_source_field(self, index: int) -> Any:
|
|
337
379
|
"""Get sources report field by index."""
|
|
338
|
-
config = self.config
|
|
339
380
|
record_idx = self.current_record_index
|
|
340
381
|
|
|
341
|
-
if record_idx >= len(
|
|
382
|
+
if record_idx >= len(self.sources):
|
|
342
383
|
return 0
|
|
343
384
|
|
|
344
|
-
source =
|
|
385
|
+
source = self.sources[record_idx]
|
|
345
386
|
fields = list(SOURCE_FIELDS.keys())
|
|
346
387
|
|
|
347
388
|
if index >= len(fields):
|
|
@@ -373,13 +414,12 @@ class MockChronySession:
|
|
|
373
414
|
|
|
374
415
|
def _get_sourcestats_field(self, index: int) -> Any:
|
|
375
416
|
"""Get sourcestats report field by index."""
|
|
376
|
-
config = self.config
|
|
377
417
|
record_idx = self.current_record_index
|
|
378
418
|
|
|
379
|
-
if record_idx >= len(
|
|
419
|
+
if record_idx >= len(self.sources):
|
|
380
420
|
return 0
|
|
381
421
|
|
|
382
|
-
source =
|
|
422
|
+
source = self.sources[record_idx]
|
|
383
423
|
fields = list(SOURCESTATS_FIELDS.keys())
|
|
384
424
|
|
|
385
425
|
if index >= len(fields):
|