Skip to content

fix(update-actions): port Tencent Cloud and Kubernetes updater fixes from #75 - #81

Merged
LDLDL merged 5 commits into
mainfrom
fix/update-actions
Oct 5, 2026
Merged

LDLDL merged 5 commits into
mainfrom
fix/update-actions

Conversation

@sosyz

@sosyz sosyz commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This ports the Tencent Cloud and Kubernetes updater fixes from #75 to the update actions that replaced the old certdx_tools sub-commands in v0.7.0 (pkg/client/updateactions/{tencentcloud,kubernetes}). @ExerciseBook, this is the k8s / txcloud part of #75 you were asked to review. It's smaller now and rebased onto the daemon-native code.

Tencent Cloud (fix(tencentcloud))

  • Compare the real expiry. The action used to take the newest uploaded certificate with matching SANs as the one to replace. Once an earlier delivery had uploaded the renewed certificate, that pick was the renewed certificate itself. It now compares each CertEndTime with the renewed certificate's real NotAfter:
    • stored is the renewed certificate, if it's already uploaded. Only a certificate whose CertEndTime equals the leaf's NotAfter to the second (±2s for rounding) is a candidate, and it is confirmed by content via DescribeCertificateDetail: the stored public certificate, or its SHA-1 fingerprint, must match the renewed leaf. If identity can't be checked, the update fails instead of guessing.
    • old is the newest other certificate that expires no later than the renewed one. A same-SAN reissue (e.g. an emergency key rotation within a day, even one with an identical expiry) therefore gets replaced, rather than being skipped as "already uploaded" or becoming the target of a CertificateExists rebind.
    • Longer-lived certificates are never replaced.
    • CertEndTime is parsed as Beijing time (the SDK documents it as GMT+8) instead of UTC.
  • Confirm deploys. After UpdateCertificateInstance, the action polls DescribeHostUpdateRecordDetail until every resource has been re-bound.
    • Failed resources are reported as an error.
    • A missing CAM permission for the poll only logs a warning.
    • A DeployRecordId of 0 repeats the request, as the SDK asks.
  • CertificateExists re-binds the resources to the stored copy instead of reporting a no-op as success. If the rebind returns CertificateNotDeployInstance / CertificateDeployInstanceEmpty, nothing is left on the old certificate (e.g. a redelivery after a daemon restart), and that counts as success.
  • Terminal vs transient errors.
    • Throttling, InternalError, network/5xx and CertificateDeployHasPendingRecord are retried inside the action with exponential backoff (2s, 4s, 8s). These errors come back in under retry.Do's 1s fast-fail floor, so they were never retried before.
    • Final deploy verdicts (failed resources, record never lists a resource, confirmation timeout) are wrapped in the new retry.Permanent, so the action runner doesn't replay the upload and deploy.
  • SAN set comparison ignores case, a trailing root dot and duplicates.
  • The SDK client sits behind a small sslAPI interface so the whole flow can be unit-tested with a fake.

retry (feat(retry))

  • retry.Permanent(err) / retry.IsPermanent(err): Do returns a marked error straight away, however long the attempt took. The action runner calls retry.Do around every action, so this is how the Tencent action stops slow-but-final failures from being retried. It's a new function; existing callers behave exactly as before.

Kubernetes (fix(kubernetes), feat(domain))

  • Wildcard coverage. Annotations were matched with domain.AllAllowed (the allow-list rule), which treats *.example.com as a literal label. So the documented wildcard-only certificate (["*.example.com", "*.mm.example.com"]) never matched a secret annotated foo.example.com. The action now uses the new domain.AllCovered, in a new file pkg/domain/cover.go. This change only widens matching:
    • Plain entries keep the parent-domain rule, so every secret that matched before still matches.
    • A wildcard entry covers itself and names exactly one label below it, never the apex (RFC 6125).
  • Server-side field selector type=kubernetes.io/tls on the cluster-wide secret list. The local type check stays as a guard.
  • docs/client.md describes the new matching rules and the Tencent confirmation flow.

Review feedback from #75 addressed

  • secretListPageSize = 500 / paginated listing: LDLDL said "Don't do this for now." It is not included. The list call is the same as on main apart from the added FieldSelector, which doesn't depend on pagination.
  • The k8s and txcloud parts ExerciseBook was asked to review are all in this PR.

Dropped as no longer applicable

  • Tencent run deadline for cron alerting (waitDeadline + SIGINT/SIGTERM context in task.go): the action now runs inside the long-lived client daemon, which already cancels its root context on shutdown. Every step still has its own limit:

    • each SDK request: ReqTimeout 60s
    • transient retries: capped at 3
    • deploy confirmation: 5 min (90s if the record never lists a resource)

    A deadline for the whole run would add nothing and could cut off a slow deploy half-way.

  • Expiring/activating certificate matching (findReplacementCertificate, the FilterExpiring lookups, oldCertEnd): v0.7.0 removed the expiring-certificate filter. The expiry comparison against the renewed certificate described above replaces it.

  • Kubernetes pending-secret tracking and richer wait-timeout errors (markPending, pendingWatchNames, waitReplaceTask): the daemon action has no one-shot wait. Each Update already returns per-secret errors joined together.

  • Tencent WaitReplaceTask deadline error aggregation: same reason; there is no wait step any more.

Breaking changes

None. Two behaviour changes, neither of which breaks an existing config:

  • The kubernetes action now also patches secrets covered by a wildcard entry.
  • The tencentCloud action needs ssl:DescribeHostUpdateRecordDetail to confirm deploys. Without it the action logs a warning and carries on.
  • The tencentCloud action needs ssl:DescribeCertificateDetail, but only when a same-SAN certificate expires at the same second as the renewed one (typically an earlier delivery already uploaded it). Without that permission, such an update fails with an explicit error.

Test results

  • gofmt -l pkg exec test: clean
  • go vet ./pkg/...: clean
  • go test -race -count=1 ./pkg/...: all pass
  • go build ./... && go vet ./... in ., exec/caddytls, exec/client, exec/server, exec/tools, test/e2e: all pass

New and ported tests:

  • tencentcloud: util_test.go ports fix: address 48 findings from full code-review (server / client / ACME / tools) #75's SAN canonicalization, Beijing-zone time parsing, deploy-record, permission and transient-classification tests. The new action_test.go runs the whole flow against a fake SSL client: replace + confirm, failed deploy / unconfirmable / timeout are permanent, permission-less confirmation, CertificateExists rebind, redelivery no-op, DeployRecordId == 0 repeat, transient retry, stored-copy identity by content and by fingerprint, a reissue expiring within a day or at the same second being replaced rather than treated as stored, no rebind to an unrelated same-expiry certificate, and failing when identity can't be checked. util_test.go also covers fingerprint normalization.
  • kubernetes: wildcard coverage (covered, wildcard, nested and apex secrets) and the field selector.
  • domain: cover_test.go with fix: address 48 findings from full code-review (server / client / ACME / tools) #75's CertCovers / AllCovered cases.
  • retry: Permanent stops retrying.

Not verified against the live Tencent Cloud API. In particular, which error code UpdateCertificateInstance returns when the old certificate has nothing bound comes from the SDK's documented error list.

Split from #75.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T08:27:03.995755Z 27fdbb8 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 27fdbb83a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +375 to +378
case !end.After(newEnd.Add(sameExpiryTolerance)):
if stored == nil || end.After(storedEnd) {
stored, storedEnd = cert, end
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not identify stored certificates by a 24-hour expiry window

When a certificate is reissued for the same SANs within 24 hours—such as an emergency key rotation or a retry after revocation—the previously uploaded certificate is classified as stored solely because its expiry is close to newEnd. If it is the only matching certificate, Update returns success without deploying the newly delivered certificate; if another older certificate exists, the CertificateExists path can rebind resources to this unrelated certificate. Since parseTxcTime already converts the zone-less value to an absolute Beijing time, stored-copy detection should use the API's timestamp precision or verify certificate identity rather than accepting a full-day window.

Useful? React with 👍 / 👎.

sosyz and others added 5 commits September 29, 2026 17:51
IsSubdomain treats every allow-list entry as a literal label sequence,
which is right for the server allow-list gate but wrong when matching
against the names a certificate was issued for: a "*.example.com"
certificate does cover "foo.example.com".

CertCovers / CoveredByAny / AllCovered keep IsSubdomain's rule for
literal entries and add RFC 6125 wildcard matching: a wildcard covers
itself and names exactly one label below its base, never the apex.

Split from #75.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A certificate listing only "*.example.com" (the documented example)
never matched a secret annotated "foo.example.com": the annotation was
checked with the allow-list rule, which matches "*.example.com" as a
literal label. Match with domain.AllCovered instead, so wildcard
entries cover names one label below them. Plain entries keep the
parent-domain rule, so every secret matched before still matches.

Also ask the apiserver for kubernetes.io/tls secrets only instead of
listing every secret in the cluster on each renewal; the local type
check stays as a guard.

Split from #75.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Do retries any failure that took longer than a second, which replays
work whose slow answer is nonetheless final (a Tencent Cloud deploy
record reporting failed resources arrives after minutes of polling).
retry.Permanent wraps such an error so Do returns it straight away;
the wrapped error stays reachable through errors.Is / errors.As.

Split from #75.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Port the Tencent Cloud updater fixes from #75 to the daemon-native
update action:

- Pick the certificate to replace by comparing each uploaded
  certificate's CertEndTime with the renewed certificate's real
  NotAfter. The newest same-SAN certificate used to be chosen blindly,
  which is the renewed certificate itself once an earlier delivery
  uploaded it. CertEndTime is parsed as Beijing time, not UTC.
- Wait for the UpdateCertificateInstance deploy record
  (DescribeHostUpdateRecordDetail) before reporting success, bounded by
  a five-minute confirmation window. Failed resources are an error; a
  missing CAM permission for the read-only poll only warns.
- On FailedOperation.CertificateExists, re-bind the resources to the
  stored copy instead of reporting a no-op as success. A rebind that
  finds nothing bound to the old certificate (a redelivery after a
  daemon restart) succeeds.
- Retry throttling, InternalError, network errors and a not-yet-created
  deploy task with backoff; they fail faster than retry.Do's fast-fail
  floor and were never retried. Mark final deploy verdicts
  retry.Permanent so the action runner does not replay the upload.
- Compare SAN sets case-, root-dot- and duplicate-insensitively.

Split from #75.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pickCertificates treated any same-SAN certificate expiring within 24h
of the renewed one as the renewed certificate already uploaded. A
reissue inside that window (emergency key rotation, reissue after
revocation) was then skipped as "already uploaded", or became the
target of a CertificateExists rebind.

Match expiry to the second only (CertEndTime is documented as GMT+8,
so the tolerance just absorbs rounding), and confirm such a candidate
with DescribeCertificateDetail: the stored public certificate, or its
SHA-1 fingerprint, must equal the renewed leaf. A same-expiry
certificate with other content is replaced like any older one. If the
identity cannot be checked, the update fails instead of guessing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
// Only TLS secrets can ever be updated, so let the apiserver do the
// filtering instead of returning every secret in the cluster.
raw, err := a.kubeClient.CoreV1().Secrets("").List(ctx, metav1.ListOptions{
FieldSelector: tlsSecretFieldSelector,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

这么好的功能怎么不早点告诉我 😡

@LDLDL
LDLDL merged commit eb0afa4 into main Oct 5, 2026
2 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.

3 participants