Files
Dmitry Ng 242b79e97a feat: rework update/support contracts, fix SDK retry body replay, and refresh docs for v1.0.0
- Split reported components into images vs. files, add update strategies and per-stack resolution, and a shared action/reason vocabulary for update answers
- Add models.ParseEnvelope[T] and MsgLogTypeWait to match the server's response contract; fix SDK retries silently resending an exhausted request body
- Update examples/report-errors to continue issues via -issue-id and render streamed answers live; refresh README/API.md/doc.go for the license key flow
2026-08-12 20:09:30 +03:00

206 lines
8.2 KiB
Go

package models
import "fmt"
// ComponentAction is what a client should do about one reported artefact.
//
// Exactly one action comes back per reported component, always — including the
// ones the service could resolve nothing for. Before this existed, such a
// component was simply absent from the answer, which made "we have nothing for
// you" byte-identical to "you are up to date".
type ComponentAction string
const (
// ComponentActionCurrent: what is installed is what is offered.
ComponentActionCurrent ComponentAction = "current"
// ComponentActionInstall: nothing is installed and there is something to install.
ComponentActionInstall ComponentAction = "install"
// ComponentActionUpgrade: what is installed is older than what is offered.
ComponentActionUpgrade ComponentAction = "upgrade"
// ComponentActionDowngrade: the offered artefact carries a LOWER version than
// what is installed, and is offered anyway because it is not OLDER — a
// deliberate move onto a release, never a rollback in time.
ComponentActionDowngrade ComponentAction = "downgrade"
// ComponentActionUnknown: there is nothing to compare against. Always
// accompanied by a reason.
ComponentActionUnknown ComponentAction = "unknown"
)
func (a ComponentAction) String() string { return string(a) }
func (a ComponentAction) Valid() error {
switch a {
case ComponentActionCurrent, ComponentActionInstall, ComponentActionUpgrade,
ComponentActionDowngrade, ComponentActionUnknown:
return nil
default:
return fmt.Errorf("invalid ComponentAction: %s", a)
}
}
// Actionable reports whether this action is something the client can carry out.
// It is the definition of has_update: `current` and `unknown` neither raise it
// nor clear it.
func (a ComponentAction) Actionable() bool {
switch a {
case ComponentActionInstall, ComponentActionUpgrade, ComponentActionDowngrade:
return true
default:
return false
}
}
// ComponentReason explains an `unknown` action.
type ComponentReason string
const (
// ReasonTagNotPublished: the component and repository are published, but
// nothing under the reference this installation follows.
ReasonTagNotPublished ComponentReason = "tag_not_published"
// ReasonRepositoryNotTracked: the installation runs an image the service does
// not publish — usually a private rebuild under a private name.
ReasonRepositoryNotTracked ComponentReason = "repository_not_tracked"
// ReasonComponentNotTracked: nothing is published for this component on this
// platform at all.
ReasonComponentNotTracked ComponentReason = "component_not_tracked"
// ReasonNoReleaseArtifactForTag: the component exists, but the release the
// stack is measured against links no artefact of it.
ReasonNoReleaseArtifactForTag ComponentReason = "no_release_artifact_for_tag"
)
func (r ComponentReason) String() string { return string(r) }
func (r ComponentReason) Valid() error {
switch r {
case ReasonTagNotPublished, ReasonRepositoryNotTracked,
ReasonComponentNotTracked, ReasonNoReleaseArtifactForTag:
return nil
default:
return fmt.Errorf("invalid ComponentReason: %s", r)
}
}
// StackResolution says how a stack's answer was arrived at.
//
// DIAGNOSTIC ONLY. Neither an update button nor an application strategy may
// branch on it: it exists so that `has_update: false` can be READ rather than
// guessed at — most of all in the case it used to be silent about, an
// installation running something newer than anything curated.
type StackResolution string
const (
// StackResolutionRelease: answered from a curated release.
StackResolutionRelease StackResolution = "release"
// StackResolutionChannel: answered from the tags the client follows.
StackResolutionChannel StackResolution = "channel"
// StackResolutionAheadOfRelease: something installed is newer than what would
// have been offered, so nothing was. Not an error — the next release catches up.
StackResolutionAheadOfRelease StackResolution = "ahead_of_release"
// StackResolutionNoArtifactForTag: at least one component found nothing in its scope.
StackResolutionNoArtifactForTag StackResolution = "no_artifact_for_tag"
// StackResolutionNotTracked: at least one component names something not published at all.
StackResolutionNotTracked StackResolution = "not_tracked"
)
func (r StackResolution) String() string { return string(r) }
func (r StackResolution) Valid() error {
switch r {
case StackResolutionRelease, StackResolutionChannel, StackResolutionAheadOfRelease,
StackResolutionNoArtifactForTag, StackResolutionNotTracked:
return nil
default:
return fmt.Errorf("invalid StackResolution: %s", r)
}
}
// StackStatus is how an installation relates to one product stack.
//
// Four of the values are exactly the distinctions the pre-SDK contract drew with
// three booleans per stack (connected / installed / external), collapsed into
// one value so that "who runs what, and how" is a single field. The fifth says
// nothing is known, which is a different statement from all four.
type StackStatus string
const (
// StackStatusUndefined: nothing is known about this stack. It is what a stack
// nobody spoke about is recorded as, and a client may send it for a stack it
// cannot observe. It is NOT "not used" — reading it as that turns ignorance
// into a negative answer, and a fleet of clients too old to report anything
// then looks like a fleet that uses nothing.
StackStatusUndefined StackStatus = "undefined"
// StackStatusUnused: not configured. This is a positive statement — the
// client looked and there is nothing — and it is worth more than silence.
StackStatusUnused StackStatus = "unused"
// StackStatusConnected: configured and in use, but nothing of it deployed by
// this installation — typically still being set up.
StackStatusConnected StackStatus = "connected"
// StackStatusInstalled: deployed and operated by this installation.
StackStatusInstalled StackStatus = "installed"
// StackStatusExternal: in use, but deployed and operated elsewhere. There are
// no images to offer for it; the installation only talks to it.
StackStatusExternal StackStatus = "external"
)
func (s StackStatus) String() string { return string(s) }
func (s StackStatus) Valid() error {
switch s {
case StackStatusUndefined, StackStatusUnused,
StackStatusConnected, StackStatusInstalled, StackStatusExternal:
return nil
default:
return fmt.Errorf("invalid StackStatus: %s", s)
}
}
// AllStackStatuses is the vocabulary in one place, for the tests that guard the
// hand-written copies of it.
var AllStackStatuses = []StackStatus{
StackStatusUndefined, StackStatusUnused,
StackStatusConnected, StackStatusInstalled, StackStatusExternal,
}
// StackInfo is how one product stack is being used by this installation.
//
// It answers a question the component lists cannot. A stack deployed elsewhere
// reports no images at all — it has no reference that could be resolved — so
// without this field an externally hosted Langfuse is indistinguishable from no
// Langfuse, and that is the difference between "nobody wants this" and
// "everybody runs it their own way".
//
// Nothing in the answer depends on it. Report it anyway: it is the only way the
// service learns how its parts are actually deployed.
type StackInfo struct {
Stack ProductStack `json:"stack" validate:"required,valid"`
Status StackStatus `json:"status" validate:"required,valid"`
}
func (s StackInfo) Valid() error {
return validate.Struct(s)
}
// validArtefactAnswer is the invariant field tags cannot express: an entry
// either names an artefact or explains why it does not, and an action the client
// can carry out must always name one.
func validArtefactAnswer(
component ComponentType, action ComponentAction, reason *ComponentReason, resolved bool,
) error {
if action == ComponentActionUnknown {
if resolved {
return fmt.Errorf("%s: an unknown component must not carry an artefact", component)
}
if reason == nil {
return fmt.Errorf("%s: an unknown component must say why", component)
}
return nil
}
if reason != nil {
return fmt.Errorf("%s: a reason explains an unknown component, and this one is %s", component, action)
}
if action.Actionable() && !resolved {
return fmt.Errorf("%s: action %s names no artefact to act on", component, action)
}
return nil
}