Files
silo-server/internal/plugins/catalog_service.go
T
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* feat(observability): OpenTelemetry logs+traces with secret redaction

Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing
stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with
no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged.

Bootstrap (internal/telemetry):
- Setup() builds one shared resource, a TracerProvider (parent-based trace-id
  ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator
  from env. It installs NO MeterProvider — metrics stay on Prometheus, and the
  built-in no-op global MeterProvider keeps the trace instrumentation libs from
  double-emitting. Shutdown is deferred with a flush timeout.
- Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the
  shared LevelVar and best-effort so a failing collector can't break the console
  or DB branches. stderr + opslog stay untouched.

Secret redaction (internal/logredact):
- A slog.Handler masks secret-keyed attributes (password, token, api_key,
  authorization, cookie, ...) — including .With-bound attrs, nested groups,
  secret-keyed group subtrees, and values behind a LogValuer — on the console
  and OTLP sinks, with a no-op fast path when a record has no secret keys.
  opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one
  marker list.

Rotation is infra-managed (no custom file sink): container runtime for stderr,
collector/backend for OTLP, opslog partition-pruning for the DB. Documented in
docs/architecture/observability.md.

Verification: go build ./..., go vet, gofmt -l — clean; go test
./internal/telemetry/ ./internal/logredact/ -race pass.

AI-use disclosure: implemented with AI assistance (Claude Code), including
adversarial reviews that hardened the bootstrap and fixed two redaction leak
paths; reviewed by the author.

* refactor(observability): slog context+component sweep, sloglint gate (phase 3)

Part of #265. Builds on the OTel bootstrap + redaction commit.

Standardizes every log call site onto the context-carrying slog variants so
records correlate with the active OpenTelemetry trace, and locks the standard
in with a machine gate so future code (human- or AI-authored) can't drift back.

- Call-site sweep: converted the remaining slog.<Level>(...) calls to the
  slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope
  (background/init calls with no ctx are left as-is), across 183 files. Applied
  via a type-aware AST codemod. Log levels and message strings are preserved
  verbatim; a component attr (canonical per-package name) is added to direct
  package-level slog calls. Bound-logger calls keep their existing .With
  bindings. The main.go and telemetry package conversions rode with their file
  in the previous commit to keep each file within a single commit.
- Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg,
  key-naming-case=snake, no-mixed-args. After the sweep all four report zero
  violations repo-wide (tests included), so make lint / CI now blocks any
  regression to the non-context form. The gate ships with the sweep because it
  cannot be green until the legacy sites are converted.

Metrics remain on Prometheus; no behavior change to /metrics or Grafana.

Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4
rules) 0 violations repo-wide; log levels verified unchanged.

AI-use disclosure: implemented with AI assistance (Claude Code), including the
codemod; reviewed by the author.

* fix(observability): honor per-signal OTLP protocol and secret WithGroup names

Two Codex review findings on PR #290:

- telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the
  generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector
  setups (e.g. HTTP logs + gRPC traces) build the right exporter.
- logredact: entering a group whose name is secret-bearing (e.g.
  WithGroup("authorization")) now masks every leaf in that subtree,
  matching how slog.Group("authorization", ...) is masked as a whole.

* fix(observability): address review feedback on telemetry bootstrap

- Telemetry setup failure no longer kills boot: Setup returns usable
  no-op providers alongside the error and main logs and continues with
  telemetry disabled, honoring the best-effort contract.
- Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_*
  variants); unsupported values fall back to parentbased_traceidratio.
- Attach node identity as semconv service.instance.id instead of the
  non-semconv node.name.
- Rename opslog retention-scope log attrs to target_component/target_level
  so they no longer collide with the canonical component routing key, and
  tag those lines with component=opslog.
- Fix stale levelGated comment casing; use WarnContext in the telemetry
  shutdown defer; document the LogValuer double-resolve on the redaction
  slow path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:53:52 -04:00

519 lines
14 KiB
Go

package plugins
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path"
"runtime"
"slices"
"strings"
"time"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
)
type PlatformBinary struct {
URL string `json:"url"`
Checksum string `json:"checksum"`
}
type CatalogPackage struct {
Manifest *pluginv1.PluginManifest `json:"manifest"`
ArchiveURL string `json:"archive_url,omitempty"`
ChecksumsURL string `json:"checksums_url,omitempty"`
Binaries map[string]PlatformBinary `json:"binaries,omitempty"`
}
type RepositoryIndex struct {
Plugins []CatalogPackage `json:"plugins"`
}
type CatalogEntry struct {
RepositoryID int
Manifest *pluginv1.PluginManifest
ArchiveURL string
Checksum string
}
type InstallCatalogRequest struct {
RepositoryID int
PluginID string
Version string
}
type ResolvedCatalogInstall struct {
RepositoryID int
ArchiveURL string
Checksum string
LegacyArchive bool
}
type CatalogServiceOptions struct {
HTTPClient *http.Client
SiloAPIVersion string
CurrentOS string
CurrentArch string
}
type CatalogService struct {
repositories *RepositoryStore
httpClient *http.Client
siloAPIVersion string
currentOS string
currentArch string
}
func NewCatalogService(repositories *RepositoryStore, opts CatalogServiceOptions) *CatalogService {
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
apiVersion := opts.SiloAPIVersion
if apiVersion == "" {
apiVersion = DefaultSiloAPIVersion
}
currentOS := opts.CurrentOS
if currentOS == "" {
currentOS = runtime.GOOS
}
currentArch := opts.CurrentArch
if currentArch == "" {
currentArch = runtime.GOARCH
}
return &CatalogService{
repositories: repositories,
httpClient: httpClient,
siloAPIVersion: apiVersion,
currentOS: currentOS,
currentArch: currentArch,
}
}
func (s *CatalogService) Fetch(ctx context.Context) ([]CatalogEntry, error) {
repositories, err := s.repositories.List(ctx)
if err != nil {
return nil, err
}
catalogByVersion := make(map[string]CatalogEntry)
for _, repository := range repositories {
if !repository.Enabled {
continue
}
index, err := s.fetchRepositoryIndex(ctx, repository.URL)
if err != nil {
slog.WarnContext(ctx, "skipping broken plugin repository", "component", "plugins",
"repository_id", repository.ID,
"repository_url", repository.URL,
"error", err,
)
continue
}
now := time.Now().UTC()
if err := s.repositories.Update(ctx, repository.ID, UpdateRepositoryInput{LastFetchedAt: &now}); err != nil {
return nil, err
}
for _, pkg := range index.Plugins {
entry, ok, err := s.catalogEntryFromPackage(repository, pkg)
if err != nil {
slog.WarnContext(ctx, "skipping invalid plugin catalog entry", "component", "plugins",
"repository_id", repository.ID,
"repository_url", repository.URL,
"plugin_id", manifestPluginID(pkg.Manifest),
"version", manifestVersion(pkg.Manifest),
"error", err,
)
continue
}
if !ok {
continue
}
key := capabilityKey(entry.Manifest.GetPluginId(), entry.Manifest.GetVersion())
if _, exists := catalogByVersion[key]; exists {
continue
}
catalogByVersion[key] = entry
}
}
entries := make([]CatalogEntry, 0, len(catalogByVersion))
for _, entry := range catalogByVersion {
entries = append(entries, entry)
}
slices.SortFunc(entries, func(left, right CatalogEntry) int {
if left.Manifest.GetPluginId() == right.Manifest.GetPluginId() {
switch {
case left.Manifest.GetVersion() < right.Manifest.GetVersion():
return -1
case left.Manifest.GetVersion() > right.Manifest.GetVersion():
return 1
default:
return 0
}
}
switch {
case left.Manifest.GetPluginId() < right.Manifest.GetPluginId():
return -1
case left.Manifest.GetPluginId() > right.Manifest.GetPluginId():
return 1
default:
return 0
}
})
return entries, nil
}
func (s *CatalogService) ResolveInstall(ctx context.Context, req InstallCatalogRequest) (*ResolvedCatalogInstall, error) {
if req.RepositoryID == 0 {
return nil, fmt.Errorf("repository_id is required")
}
if strings.TrimSpace(req.PluginID) == "" {
return nil, fmt.Errorf("plugin_id is required")
}
if strings.TrimSpace(req.Version) == "" {
return nil, fmt.Errorf("version is required")
}
repository, err := s.repositories.GetByID(ctx, req.RepositoryID)
if err != nil {
return nil, err
}
if !repository.Enabled {
return nil, fmt.Errorf("plugin repository %d is disabled", repository.ID)
}
index, err := s.fetchRepositoryIndex(ctx, repository.URL)
if err != nil {
return nil, err
}
now := time.Now().UTC()
if err := s.repositories.Update(ctx, repository.ID, UpdateRepositoryInput{LastFetchedAt: &now}); err != nil {
return nil, err
}
for _, pkg := range index.Plugins {
if pkg.Manifest == nil {
continue
}
if pkg.Manifest.GetPluginId() != req.PluginID || pkg.Manifest.GetVersion() != req.Version {
continue
}
target, err := s.installTargetFromPackage(ctx, repository, pkg)
if err != nil {
return nil, err
}
return target, nil
}
return nil, fmt.Errorf("plugin %s@%s not found in repository %d", req.PluginID, req.Version, req.RepositoryID)
}
func (s *CatalogService) fetchRepositoryIndex(ctx context.Context, repositoryURL string) (*RepositoryIndex, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, repositoryURL, nil)
if err != nil {
return nil, fmt.Errorf("build repository request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch repository index %q: %w", repositoryURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch repository index %q: unexpected status %d", repositoryURL, resp.StatusCode)
}
var index RepositoryIndex
if err := json.NewDecoder(resp.Body).Decode(&index); err != nil {
return nil, fmt.Errorf("decode repository index %q: %w", repositoryURL, err)
}
return &index, nil
}
func supportsPlatform(manifest *pluginv1.PluginManifest, osName, arch string) bool {
for _, platform := range manifest.GetSupportedPlatforms() {
if platform.GetOs() == osName && platform.GetArch() == arch {
return true
}
}
return false
}
func resolveRepositoryURL(repositoryURL, resourceURL string) (string, error) {
if resourceURL == "" {
return "", fmt.Errorf("plugin resource url is required")
}
baseURL, err := url.Parse(repositoryURL)
if err != nil {
return "", fmt.Errorf("parse repository url %q: %w", repositoryURL, err)
}
relativeURL, err := url.Parse(resourceURL)
if err != nil {
return "", fmt.Errorf("parse resource url %q: %w", resourceURL, err)
}
return baseURL.ResolveReference(relativeURL).String(), nil
}
func (s *CatalogService) catalogEntryFromPackage(repository *Repository, pkg CatalogPackage) (CatalogEntry, bool, error) {
if len(pkg.Binaries) > 0 {
if err := ValidateCatalogManifest(pkg.Manifest); err != nil {
return CatalogEntry{}, false, err
}
if pkg.Manifest.GetSiloApiVersion() != s.siloAPIVersion {
return CatalogEntry{}, false, nil
}
platformKey := s.currentOS + "/" + s.currentArch
binary, ok := pkg.Binaries[platformKey]
if !ok {
return CatalogEntry{}, false, nil
}
if strings.TrimSpace(binary.URL) == "" {
return CatalogEntry{}, false, fmt.Errorf("plugin binary url is required for platform %s", platformKey)
}
if strings.TrimSpace(binary.Checksum) == "" && strings.TrimSpace(pkg.ChecksumsURL) == "" {
return CatalogEntry{}, false, fmt.Errorf("plugin binary checksum is required for platform %s", platformKey)
}
resolvedURL, err := resolveRepositoryURL(repository.URL, binary.URL)
if err != nil {
return CatalogEntry{}, false, err
}
checksum := strings.TrimSpace(binary.Checksum)
if checksum != "" {
checksum, err = normalizeSHA256Checksum(checksum)
if err != nil {
return CatalogEntry{}, false, err
}
}
if strings.TrimSpace(pkg.ChecksumsURL) != "" {
if _, err := resolveRepositoryURL(repository.URL, pkg.ChecksumsURL); err != nil {
return CatalogEntry{}, false, err
}
}
return CatalogEntry{
RepositoryID: repository.ID,
Manifest: pkg.Manifest,
ArchiveURL: resolvedURL,
Checksum: checksum,
}, true, nil
}
if err := ValidateManifest(pkg.Manifest); err != nil {
return CatalogEntry{}, false, err
}
if pkg.Manifest.GetSiloApiVersion() != s.siloAPIVersion {
return CatalogEntry{}, false, nil
}
if !supportsPlatform(pkg.Manifest, s.currentOS, s.currentArch) {
return CatalogEntry{}, false, nil
}
resolvedURL, err := resolveRepositoryURL(repository.URL, pkg.ArchiveURL)
if err != nil {
return CatalogEntry{}, false, err
}
return CatalogEntry{
RepositoryID: repository.ID,
Manifest: pkg.Manifest,
ArchiveURL: resolvedURL,
}, true, nil
}
func (s *CatalogService) installTargetFromPackage(ctx context.Context, repository *Repository, pkg CatalogPackage) (*ResolvedCatalogInstall, error) {
if len(pkg.Binaries) > 0 {
if err := ValidateCatalogManifest(pkg.Manifest); err != nil {
return nil, err
}
if pkg.Manifest.GetSiloApiVersion() != s.siloAPIVersion {
return nil, fmt.Errorf("plugin silo_api_version %q is not supported", pkg.Manifest.GetSiloApiVersion())
}
platformKey := s.currentOS + "/" + s.currentArch
binary, ok := pkg.Binaries[platformKey]
if !ok {
return nil, fmt.Errorf("plugin %s@%s does not support platform %s", pkg.Manifest.GetPluginId(), pkg.Manifest.GetVersion(), platformKey)
}
if strings.TrimSpace(binary.URL) == "" {
return nil, fmt.Errorf("plugin binary url is required for platform %s", platformKey)
}
resolvedURL, err := resolveRepositoryURL(repository.URL, binary.URL)
if err != nil {
return nil, err
}
checksum := strings.TrimSpace(binary.Checksum)
if checksum != "" {
checksum, err = normalizeSHA256Checksum(checksum)
if err != nil {
return nil, err
}
} else {
if strings.TrimSpace(pkg.ChecksumsURL) == "" {
return nil, fmt.Errorf("plugin binary checksum is required for platform %s", platformKey)
}
resolvedChecksumsURL, err := resolveRepositoryURL(repository.URL, pkg.ChecksumsURL)
if err != nil {
return nil, err
}
checksum, err = s.fetchChecksumForBinary(ctx, resolvedChecksumsURL, resolvedURL)
if err != nil {
return nil, err
}
}
return &ResolvedCatalogInstall{
RepositoryID: repository.ID,
ArchiveURL: resolvedURL,
Checksum: checksum,
LegacyArchive: false,
}, nil
}
if err := ValidateManifest(pkg.Manifest); err != nil {
return nil, err
}
if pkg.Manifest.GetSiloApiVersion() != s.siloAPIVersion {
return nil, fmt.Errorf("plugin silo_api_version %q is not supported", pkg.Manifest.GetSiloApiVersion())
}
if !supportsPlatform(pkg.Manifest, s.currentOS, s.currentArch) {
return nil, fmt.Errorf("plugin %s@%s does not support platform %s/%s", pkg.Manifest.GetPluginId(), pkg.Manifest.GetVersion(), s.currentOS, s.currentArch)
}
resolvedURL, err := resolveRepositoryURL(repository.URL, pkg.ArchiveURL)
if err != nil {
return nil, err
}
return &ResolvedCatalogInstall{
RepositoryID: repository.ID,
ArchiveURL: resolvedURL,
LegacyArchive: true,
}, nil
}
func (s *CatalogService) fetchChecksumForBinary(ctx context.Context, checksumsURL, binaryURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumsURL, nil)
if err != nil {
return "", fmt.Errorf("build checksums request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetch checksums file %q: %w", checksumsURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("fetch checksums file %q: unexpected status %d", checksumsURL, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read checksums file %q: %w", checksumsURL, err)
}
checksum, err := checksumForBinary(string(data), binaryURL)
if err != nil {
return "", err
}
return checksum, nil
}
func checksumForBinary(contents string, binaryURL string) (string, error) {
parsedURL, err := url.Parse(binaryURL)
if err != nil {
return "", fmt.Errorf("parse binary url %q: %w", binaryURL, err)
}
filename := path.Base(parsedURL.Path)
if filename == "." || filename == "/" || filename == "" {
return "", fmt.Errorf("resolve checksum target from binary url %q", binaryURL)
}
for _, line := range strings.Split(contents, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
checksum, entryFilename, ok, err := parseChecksumLine(line)
if err != nil {
return "", err
}
if !ok {
continue
}
if path.Base(entryFilename) == filename {
return checksum, nil
}
}
return "", fmt.Errorf("binary checksum for %q not found", filename)
}
func parseChecksumLine(line string) (checksum string, filename string, ok bool, err error) {
line = strings.TrimSpace(line)
if line == "" {
return "", "", false, nil
}
if len(line) < 65 {
return "", "", false, fmt.Errorf("invalid checksum line %q", line)
}
checksum = strings.TrimSpace(line[:64])
checksum, err = normalizeSHA256Checksum(checksum)
if err != nil {
return "", "", false, err
}
rest := strings.TrimLeft(line[64:], " \t")
rest = strings.TrimPrefix(rest, "*")
rest = strings.TrimSpace(rest)
if rest == "" {
return "", "", false, fmt.Errorf("invalid checksum line %q", line)
}
return checksum, rest, true, nil
}
func normalizeSHA256Checksum(checksum string) (string, error) {
checksum = strings.ToLower(strings.TrimSpace(checksum))
if len(checksum) != 64 {
return "", fmt.Errorf("invalid sha256 checksum %q", checksum)
}
if _, err := hex.DecodeString(checksum); err != nil {
return "", fmt.Errorf("invalid sha256 checksum %q", checksum)
}
return checksum, nil
}
func manifestPluginID(manifest *pluginv1.PluginManifest) string {
if manifest == nil {
return ""
}
return manifest.GetPluginId()
}
func manifestVersion(manifest *pluginv1.PluginManifest) string {
if manifest == nil {
return ""
}
return manifest.GetVersion()
}