mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 08:59:08 +02:00
[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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user