iop/packages/go/agentprovider/catalog/discovery.go

241 lines
6.5 KiB
Go

package catalog
import (
"context"
"fmt"
"os/exec"
"regexp"
"sort"
"strings"
"time"
"iop/packages/go/agentconfig"
)
// RunResult is the bounded output of one discovery probe.
type RunResult struct {
Output string
}
// Runner provides the small process seam used by discovery.
type Runner interface {
LookPath(command string) (string, error)
Run(ctx context.Context, command string, args []string) (RunResult, error)
}
type osRunner struct{}
func (osRunner) LookPath(command string) (string, error) {
return exec.LookPath(command)
}
func (osRunner) Run(ctx context.Context, command string, args []string) (RunResult, error) {
output, err := exec.CommandContext(ctx, command, args...).CombinedOutput()
return RunResult{Output: string(output)}, err
}
// Discoverer validates a catalog and resolves its profiles against the host.
type Discoverer struct {
catalog agentconfig.Catalog
runner Runner
}
// NewDiscoverer constructs a host discoverer. A nil Runner uses os/exec.
func NewDiscoverer(cfg agentconfig.Catalog, runner Runner) (*Discoverer, error) {
normalized, err := agentconfig.Normalize(cfg)
if err != nil {
return nil, err
}
if runner == nil {
runner = osRunner{}
}
return &Discoverer{catalog: normalized, runner: runner}, nil
}
// Discover returns all profile states in stable profile-ID order.
func (d *Discoverer) Discover(ctx context.Context) []Readiness {
results := make([]Readiness, 0, len(d.catalog.Profiles))
for _, profile := range d.catalog.Profiles {
results = append(results, d.discoverResolved(ctx, mustResolve(d.catalog, profile.ID)))
}
sort.Slice(results, func(i, j int) bool {
return results[i].ProfileID < results[j].ProfileID
})
return results
}
// DiscoverProfile resolves one official profile.
func (d *Discoverer) DiscoverProfile(ctx context.Context, profileID string) (Readiness, error) {
resolved, ok := d.catalog.ResolveProfile(profileID)
if !ok {
return Readiness{}, fmt.Errorf("agent provider catalog: unknown profile %q", profileID)
}
result := d.discoverResolved(ctx, resolved)
if result.Error != nil {
return result, result.Error
}
return result, nil
}
func (d *Discoverer) discoverResolved(ctx context.Context, resolved agentconfig.ResolvedProfile) Readiness {
provider := resolved.Provider
model := resolved.Model
profile := resolved.Profile
base := Readiness{
ProviderID: provider.ID,
ModelID: model.ID,
ProfileID: profile.ID,
Command: provider.Command,
Capabilities: append([]string(nil), profile.Capabilities...),
}
command, err := d.runner.LookPath(provider.Command)
if err != nil {
failure := readinessFailure(
StateMissingBinary,
provider.ID, model.ID, profile.ID,
fmt.Sprintf("provider %q binary %q was not found on PATH", provider.ID, provider.Command),
ErrBinaryMissing,
)
failure.Command = provider.Command
failure.Capabilities = base.Capabilities
return failure
}
version, err := d.runProbe(ctx, command, provider.VersionProbe.Args, provider.VersionProbe.TimeoutMS)
if err != nil {
return d.probeFailure(base, "version", version, err)
}
base.Version = firstNonEmptyLine(version.Output)
auth, authErr := d.runProbe(
ctx, command, provider.Authentication.Args, provider.Authentication.TimeoutMS,
)
authText := diagnostic(auth.Output)
unauthenticated := matches(provider.Authentication.UnauthenticatedPattern, authText)
if unauthenticated {
failure := readinessFailure(
StateUnauthenticated,
provider.ID, model.ID, profile.ID,
fmt.Sprintf("provider %q requires authentication", provider.ID),
ErrAuthenticationRequired,
)
failure.Command = provider.Command
failure.Version = base.Version
failure.Capabilities = base.Capabilities
return failure
}
if authErr != nil {
return d.probeFailure(base, "authentication", auth, authErr)
}
if provider.Authentication.SuccessPattern != "" &&
!matches(provider.Authentication.SuccessPattern, authText) {
return d.probeFailure(
base,
"authentication",
auth,
fmt.Errorf("success pattern did not match"),
)
}
if len(provider.ModelProbe.Args) > 0 {
models, modelErr := d.runProbe(
ctx, command, provider.ModelProbe.Args, provider.ModelProbe.TimeoutMS,
)
if modelErr != nil {
return d.probeFailure(base, "model", models, modelErr)
}
if !modelOutputContains(models.Output, model.Target) {
failure := readinessFailure(
StateUnsupportedModel,
provider.ID, model.ID, profile.ID,
fmt.Sprintf("provider %q does not report model target %q", provider.ID, model.Target),
ErrModelUnsupported,
)
failure.Command = provider.Command
failure.Version = base.Version
failure.Capabilities = base.Capabilities
return failure
}
}
base.State = StateReady
base.Detail = "installed and authenticated"
return base
}
func (d *Discoverer) runProbe(
parent context.Context,
command string,
args []string,
timeoutMS int,
) (RunResult, error) {
if timeoutMS == 0 {
timeoutMS = agentconfig.DefaultProbeTimeoutMS
}
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutMS)*time.Millisecond)
defer cancel()
result, err := d.runner.Run(ctx, command, args)
if ctx.Err() != nil {
return result, ctx.Err()
}
return result, err
}
func (d *Discoverer) probeFailure(
base Readiness,
probe string,
result RunResult,
err error,
) Readiness {
detail := fmt.Sprintf("%s probe failed", probe)
if output := diagnostic(result.Output); output != "" {
detail += ": " + output
} else if err != nil {
detail += ": " + diagnostic(err.Error())
}
failure := readinessFailure(
StateProbeError,
base.ProviderID, base.ModelID, base.ProfileID,
detail,
fmt.Errorf("%w: %v", ErrProbeFailed, err),
)
failure.Command = base.Command
failure.Version = base.Version
failure.Capabilities = base.Capabilities
return failure
}
func matches(expression, value string) bool {
if expression == "" {
return false
}
compiled := regexp.MustCompile(expression)
return compiled.MatchString(value)
}
func firstNonEmptyLine(output string) string {
for _, line := range strings.Split(diagnostic(output), "\n") {
if trimmed := strings.TrimSpace(line); trimmed != "" {
return trimmed
}
}
return ""
}
func modelOutputContains(output, target string) bool {
for _, line := range strings.Split(Redact(output), "\n") {
if strings.TrimSpace(line) == target {
return true
}
}
return false
}
func mustResolve(cfg agentconfig.Catalog, profileID string) agentconfig.ResolvedProfile {
resolved, ok := cfg.ResolveProfile(profileID)
if !ok {
panic("validated catalog lost profile " + profileID)
}
return resolved
}