Commit a6adf838 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

fix(r2): edit a partner's agreement in place instead of replacing it

Federation Manager now keeps one agreement per partner
(oop/code/federation-manager!40):
a second POST /internal/agreements gets 409 agreement-exists.
fm_partners.py posted a new agreement whenever the inventory terms
changed and suspended the old one, so a re-run with changed zones or
terms failed.

agree() now PATCHes the partner's agreement with only what differs:
API types, zones (always with their terms), terms, status back to active,
no end date, and a start date no later than now. With no agreement it still
POSTs one. An FM from before one-per-partner may hold several; the one it
applies is edited and other active ones are suspended, as before.

Validation on test-op1 (FM develop + fix/zone-sync-limits), partner
operator-2:
- old script, changed terms: 409, playbook step fails
- same terms on an agreement with an end date: updated valid_until
- re-run: unchanged
- added flavour: updated zone_resources; removing it again: same
- suspended agreement: updated status; FM sent ZONES REMOVE then ADD,
  both delivered to OP-2
The testbed agreement was restored afterwards.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent b829ef09
Loading
Loading
Loading
Loading
+2 −1
Changes for ansible/roles/oop-platform/defaults/main.yml: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -109,7 +109,8 @@ oop_platform_extra_values: {}
#     terms                GSMA terms offered in each zone: reservedComputeResources,
#                          computeResourceQuotaLimits, flavoursSupported
#     api_types            requests allowed (default install-app, remove-app)
#                          A changed agreement replaces the active one, which is suspended.
#                          FM keeps one agreement per partner: a changed one is edited in
#                          place and made active again, with no end date.
oop_platform_partners: []
# Same agreement for every partner the r2_existing_cluster federate playbook finds.
oop_platform_partner_agreement: {}
+47 −18
Changes for ansible/roles/oop-platform/files/fm_partners.py: 47 added lines, 18 removed lines.
Original line number Diff line number Diff line
@@ -78,8 +78,20 @@ def covers(wanted, stored):
    return wanted == stored


def when(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


def stamp(moment):
    return moment.strftime("%Y-%m-%dT%H:%M:%SZ")


def agree(partner, wanted, zone_ids):
    """Make the partner's active agreement match wanted; FM keeps the old ones for audit."""
    """Make the partner's agreement match wanted: active, no end date, these terms.

    FM keeps one agreement per partner (a second POST gets 409), so an existing one is
    edited in place; FM tells the partner about zones added or withdrawn.
    """
    missing = [z for z in wanted["zones"] if z not in zone_ids]
    if missing:
        raise SystemExit(
@@ -87,28 +99,45 @@ def agree(partner, wanted, zone_ids):
            f"(known: {', '.join(sorted(zone_ids)) or 'none'})"
        )
    ids = [zone_ids[z] for z in wanted["zones"]]
    body = {
        "partner_op_id": partner["id"],
    now = datetime.now(timezone.utc)
    terms = {
        "permitted_api_types": sorted(wanted.get("api_types", ["install-app", "remove-app"])),
        "permitted_zone_ids": ids,
        "zone_resources": {i: wanted["terms"] for i in ids},
        "valid_from": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "status": "active",
    }
    detail = expect(*call("GET", f"/internal/partners/{partner['id']}"), 200)
    active = [a for a in detail.get("agreements", []) if a["status"] == "active"]
    for current in active:
        if (
            sorted(current["permitted_api_types"]) == body["permitted_api_types"]
            and sorted(current["permitted_zone_ids"]) == sorted(ids)
            and covers(body["zone_resources"], current["zone_resources"])
            and current["valid_until"] is None
        ):
    # the one FM applies first; an FM from before one-per-partner may hold several
    current, *others = sorted(
        (a for a in detail.get("agreements", []) if a["status"] != "expired"),
        key=lambda a: (a["status"] != "active", when(a["valid_from"])),
    ) or [None]
    if current is None:
        body = {"partner_op_id": partner["id"], **terms, "valid_from": stamp(now), "status": "active"}
        return expect(*call("POST", "/internal/agreements", body), 201)["id"], "created"

    changes = {}
    if sorted(current["permitted_api_types"]) != terms["permitted_api_types"]:
        changes["permitted_api_types"] = terms["permitted_api_types"]
    # zones and their terms go together: FM refuses terms for a zone it does not grant
    if sorted(current["permitted_zone_ids"]) != sorted(ids):
        changes["permitted_zone_ids"] = ids
        changes["zone_resources"] = terms["zone_resources"]
    elif not covers(terms["zone_resources"], current["zone_resources"]):
        changes["zone_resources"] = terms["zone_resources"]
    if current["status"] != "active":
        changes["status"] = "active"
    if current["valid_until"] is not None:
        changes["valid_until"] = None
    if when(current["valid_from"]) > now:
        changes["valid_from"] = stamp(now)
    if changes:
        expect(*call("PATCH", f"/internal/agreements/{current['id']}", changes), 200)
    for older in others:
        if older["status"] == "active":
            expect(*call("PATCH", f"/internal/agreements/{older['id']}", {"status": "suspended"}), 200)
    if not changes:
        return current["id"], "unchanged"
    created = expect(*call("POST", "/internal/agreements", body), 201)
    for current in active:
        expect(*call("PATCH", f"/internal/agreements/{current['id']}", {"status": "suspended"}), 200)
    return created["id"], "created" + (f", suspended {len(active)} older" if active else "")
    return current["id"], "updated " + ", ".join(sorted(changes))


def local_zones():
@@ -123,7 +152,7 @@ def federate(partner, status_link):
        return body["FederationContextId"], "unchanged"
    expect(status, body, 404)
    request = {
        "initialDate": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "initialDate": stamp(datetime.now(timezone.utc)),
        "partnerStatusLink": status_link,
    }
    body = expect(*call("POST", path, request), 200)