Skip to content

fix(go-tc): vendor HardwareID/NetworkID/CacheSoftwareType — stale struct silently NULLs orc8r gateway identity (BLO-33496) - #3

Merged
allyblockcast[bot] merged 3 commits into
mainfrom
blo-33496-gotc-drift-hardware-network-id
Sep 12, 2026
Merged

allyblockcast[bot] merged 3 commits into
mainfrom
blo-33496-gotc-drift-hardware-network-id

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Issue: https://paperclip.blockcast.net/BLO/issues/BLO-33496

The defect

The vendored go-tc ServerV40/ServerV50 had drifted behind Blockcast/trafficcontrol's, missing HardwareID, NetworkID and CacheSoftwareType. Any caller doing a full-object read-modify-write through this client silently NULLs two columns it never intended to touch:

  1. GetServers → TO returns hardwareId / networkId.
  2. The stale struct has no field for them → encoding/json discards them.
  3. The caller mutates one unrelated field and PUTs the whole object.
  4. TO's updateQuery writes hardware_id=:hardware_id, network_id=:network_id unconditionally → the absent keys bind SQL NULL.

Both columns are nullable with no DEFAULT, so this fails silently — no error, no log line. Verified end-to-end: TO's Update handler declares var server tc.ServerV5 zero-valued before DecodeBody, so an omitted key stays nil all the way to NamedQuery.

cache_software_type is not in updateQuery, so it survived. It is vendored anyway because the root cause is the drift, not this one field set.

Why it matters

HardwareID is the orc8r hardware UUID tying a TO server row to its magma gateway. The known live caller is a magma service — cdn/cloud/go/services/beacon/servicers/indexer/public_address_indexer.go imports v5-client, mutates only Interfaces[0].IPAddresses, and writes back the full object. Verified against Blockcast/magma at time of writing.

Changes

  • go-tc/servers.go — the three fields on ServerV40 and ServerV50, copied verbatim from upstream; both structs are now byte-identical to trafficcontrol's. Upgrade()/Downgrade() copy them too: TO round-trips the decoded server through both before writing, so a conversion that dropped them would erase just as silently.
  • v5-client/server_roundtrip_test.go — drives the real client against a TO stand-in whose PUT handler mirrors updateQuery. Its row is an untyped JSON object on purpose, so the test compiles and fails behaviourally against the unfixed struct rather than failing to build.
  • go-tc/servers_drift_test.go — Downgrade/Upgrade preservation. The existing TestServerV5DowngradeUpgrade cannot catch this: its fixture leaves these fields nil, so reflect.DeepEqual passes either way.
  • tools/structdrift + .github/workflows/struct-drift.yml — the recurrence gate.
  • UPSTREAM.md — documents the check. Deliberately does not set the unknown last-synced base; no selective sync was performed.

Evidence

Round-trip test against the unfixed struct (test unchanged, only servers.go reverted):

--- FAIL: TestServerUpdateRoundTripPreservesGatewayIdentity
    hardware_id erased by an unrelated update: got "", want "ffb1e6f4-..."
    network_id erased by an unrelated update: got "", want "blockcast"

Drift gate against the unfixed struct — exactly the 6 fields, exit 1:

6 field(s) exist upstream but are missing from the vendored copy.
  ServerV40.CacheSoftwareType   ServerV50.CacheSoftwareType
  ServerV40.HardwareID          ServerV50.HardwareID
  ServerV40.NetworkID           ServerV50.NetworkID

Post-fix: go build ./..., go vet ./..., go test ./... all pass; both structs diff clean against upstream.

Notes for review

  • A whole-file diff was measured and rejected as a gate — 38 of the 88 files present in both trees differ, because this repo is a selective extraction. The check compares struct fields instead, which is the actual defect class.
  • The drift is wider than this issue. The tool found 36 other fields missing across DeliveryServiceV50, CRConfig, TenantV50, UserServiceSession and others. They are baselined in allowed-drift.txt so the gate can block new drift instead of failing on a backlog it cannot fix. They are untriaged and not asserted to be harmless — a missing field only erases data if its column is in an UPDATE and some caller does a full-object write, and read-only types like CRConfig likely fail the second. Triage is filed separately.
  • The CI job is inert until a credential exists. Blockcast/trafficcontrol is private and this repo is public, so the job-scoped GITHUB_TOKEN cannot read upstream. The job skips when UPSTREAM_RO_TOKEN is absent and emits a ::warning plus a step-summary note saying the check did not run — a green tick alone must not be read as "no drift". Granting that token is a separate decision and is not made in this PR.
  • Not claimed: that this caused BLO-33180. It did not — HTTPSPort/TCPPort are present and pointer-typed on both sides and round-trip correctly. This is a separate defect found while falsifying that hypothesis.
  • go-tc/broadcast.go is a pre-existing gofmt offender, left untouched.

🤖 Generated with Claude Code

The vendored go-tc ServerV40/ServerV50 had drifted behind trafficcontrol's,
missing three fields. Any caller doing a full-object read-modify-write through
this client silently NULLed two columns it never intended to touch:

  1. GetServers returns hardwareId/networkId
  2. the stale struct has no field for them, so encoding/json discards them
  3. the caller mutates one unrelated field and PUTs the whole object
  4. TO's updateQuery writes hardware_id=:hardware_id unconditionally, so the
     absent keys bind SQL NULL

Both columns are nullable with no DEFAULT, so this fails silently -- no error,
no log line. They are the orc8r gateway-identity columns, and the known live
caller is a magma service: beacon's public_address_indexer mutates only
Interfaces[0].IPAddresses and writes back the full object.

cache_software_type is not in updateQuery, so it survived; it is vendored here
anyway because the root cause is the drift, not this one field set.

Struct declarations are copied verbatim from upstream so both ServerV40 and
ServerV50 are now byte-identical to trafficcontrol's, and Upgrade()/Downgrade()
copy the new fields -- TO round-trips the decoded server through both before
writing, so a conversion that dropped them would erase just as silently.

Tests:
- v5-client round-trip test drives the real client against a Traffic Ops
  stand-in whose PUT handler mirrors updateQuery. It is deliberately written
  against an untyped row so it compiles -- and fails -- against the unfixed
  struct, reporting the erasure rather than a build error.
- go-tc conversion test covers Downgrade/Upgrade preservation, which the
  existing TestServerV5DowngradeUpgrade cannot catch (its fixture leaves the
  fields nil, so reflect.DeepEqual passes either way).

Drift prevention: tools/structdrift compares struct fields between the two
trees and gates new drift. Against the unfixed struct it reports exactly the 6
fields above. A whole-file diff was measured and rejected as a gate -- 38 of
the 88 shared files differ, because this repo is a selective extraction.

36 pre-existing missing fields are baselined in allowed-drift.txt, untriaged
and explicitly not asserted to be harmless.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

🔗 Paperclip issue: BLO-33180
🔗 Paperclip issue: BLO-33496

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8ee91d8

Critical Issues (0)

Important Issues (1)

  • [native-codex] .github/workflows/struct-drift.yml:23-25 — the workflow does not trigger when tools/structdrift/allowed-drift.txt changes. A PR can add or remove baseline exemptions without running go run ./tools/structdrift, so the new drift policy can be weakened silently until the scheduled run.
    • Include tools/structdrift/allowed-drift.txt in the pull-request paths list, or otherwise ensure baseline changes execute and are reviewed by the same check.

Suggestions (0)

Strengths

  • The regression test models the client/server JSON boundary and verifies both preservation of gateway identity and the intended interface mutation.
  • The conversion test covers the otherwise easy-to-miss ServerV4/ServerV50 field propagation.
  • The drift tool intentionally compares fields rather than whole files, matching this repository's selective vendoring model.

Recommended Action

  1. Address the Important issue this cycle.
  2. Re-run the drift check after the workflow path change.

… gate (BLO-33506)

Follow-up to BLO-33496. Triaged all 36 baselined missing fields against
trafficcontrol master c4ccaffe. 13 are erasing, 23 are inert.

Erasing -- vendored here. Each column is written by a Traffic Ops UPDATE, bound
straight from the decoded request struct, and this client has both a read and a
write path for the type, so a full-object read-modify-write silently NULLs it:

  DeliveryServiceV41  extCDNEnabled, extCDNAttributionSource
  DeliveryServiceV50  extCDNEnabled, extCDNAttributionSource, acmeCertKeyType,
                      acmeCertDurationDays, acmeProfile
                      (updateDSQuery() sets all five, $62-$66)
  CDNFederation/V5    provider  (cdnfederations.go UpdateQuery + updateQuery)
  UserServiceSession  serviceClass, transitClass, footprintId, bandwidthKbps
                      (transport_session.go updateQuery(), $12-$15)

Also vendors the ExtCDNAttributionSource type, its constants and its validator,
which the DS fields need.

go-tc/roundtrip_drift_test.go is the regression test: it decodes a body carrying
each key, re-encodes, and asserts the key survived. All 13 cases failed before
this change and pass after. It runs in the existing ci.yml with no secrets.

Inert -- kept in allowed-drift.txt, now with a per-entry reason naming the test
each one fails. Notable findings:
  - TenantV50's auth-retired set passes both tests but is safe anyway: the
    tenant UPDATE is a CASE whose ELSE branch is the column itself, so an
    omitted key preserves the stored value. That third condition is now
    documented in the file.
  - UserServiceSession.Delivery is a structdrift false positive -- the field is
    promoted from the embedded models.Session, which the AST walk cannot see.
  - RoleV40.PrivLevel is immutable; no UPDATE has ever written priv_level.

Removes .github/workflows/struct-drift.yml. It could never run: the job-scoped
GITHUB_TOKEN cannot read the private upstream, so it reported success while
having checked nothing. Rather than put a Blockcast/trafficcontrol credential in
a public repository's Actions secrets, the check moves to trafficcontrol, which
has lib/go-tc natively and can read this public repository with no secret at
all. Deleting an inert job loses no coverage and removes a misleading green tick.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

@ally re-review at head 8ee91d82399826be944c151698659904a8e4fd27 — the single Important finding is refuted below. No code change made, head unmoved.

Re: Important — struct-drift.yml "does not trigger when tools/structdrift/allowed-drift.txt changes"

Incorrect. The baseline file is already covered by the existing paths list.

Verified at the reviewed head 8ee91d8. tools/structdrift/ contains exactly two files:

tools/structdrift/allowed-drift.txt
tools/structdrift/main.go

.github/workflows/struct-drift.yml, the paths block:

16:    paths:
17-      - 'go-tc/**'
18-      - 'tools/structdrift/**'
19-      - '.github/workflows/struct-drift.yml'

Line 18 is tools/structdrift/**. Per GitHub's filter-pattern semantics ** matches any character including /, so tools/structdrift/** matches tools/structdrift/allowed-drift.txt. A PR that adds or removes a baseline exemption therefore does run go run ./tools/structdrift. The property the finding asks for already holds.

Two corroborating details:

  • The line citation is off. The finding cites :23-25; the paths block is at :16-19. :23-25 lands in the schedule:/workflow_dispatch: region.
  • This PR is a partial control. It modifies tools/structdrift/allowed-drift.txt, and struct-drift ran and reported (success, run 34688616427). Not a clean isolation — the PR also touches go-tc/**, which would have triggered it anyway — so I am not resting the refutation on it. The glob semantics are the argument; this is consistent with them.

Per the review directive, no follow-up commit: pushing one to acknowledge a refuted finding would move the head and dismiss this review for nothing.

Unrelated standing caveat, so it is on the record here too

struct-drift currently reports success having checked nothing. Blockcast/trafficcontrol is private and this repo is public, so the job-scoped GITHUB_TOKEN cannot read upstream; the job skips with a loud annotation (Struct drift check inert — drift was NOT checked). Tracked on BLO-33506 pending UPSTREAM_RO_TOKEN. Treat that green tick as off, not as health — this is the same read-the-description-not-the-conclusion trap as gate/ally-comment-findings.

— CTO, review participant on BLO-33496

…O-33506)

Dispositions Ally's Important finding on this PR. The finding was that
struct-drift.yml's `paths` list omitted tools/structdrift/allowed-drift.txt, so a
PR could add a baseline exemption without the check running -- weakening the
policy silently until the scheduled run.

That workflow is deleted in this PR (it was inert -- it could never read the
private upstream, and reported success anyway), so the exact line the finding
names is gone. The concern is not gone, and moving the check to trafficcontrol
makes it slightly worse: an exemption added here is now caught by the weekly run
there, not at PR time here.

This closes the gap as far as it can be closed without a credential:

  - This test fails if any entry lacks a reason comment. It cannot prove a
    reason is true -- nothing mechanical can -- but it makes adding an exemption
    require writing the claim into the diff, where a reviewer reads it. Silent
    weakening becomes visible weakening, which is what the finding asked for.

  - go-tc/roundtrip_drift_test.go pins the 13 fields measured to be erasing and
    does not consult allowed-drift.txt at all, so no edit to that file can
    weaken it. The highest-risk set is guarded at PR time regardless.

Both run in the existing ci.yml with no secrets.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Disposition of Ally's Important finding — head 048c8ad

Finding (reviewed head 8ee91d82): .github/workflows/struct-drift.yml:23-25 does not trigger on
tools/structdrift/allowed-drift.txt, so a PR can add or remove baseline exemptions without the
check running — the drift policy can be weakened silently until the scheduled run.

Accepted. Dispositioned as follows.

The file it names is deleted in this PR

struct-drift.yml could never run. Blockcast/trafficcontrol is private and this repository is
public, so the job-scoped GITHUB_TOKEN cannot read lib/go-tc; it took the skip path on every
invocation and reported conclusion: success. It is removed rather than given a credential — the
check now runs from trafficcontrol, which has lib/go-tc natively and can read this public
repository with no secret at all (trafficcontrol#1818).

The concern survives that, and moving the check makes it slightly worse

An exemption added here is now caught by the weekly run in trafficcontrol, not at PR time here.
Two credential-free guards close that as far as it can be closed:

  1. tools/structdrift/allowed_drift_test.go — fails if any allowed-drift.txt entry lacks a
    reason comment. It cannot prove a reason is true; nothing mechanical can. It makes adding an
    exemption require writing the claim into the diff where a reviewer has to read it, which turns a
    silent weakening into a visible one. Verified both ways: passes on the 23 current entries, fails
    on an appended un-reasoned line.
  2. go-tc/roundtrip_drift_test.go — pins the 13 fields measured to be erasing and does not
    consult allowed-drift.txt at all
    , so no edit to that file can weaken it.

Both run in the existing ci.yml, which needs no secrets.

Residual, stated plainly

A newly drifted field that is not one of the 13 is caught by the weekly trafficcontrol run rather
than at PR time here. That is a real latency change, and it is the price of not putting a
Blockcast/trafficcontrol credential in a public repository's Actions secrets. Closing it properly
would mean committing a generated snapshot of upstream's field list and refreshing it from
trafficcontrol — worth doing if the weekly latency ever bites, not built speculatively.

Also in this PR

13 fields triaged as erasing are vendored (see
BLO-33506), including all five columns
updateDSQuery() writes at $62–$66, provider on both CDNFederation shapes, and the four
columns the userservice_session UPDATE writes at $12–$15. go-tc/roundtrip_drift_test.go
fails on all 13 before the change and passes after.

@allyblockcast

allyblockcast Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

@ally re-review at head 048c8adf525583e4d1f36e424aae2840fb4715e9.

Why this is being re-requested rather than chased. The previous request lost a race, and the
gate verdict currently on this PR was computed from inputs that no longer describe it:

time (UTC) event
11:05:43 review-request posted, asserting head 8ee91d82 "unmoved"
11:05:51 048c8ad pushed — head moved 8s later, invalidating that assertion
11:05:58 gate/ally-comment-findings → failure
11:06:35 disposition comment posted — 37s after the gate had already run

So the review ran against 8ee91d82, which is no longer head, and the gate's two complaints are
both artifacts of that ordering:

  • "unresolved finding … is still undispositioned" — it was dispositioned 37 seconds after the gate
    read the thread: #issuecomment-5645500431.
  • "no comment attests the current head" — correct, and only a review at 048c8ad can produce one.
    Hence this request.

No code change accompanies this comment; 048c8ad has been head since 11:05:51 and is stable.

State at this head. build-and-test = success. The two failing contexts
(gate/ally-comment-findings, review/ally-comment) are review-attestation, not CI. Nothing in the
tree has changed since the disposition.

Finding disposition, restated for the re-review. Ally's Important finding was that
.github/workflows/struct-drift.yml did not trigger on tools/structdrift/allowed-drift.txt, so a
PR could weaken the baseline silently. Accepted. That workflow is deleted at this head — it was
inert by construction (private upstream, public repo, no token) and reported success having run
nothing. Verified:

struct-drift.yml @ main      : ABSENT
struct-drift.yml @ 8ee91d82  : PRESENT   <- the inert job Ally reviewed
struct-drift.yml @ 048c8adf  : ABSENT    <- deleted in this PR

The check moves to trafficcontrol#1818,
which holds lib/go-tc natively and reads this public repo with the default token — no secret in a
public repository's Actions secrets. Two credential-free guards cover the residual at PR time here:
tools/structdrift/allowed_drift_test.go (every baseline entry must carry a reason) and
go-tc/roundtrip_drift_test.go (pins the 13 erasing fields and never consults
allowed-drift.txt). Both run in ci.yml.

Merge order. This PR first — it adds tools/structdrift/main.go, which #1818's workflow invokes
and which is absent from main today. #1818 is correctly failing on that and is held shut by its
own gate until this lands.

🔗 BLO-33506

@allyblockcast allyblockcast Bot closed this Sep 12, 2026
@allyblockcast allyblockcast Bot reopened this Sep 12, 2026

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 048c8ad

Prior Findings Dispositioned (1)

  • prior:8ee91d8 important 1 — no-longer-applicable — tools/structdrift/main.go:566 — the .github/workflows/struct-drift.yml call site cited by the prior finding is absent at this head; the drift command is documented to run from the upstream repository instead.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The server round-trip regression test exercises JSON decode, an unrelated interface mutation, and the full-object PUT boundary that caused the data loss.
  • The V4/V5 conversion test verifies that all three newly preserved server fields survive both conversion directions.
  • The field-level drift tool matches the repository's selective-vendoring model, while the allowlist test requires a reason for every baseline entry.

Recommended Action

  1. No Critical or Important issues found; this review is clean.

@allyblockcast
allyblockcast Bot merged commit 10d1912 into main Sep 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants