[management] Check a provider's url and credential before saving it

The provider form accepted anything and found out later. A typo in the
upstream, a key pasted a character short, an AWS access key in a field that
wants a Bedrock API key — all saved cleanly, then surfaced minutes later as a
failed request or an empty model picker, with nothing pointing back at the
record that caused it.

CreateProvider now spends the credential once against the vendor's own model
listing, and UpdateProvider does the same when the upstream or the key
changed — only then, so renames, model rows and price edits neither wait on a
vendor nor fail because one is having a bad day. Both run before the store
write, so a rejected rotation leaves the working key exactly where it was.

The check reuses the discovery Fetch rather than a lighter status probe. It
exercises the path the model picker will take, so a URL answering 200 with a
login page fails here instead of passing a status check and producing an empty
picker later.

Failures that mean "we cannot ask" are not failures: a gateway with no listing
endpoint, a Bedrock record behind a proxy where no control-plane host can be
derived, and a self-hosted endpoint on a private network that the proxy
reaches through the tunnel but management cannot. None of those are evidence
the record is wrong, and refusing them would make this a lockout.

Discovery failures are typed for it. The message an operator sees carries no
status code and never echoes their URL — WriteError lowercases it, and paths
are case-sensitive, so an echoed URL would come back altered and describe
something they did not type. The vendor's status is logged instead.
This commit is contained in:
mlsmaycon
2026-08-23 21:26:06 +00:00
parent f03853867b
commit 4e3578b847
7 changed files with 683 additions and 14 deletions
@@ -146,7 +146,7 @@ func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) {
resp, err := c.httpClient().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("reach %s: %w", entry.Name, err)
return nil, &UnreachableError{Provider: entry.Name, Err: err}
}
defer func() { _ = resp.Body.Close() }()
@@ -157,7 +157,7 @@ func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) {
if resp.StatusCode != http.StatusOK {
// Surface the vendor's own status. An operator whose key lacks a scope
// needs to see 403 rather than a generic failure.
return nil, fmt.Errorf("%s returned %d for its model listing", entry.Name, resp.StatusCode)
return nil, &VendorStatusError{Provider: entry.Name, Status: resp.StatusCode}
}
ids, err := parseListing(entry.Discovery.Shape, body)
@@ -194,8 +194,8 @@ func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, erro
region = RegionFromUpstream(entry, req.UpstreamURL)
}
if region == "" {
return "", fmt.Errorf("%w: %s discovery needs a region, and none could be read from the provider upstream",
ErrInvalidRequest, entry.Name)
return "", fmt.Errorf("%w: %w: %s discovery needs a region, and none could be read from the provider upstream",
ErrInvalidRequest, ErrNoDiscoveryHost, entry.Name)
}
host = strings.ReplaceAll(host, catalog.RegionPlaceholder, region)
}
@@ -266,7 +266,7 @@ func (c *Client) checkPublicHost(host string) error {
// loopback address is still a way to reach loopback.
for _, addr := range addrs {
if !isPublic(addr) {
return fmt.Errorf("%w: discovery host %q resolves to a non-public address", ErrInvalidRequest, host)
return fmt.Errorf("%w: %w: discovery host %q resolves to a non-public address", ErrInvalidRequest, ErrPrivateHost, host)
}
}
return nil
@@ -463,7 +463,7 @@ func guardDialAddress(address string) error {
return fmt.Errorf("discovery dial address %q is not an IP", host)
}
if !isPublic(addr) {
return fmt.Errorf("discovery refused to dial non-public address %s", addr)
return fmt.Errorf("%w: discovery refused to dial non-public address %s", ErrPrivateHost, addr)
}
return nil
}
@@ -0,0 +1,117 @@
package modeldiscovery
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"os"
"syscall"
)
// The errors below exist so a caller can tell one discovery failure from
// another without reading the message. Fetch is used for two jobs now: filling
// the model picker, which only needs to know that it failed, and checking a
// provider's credential at save time, which has to tell the operator whether
// the URL or the key is the problem. A string is the wrong thing to branch on
// for the second, so each failure carries its own type.
// VendorStatusError reports that the vendor answered the listing with
// something other than 200. Status is the vendor's own code: 401 and 403 mean
// the credential was refused, 404 and 405 mean the URL does not serve this
// API at all, and 5xx means the vendor is unwell — three different things to
// tell an operator, and only the number separates them.
type VendorStatusError struct {
Provider string
Status int
}
func (e *VendorStatusError) Error() string {
return fmt.Sprintf("%s returned %d for its model listing", e.Provider, e.Status)
}
// UnreachableError reports that the request never reached the vendor at all:
// the name did not resolve, the connection was refused, TLS failed, or the
// attempt timed out. Nothing was authenticated, so the credential is not
// implicated — only the URL is.
type UnreachableError struct {
Provider string
Err error
}
func (e *UnreachableError) Error() string {
return fmt.Sprintf("reach %s: %v", e.Provider, e.Err)
}
func (e *UnreachableError) Unwrap() error { return e.Err }
// Reason names the transport failure in the words an operator can act on.
// "connection refused" and "no such host" are the difference between a wrong
// port and a wrong hostname, which is worth the few lines it takes to tell
// them apart. An empty string means the cause was not one we recognise, and
// the caller should say only that the host could not be reached rather than
// paste a Go error into the UI.
func (e *UnreachableError) Reason() string {
err := e.Err
var dns *net.DNSError
if errors.As(err, &dns) {
if dns.IsNotFound {
return "no such host"
}
return "dns lookup failed"
}
// Timeouts are checked before the syscall cases: a dial that times out is
// reported as a net.OpError wrapping a timeout, and the operator needs to
// hear "timed out" rather than the syscall underneath it.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) {
return "connection timed out"
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "connection timed out"
}
if errors.Is(err, syscall.ECONNREFUSED) {
return "connection refused"
}
if errors.Is(err, syscall.EHOSTUNREACH) || errors.Is(err, syscall.ENETUNREACH) {
return "host unreachable"
}
var certErr *tls.CertificateVerificationError
if errors.As(err, &certErr) {
return "tls certificate not trusted"
}
var recordErr tls.RecordHeaderError
if errors.As(err, &recordErr) {
return "not a tls endpoint"
}
return ""
}
// ErrUnparseableListing marks a 200 whose body is not a model listing in the
// shape the catalog declared. It is a distinct outcome from a status refusal:
// the host answered and authenticated fine, it just is not the API we were
// aiming at — a login page or an unrelated service on the configured URL.
var ErrUnparseableListing = errors.New("response is not a model listing")
// ErrNoDiscoveryHost marks a provider whose listing host cannot be worked out
// from the record. Bedrock's listing lives on a control-plane host derived
// from the region in the upstream, so an operator pointing the record at a
// proxied or self-hosted endpoint leaves nowhere to send it. Inventing a host
// would spend their credential somewhere they never configured.
//
// It wraps ErrInvalidRequest so the discovery endpoint keeps answering 400,
// while a credential check can recognise it as "cannot be checked" rather
// than "is broken".
var ErrNoDiscoveryHost = errors.New("provider has no derivable discovery host")
// ErrPrivateHost marks an upstream that resolves somewhere the management
// server will not dial. A self-hosted vendor endpoint on a private network is
// a legitimate provider — the proxy reaches it through the tunnel — so this
// means the check cannot run, not that the record is wrong.
var ErrPrivateHost = errors.New("discovery host is not publicly routable")
@@ -43,7 +43,7 @@ func parseOpenAIData(body []byte) ([]listedModel, error) {
} `json:"data"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return nil, fmt.Errorf("decode model listing: %w", err)
return nil, fmt.Errorf("%w: decode model listing: %w", ErrUnparseableListing, err)
}
out := make([]listedModel, 0, len(doc.Data))
for _, entry := range doc.Data {
@@ -71,7 +71,7 @@ func parseBedrockInferenceProfiles(body []byte) ([]listedModel, error) {
} `json:"inferenceProfileSummaries"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return nil, fmt.Errorf("decode inference-profile listing: %w", err)
return nil, fmt.Errorf("%w: decode inference-profile listing: %w", ErrUnparseableListing, err)
}
out := make([]listedModel, 0, len(doc.Summaries))
for _, entry := range doc.Summaries {
@@ -98,7 +98,7 @@ func parseVertexPublisherModels(body []byte) ([]listedModel, error) {
} `json:"publisherModels"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return nil, fmt.Errorf("decode publisher-model listing: %w", err)
return nil, fmt.Errorf("%w: decode publisher-model listing: %w", ErrUnparseableListing, err)
}
out := make([]listedModel, 0, len(doc.Models))
for _, entry := range doc.Models {