Skip to content

Commit 7d5355b

Browse files
feat(release-controller): add ignored_proposals to release-index.yaml (#2015)
1 parent e15984f commit 7d5355b

6 files changed

Lines changed: 301 additions & 7 deletions

File tree

release-controller/README.md

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,42 @@ If the proposal was indeed submitted, you don't have to do anything -- the recon
205205
rm /state/<full_commit_hash>
206206
```
207207
208+
### Forcing resubmission of a previously-submitted proposal
209+
210+
The reconciler rebuilds its in-memory state on every cycle from the public
211+
dashboard and the governance canister (via `dre proposals filter -t
212+
ic-os-version-election`), and treats every matching election proposal as
213+
"already submitted" regardless of its status. A rejected or failed proposal
214+
will therefore prevent the reconciler from ever submitting a fresh one for
215+
the same version.
216+
217+
To work around this, edit [`release-index.yaml`](../release-index.yaml)
218+
and add a top-level `ignored_proposals` entry listing the NNS proposal IDs
219+
to forget. The reconciler reads this list on every cycle (no redeploy
220+
required) and drops the matching proposals from its dashboard / governance
221+
lookups before they reach `ReconcilerState`, so the affected versions go
222+
back to the "no proposal" state and a fresh proposal is submitted on the
223+
next cycle.
224+
225+
```yaml
226+
ignored_proposals:
227+
- 141776 # GuestOS election proposal rejected; resubmit the version.
228+
releases:
229+
- rc_name: rc--...
230+
versions:
231+
- ...
232+
```
233+
234+
Caveats:
235+
236+
- The IC governance canister independently refuses to re-elect a version
237+
that is already blessed. This lever only helps when the prior
238+
proposal did **not** result in a blessing (typically REJECTED or
239+
FAILED).
240+
- Remove the entry from `release-index.yaml` once the replacement
241+
proposal has been submitted. Leaving it in indefinitely silently
242+
swallows any future state for the same proposal id.
243+
208244
## Development
209245

210246
Please see the parent folder's `README.md` for virtual environment setup.
@@ -243,11 +279,10 @@ bazel run //release-controller:release-controller \
243279
Typing errors preventing you from running it, because you are editing code and
244280
testing your changes? Add `--output_groups=-mypy` right after `bazel run`.
245281

246-
The optional argument `--skip-preloading-state` makes it so that the reconciler
247-
will not preload its list of known proposals by version from the governance
248-
canister. It is useful (in conjunction with an empty reconciler state folder)
249-
to make the reconciler do all the work of submitting proposals again. It should
250-
only be used alongside `--dry-run`, to avoid submitting proposals twice.
282+
To force the reconciler to resubmit a proposal whose prior submission was
283+
rejected or otherwise failed, edit `release-index.yaml` and add the
284+
proposal ID to the top-level `ignored_proposals` list. See *Forcing
285+
resubmission of a previously-submitted proposal* above.
251286

252287
### Running the reconciler in the container it ships
253288

release-controller/reconciler.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -435,10 +435,80 @@ def __init__(
435435
self.change_determinator_factory = change_determinator_factory
436436
self.local_release_state: dict[str, dict[str, dict[OsKind, VersionState]]] = {}
437437

438+
@staticmethod
439+
def _filtered_proposals_retriever(
440+
retriever: typing.Callable[
441+
[],
442+
tuple[
443+
dict[str, dre_cli.ElectionProposal],
444+
dict[str, dre_cli.ElectionProposal],
445+
],
446+
],
447+
source: str,
448+
ignore_proposal_ids: typing.Iterable[int],
449+
) -> typing.Callable[
450+
[],
451+
tuple[
452+
dict[str, dre_cli.ElectionProposal],
453+
dict[str, dre_cli.ElectionProposal],
454+
],
455+
]:
456+
"""Wrap a proposal retriever so it drops ignored proposal IDs.
457+
458+
The wrapped retriever omits any proposal whose ``id`` is listed in
459+
the index's top-level ``ignored_proposals`` so the affected versions
460+
are treated by :class:`reconciler_state.ReconcilerState` as if no
461+
proposal had been submitted.
462+
"""
463+
ignore_set: set[int] = set(ignore_proposal_ids)
464+
465+
def _drop_ignored(
466+
d: dict[str, dre_cli.ElectionProposal],
467+
os_kind: OsKind,
468+
) -> dict[str, dre_cli.ElectionProposal]:
469+
out: dict[str, dre_cli.ElectionProposal] = {}
470+
for version, proposal in d.items():
471+
if proposal["id"] in ignore_set:
472+
LOGGER.warning(
473+
"Ignoring %s %s election proposal %s for version %s"
474+
" (listed in release-index.yaml ignored_proposals);"
475+
" the reconciler will treat this version as not yet"
476+
" proposed.",
477+
source,
478+
os_kind,
479+
proposal["id"],
480+
version,
481+
)
482+
continue
483+
out[version] = proposal
484+
return out
485+
486+
def wrapped() -> tuple[
487+
dict[str, dre_cli.ElectionProposal],
488+
dict[str, dre_cli.ElectionProposal],
489+
]:
490+
guestos, hostos = retriever()
491+
if not ignore_set:
492+
return guestos, hostos
493+
return (
494+
_drop_ignored(guestos, GUESTOS),
495+
_drop_ignored(hostos, HOSTOS),
496+
)
497+
498+
return wrapped
499+
438500
def reconcile(self) -> None:
439501
"""Reconcile the state of the network with the release index."""
440502
logger = LOGGER.getChild("reconciler")
441503
index = self.loader.index()
504+
ignore_proposal_ids = list(index.root.ignored_proposals)
505+
if ignore_proposal_ids:
506+
logger.info(
507+
"release-index.yaml requests that proposals %s be ignored"
508+
" when populating reconciler state; the corresponding"
509+
" versions will be treated as not yet proposed.",
510+
ignore_proposal_ids,
511+
)
442512

443513
# As a matter of principle, we will only process the very top
444514
# two releases (and all its versions). All else will be
@@ -449,7 +519,13 @@ def reconcile(self) -> None:
449519

450520
# Fetch latest election proposals and remember their state.
451521
try:
452-
self.state.update_state(self.dashboard.get_election_proposals_by_version)
522+
self.state.update_state(
523+
self._filtered_proposals_retriever(
524+
self.dashboard.get_election_proposals_by_version,
525+
source="dashboard",
526+
ignore_proposal_ids=ignore_proposal_ids,
527+
)
528+
)
453529
except Exception as e:
454530
logger.warning(
455531
"Did not succeed in retrieving proposals from"
@@ -460,7 +536,13 @@ def reconcile(self) -> None:
460536
# from the DRE CLI (coming from governance canister)
461537
# after the dashboard, since these are the authoritative proposals
462538
# that have the freshest state (dashboard lags).
463-
self.state.update_state(self.dre.get_election_proposals_by_version)
539+
self.state.update_state(
540+
self._filtered_proposals_retriever(
541+
self.dre.get_election_proposals_by_version,
542+
source="dre",
543+
ignore_proposal_ids=ignore_proposal_ids,
544+
)
545+
)
464546

465547
# Preload the cache of known successfully processed releases.
466548
# We will use this information as an operation plan.

release-controller/release_index.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Release(BaseModel):
3838

3939
class ReleaseIndex(BaseModel):
4040
model_config = ConfigDict(extra="forbid")
41+
ignored_proposals: List[int] = []
4142
releases: List[Release]
4243

4344
def version(self, rc_name: str, name: str) -> str:

release-controller/tests/test_reconciler_integration.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import git_repo
77
import pydantic_yaml
88
import pytest_mock.plugin
9+
import reconciler_state
910
from dre_cli import ElectionProposal
1011
from public_dashboard import DashboardAPI
1112
from reconciler import Reconciler
@@ -140,6 +141,165 @@ def _cdf(r: git_repo.GitRepo) -> commit_annotation.ChangeDeterminatorProtocol:
140141
return commit_annotation.LocalCommitChangeDeterminator(r)
141142

142143

144+
def _ignore_filter_retriever_fixture() -> typing.Callable[
145+
[],
146+
tuple[dict[str, ElectionProposal], dict[str, ElectionProposal]],
147+
]:
148+
"""
149+
Build a self-contained retriever returning one GuestOS and one HostOS
150+
election proposal with distinct IDs. This bypasses ``MockDashboard``
151+
(whose ``_fake_proposal`` always emits ``hostos_version_to_elect``)
152+
so the test exercises both branches of the ignore filter.
153+
"""
154+
guestos_commit = "11" * 20
155+
hostos_commit = "22" * 20
156+
157+
def retriever() -> tuple[
158+
dict[str, ElectionProposal], dict[str, ElectionProposal]
159+
]:
160+
guestos: dict[str, ElectionProposal] = {
161+
guestos_commit: {
162+
"id": 200001,
163+
"payload": {
164+
"replica_version_to_elect": guestos_commit,
165+
"release_package_sha256_hex": "aa" * 32,
166+
},
167+
"proposal_timestamp_seconds": 1743789296,
168+
"proposer": 61,
169+
"status": "REJECTED",
170+
"summary": "...stubbed out...",
171+
"title": "Elect new IC/GuestOS revision (test fixture)",
172+
}
173+
}
174+
hostos: dict[str, ElectionProposal] = {
175+
hostos_commit: {
176+
"id": 200002,
177+
"payload": {
178+
"hostos_version_to_elect": hostos_commit,
179+
"release_package_sha256_hex": "bb" * 32,
180+
},
181+
"proposal_timestamp_seconds": 1743789296,
182+
"proposer": 61,
183+
"status": "REJECTED",
184+
"summary": "...stubbed out...",
185+
"title": "Elect new IC/HostOS revision (test fixture)",
186+
}
187+
}
188+
return guestos, hostos
189+
190+
return retriever
191+
192+
193+
def test_reconciler_filtered_proposals_retriever_drops_ignored_ids() -> None:
194+
"""
195+
With ``ignored_proposals`` set, the wrapper around the proposal
196+
retriever must omit any matching proposal so the corresponding
197+
version is treated by ``ReconcilerState`` as not yet proposed.
198+
"""
199+
retriever = _ignore_filter_retriever_fixture()
200+
guestos_commit, hostos_commit = "11" * 20, "22" * 20
201+
ignored_guestos_id = 200001
202+
rs = ReconcilerState()
203+
204+
wrapped = Reconciler._filtered_proposals_retriever(
205+
retriever,
206+
source="dashboard",
207+
ignore_proposal_ids=[ignored_guestos_id],
208+
)
209+
guestos, hostos = wrapped()
210+
211+
assert guestos == {}, guestos
212+
assert list(hostos.keys()) == [hostos_commit], hostos
213+
assert hostos[hostos_commit]["id"] == 200002
214+
215+
rs.update_state(wrapped)
216+
assert isinstance(
217+
rs.version_proposal(guestos_commit, const.GUESTOS),
218+
reconciler_state.NoProposal,
219+
)
220+
assert isinstance(
221+
rs.version_proposal(hostos_commit, const.HOSTOS),
222+
reconciler_state.SubmittedProposal,
223+
)
224+
225+
226+
def test_reconciler_filtered_proposals_retriever_is_noop_without_ids() -> None:
227+
"""Without ``ignored_proposals``, the wrapper returns the data unchanged."""
228+
retriever = _ignore_filter_retriever_fixture()
229+
230+
direct_guestos, direct_hostos = retriever()
231+
wrapped_guestos, wrapped_hostos = Reconciler._filtered_proposals_retriever(
232+
retriever,
233+
source="dashboard",
234+
ignore_proposal_ids=[],
235+
)()
236+
237+
assert wrapped_guestos == direct_guestos
238+
assert wrapped_hostos == direct_hostos
239+
240+
241+
def test_reconciler_picks_up_ignored_proposals_from_release_index(
242+
ic_repo: git_repo.GitRepo,
243+
mocker: pytest_mock.plugin.MockerFixture,
244+
) -> None:
245+
"""
246+
``ignored_proposals`` declared at the top of ``release-index.yaml`` must
247+
propagate through the loader into the per-cycle ignore set, so a
248+
previously-rejected proposal stops blocking resubmission.
249+
"""
250+
with mocker.patch.object(ic_repo, "push_release_tags"):
251+
d, f, n, rs, a, dre, s, rl, p, db = _defaults_for_test()
252+
already_blocked_hostos_id = 138817
253+
rl = StaticReleaseLoader(
254+
pydantic_yaml.to_yaml_str(
255+
ReleaseIndexModel.model_validate(
256+
{
257+
"ignored_proposals": [already_blocked_hostos_id],
258+
"releases": [
259+
_release(
260+
"rc--2025-10-02_03-13",
261+
{"base": "45657852c1eca6728ff313808db29b47c862ad13"},
262+
),
263+
_release(
264+
"rc--2025-09-25_09-52",
265+
{"base": "206b61a8616bc93d36d6a014e5cc8edf1ba256ae"},
266+
),
267+
_release(
268+
"rc--2025-09-19_10-17",
269+
{"base": "bf0d4d1b8cb6c0c19a5afa1454ada014847aa5c6"},
270+
),
271+
],
272+
}
273+
)
274+
)
275+
)
276+
reconciler = Reconciler(
277+
f, rl, n, p, "", rs, ic_repo, lambda: _cdf(ic_repo), a, dre, db, s
278+
)
279+
280+
def fake_approved_release_notes(*args): # type: ignore
281+
return f"Fake changelog for {args}"
282+
283+
rl.proposal_summary = fake_approved_release_notes # type: ignore
284+
reconciler.reconcile()
285+
286+
# The HostOS dashboard fixture pins id 138817 to both top releases.
287+
# After reconcile, those versions must have been seen as "not yet
288+
# proposed" — i.e. the reconciler issued a fresh proposal for them
289+
# (dryrun.DRECli returns a synthetic, version-derived ID), so the
290+
# state for them no longer points at the ignored id.
291+
for commit in (
292+
"45657852c1eca6728ff313808db29b47c862ad13",
293+
"206b61a8616bc93d36d6a014e5cc8edf1ba256ae",
294+
):
295+
prop = rs.version_proposal(commit, const.HOSTOS)
296+
assert isinstance(prop, reconciler_state.SubmittedProposal), prop
297+
assert prop.proposal_id != already_blocked_hostos_id, (
298+
f"Expected a fresh proposal id for HostOS commit {commit}, "
299+
f"got the previously-blocked id {already_blocked_hostos_id}."
300+
)
301+
302+
143303
def test_reconciler_reconciles_without_error_already_submitted_proposals(
144304
ic_repo: git_repo.GitRepo,
145305
mocker: pytest_mock.plugin.MockerFixture,

release-index-schema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
"type": "object",
77
"additionalProperties": false,
88
"properties": {
9+
"ignored_proposals": {
10+
"type": "array",
11+
"items": {
12+
"type": "integer"
13+
},
14+
"description": "NNS election proposal IDs the reconciler must treat as if they had never been submitted, so it submits a fresh proposal for the underlying version on the next cycle. Intended to recover from rejected/failed proposals."
15+
},
916
"releases": {
1017
"type": "array",
1118
"items": {

release-index.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77
# HostOS or GuestOS:
88
# rc_name: <reference RC name>
99
# name: <version name>
10+
# - Use 'ignored_proposals' (top-level) to force the reconciler to forget
11+
# specific NNS election proposals when it builds its in-memory state, so
12+
# the affected versions go back to the "no proposal" state and a fresh
13+
# proposal is submitted on the next cycle. Intended to recover from
14+
# rejected / failed proposals. Remove the entry once the replacement
15+
# proposal has been submitted; the IC governance canister will still
16+
# refuse to re-elect an already blessed version.
17+
ignored_proposals:
18+
- 141776 # GuestOS election proposal rejected; resubmit the version.
1019
releases:
1120
- rc_name: rc--2026-05-15_10-22
1221
versions:

0 commit comments

Comments
 (0)