Documentation
¶
Overview ¶
Package update asks, once a day, whether a newer LinkCtrl has been published.
What leaves this instance, enumerated rather than summarized ¶
One `GET` to a compile-time constant URL. The request carries:
- this instance's source address, which is a property of opening a socket and not something this package chooses;
- the running version, in the `User-Agent`, so a maintainer reading their own logs can tell a 0.2 instance from a 0.3 one.
**Nothing else.** No instance identifier, no deployment size, no link counts, no configuration, no account. There is no request body, no query string, no cookie and no credential. TestTheRequestCarriesNothingButAVersion compares the outgoing request against an exact expected form — every header, the method, the URL and the body — so a field added here fails the build rather than shipping quietly. That test is the disclosure's enforcement, and it is deliberately written to be annoying to change.
The response is read for a version and discarded. Nothing from it is stored beyond the version string, which travels in the notification that reports it.
The rules it inherits, and the one it does not ¶
Both of this product's existing operator-facing clients refuse redirects — internal/feed and internal/webhook — and this one does too, on the same reasoning: a host that answers 302 is pointing this process at a URL nobody configured, and the destination being a constant is exactly what makes a redirect away from it worth refusing rather than following.
It does **not** get internal/webhook's dial-time address check. That guard exists because a webhook URL is a *user's* choice and can resolve anywhere, including at a cloud metadata endpoint. This destination is a constant in this file: there is no input, no registration path and nobody to rebind on behalf of. Adding the check would suggest there is something here to defend that there is not, and the difference is stated rather than left to be noticed.
Why it is its own job family rather than a step in `housekeeping` ¶
See cmd/linkctrl/jobs.go. In short: this is the only scheduled work in the product that opens a socket outwards, and burying it inside a family called *maintenance* is how egress stops being auditable in one place.
Index ¶
Constants ¶
const DefaultTimeout = 10 * time.Second
DefaultTimeout bounds one check end to end: connect, write, read.
Ten seconds, matching the webhook client. Generous for one small GET, and bounded for the same reason every other outbound call in this product is: a host that accepts a connection and then says nothing must not hold a job goroutine open indefinitely. There is no retry — see Service.Run.
const Endpoint = "https://api.github.com/repos/DevOfPie/LinkCtrl/releases/latest"
Endpoint is where the check asks, and it is a constant on purpose.
Not configurable. An operator who wants this instance to ask somewhere else wants a different product's release feed reported as this product's, and the setting that allowed it would be the one that turned a disclosed, auditable daily GET into "wherever this variable points". Turning the check *off* is the control that exists (`LINKCTRL_UPDATE_CHECK`), and it is the only one.
`/releases/latest` rather than `/releases`: GitHub's definition of *latest* already excludes drafts and pre-releases, so the milestone's *pre-releases and drafts are ignored* is true of the endpoint and not only of the code. The payload's own flags are checked anyway, below, because a claim that rests on somebody else's API semantics is a claim with nothing testing it here.
const Interval = 24 * time.Hour
Interval is the shortest gap between two checks. Once a day, which is what m55.md asks for and what the enforcing UPDATE in ClaimUpdateCheck is given.
The ticker that drives the pass is faster than this on purpose (see cmd/linkctrl/jobs.go): a job whose ticker *is* its period drifts a day later every restart, and on an instance that is redeployed most days it would never check at all. The bound is the database row; the ticker only decides how soon after the day is up the check happens.
Variables ¶
var ErrNoRelease = errors.New("update: the repository has published no release")
ErrNoRelease is what a repository with no published release answers with.
Its own error because it is the ordinary state of a fresh fork rather than a fault: GitHub answers 404 at `/releases/latest` when every release is a draft or a pre-release, and logging that at the same level as a refused connection would make a working instance look broken.
Functions ¶
func UserAgent ¶
UserAgent is the one identifying string the request carries.
`LinkCtrl/<version>`, and nothing else — no platform, no Go version, no hostname. build.Info carries all three and putting them here would be a deployment fingerprint offered for nothing: the version is what makes an aggregate answerable ("how many instances are still on 0.2"), and the rest only narrows it toward one instance.
Types ¶
type Announcer ¶
Announcer is internal/notify's writing half, as this package needs it.
The consumer owns the interface, the way internal/notify owns Enqueuer: this package should be able to be a client, a comparison and a daily bound without also knowing what an instance principal is, and a test satisfies this with a slice.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client fetches the latest published release.
func NewClient ¶
func NewClient(version, endpoint string, rt http.RoundTripper) *Client
NewClient builds the checker's HTTP client.
`endpoint` empty means Endpoint, which is what production always passes; a test passes its own httptest server. There is no configuration path that reaches this argument.
func (*Client) Latest ¶
Latest fetches the newest published release.
A draft or pre-release answers ErrNoRelease rather than a Release, so a caller cannot forget to check the flags. The endpoint should never return one — that is what `/releases/latest` means — and the check is here because the milestone's promise is about this product's behaviour and not about GitHub's.
type Config ¶
type Config struct {
// Version is build.Get().Version. A build reporting "dev" never notifies,
// which ParseVersion enforces rather than a special case here.
Version string
// Endpoint overrides the constant. Empty is production; only a test passes
// anything, and there is no configuration path that reaches it.
Endpoint string
// Announce receives a newer release. Nil means the check still runs and
// still records that it ran, and tells nobody — which is a shape only a test
// builds, and it is guarded rather than left to panic.
Announce Announcer
Log *slog.Logger
// Interval overrides Interval. Zero is Interval.
Interval time.Duration
}
Config is what a Service needs.
type Release ¶
type Release struct {
TagName string `json:"tag_name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
Release is the whole of what this package reads out of the response.
Three fields from a payload that has around forty. Everything else — the body, the assets, the author, the URLs — is decoded into nothing and discarded, which is what makes *the response is read for a version* a property of this struct rather than a promise in a comment.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the daily check: claim the day, ask, compare, tell somebody.
func (*Service) Run ¶
Run performs at most one check.
It is never fatal and never reaches a user ¶
Every failure below returns nil after a Debug line. Nothing here blocks startup or shutdown — the pass runs on the scheduler, which the process does not wait for — and no surface in the product renders any of it. A GitHub that is down, rate-limited, or answering nonsense is a day on which this instance learns nothing, which is exactly what m55.md promises and what docs/deployment.md tells an air-gapped operator to expect.
Debug rather than Warn is a deliberate choice about whose problem this is. A failed webhook is somebody's event not arriving; a failed update check is a question that went unanswered, and logging it loudly would train operators to ignore the log on instances that were never going to be able to reach GitHub.
And it never retries ¶
ClaimUpdateCheck writes the timestamp *before* the request, so a failure consumes the day the same way a success does. That is the whole of "no retry storm": there is no attempt counter to get wrong, because a second attempt cannot be reached until the row says a day has passed.
type Version ¶
type Version struct {
Major, Minor, Patch int
}
Version is a release version reduced to the three numbers that order it.
func IsNewer ¶
IsNewer reports whether `remote` names a release newer than `local`.
False whenever either side does not parse, which covers three of the milestone's four rules at once: a `dev` build never notifies, an unparseable remote version is a no-op rather than an error surface, and an equal or older remote is silence. The fourth — drafts and pre-releases — is handled where the response is read, because that is a fact about the release rather than about its number.
func ParseVersion ¶
ParseVersion reads the leading `vX.Y.Z` of a version string.
**Everything after the patch number is dropped, and that is the decision this function exists to make.** internal/build reports what the linker stamped, and what the Makefile stamps is `git describe`: a build 39 commits past the v0.2.0 tag reports `v0.2.0-39-g888dbcd`, and one from a dirty tree gets `-dirty` on the end. Read as semver those sort *below* `0.2.0`, because a pre-release suffix precedes its release — so an instance running code newer than 0.2.0 would be told that 0.2.0 was available. Read the way the string is actually produced, the suffix means "past this tag", and the honest comparison is against the tag itself with a strict `>` on the other side.
A leading `v` is optional, because a Git tag has one and a plain version string does not.
`false` for anything that does not start with three numbers, which is how **a build reporting "dev" never notifies**: `dev` and `dev-dirty` have no numbers to compare and there is nothing to be newer than. A development binary telling its operator to upgrade is noise, and this is where that is enforced rather than by a special case on the word.