mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
603 lines
17 KiB
Go
603 lines
17 KiB
Go
package ai
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
quickstartBootstrapUseCase = "patrol"
|
|
quickstartBootstrapRefreshWindow = 5 * time.Minute
|
|
quickstartProviderUnavailableText = "Quickstart credits require internet access. Connect your API key for offline AI Patrol."
|
|
)
|
|
|
|
// QuickstartCreditManager owns the persisted quickstart bootstrap state used by
|
|
// Patrol when no BYOK provider is configured. Cached local state is never
|
|
// authoritative for entitlement; it only memoizes the last server snapshot.
|
|
type QuickstartCreditManager interface {
|
|
EnsureBootstrap(ctx context.Context) error
|
|
HasCredits() bool
|
|
CreditsRemaining() int
|
|
CreditsTotal() int
|
|
HasBYOK() bool
|
|
GetProvider() providers.Provider
|
|
}
|
|
|
|
type quickstartBootstrapClient interface {
|
|
BootstrapQuickstart(ctx context.Context, bearerToken string, req pkglicensing.QuickstartBootstrapRequest) (*pkglicensing.QuickstartBootstrapResponse, error)
|
|
}
|
|
|
|
// PersistentQuickstartCreditManager persists the server-issued quickstart token
|
|
// and the latest server-reported inventory in quickstart.enc.
|
|
type PersistentQuickstartCreditManager struct {
|
|
mu sync.RWMutex
|
|
orgID string
|
|
persistence *config.ConfigPersistence
|
|
aiConfig func() *config.AIConfig
|
|
client quickstartBootstrapClient
|
|
now func() time.Time
|
|
hostname func() (string, error)
|
|
state *config.QuickstartState
|
|
}
|
|
|
|
// NewPersistentQuickstartCreditManager creates a server-authoritative
|
|
// quickstart manager backed by quickstart.enc.
|
|
func NewPersistentQuickstartCreditManager(
|
|
persistence *config.ConfigPersistence,
|
|
orgID string,
|
|
aiConfigGetter func() *config.AIConfig,
|
|
) *PersistentQuickstartCreditManager {
|
|
return NewPersistentQuickstartCreditManagerWithClient(
|
|
persistence,
|
|
orgID,
|
|
aiConfigGetter,
|
|
pkglicensing.NewLicenseServerClient(""),
|
|
)
|
|
}
|
|
|
|
// NewPersistentQuickstartCreditManagerWithClient exists for unit tests.
|
|
func NewPersistentQuickstartCreditManagerWithClient(
|
|
persistence *config.ConfigPersistence,
|
|
orgID string,
|
|
aiConfigGetter func() *config.AIConfig,
|
|
client quickstartBootstrapClient,
|
|
) *PersistentQuickstartCreditManager {
|
|
if aiConfigGetter == nil {
|
|
aiConfigGetter = func() *config.AIConfig { return nil }
|
|
}
|
|
if client == nil {
|
|
client = pkglicensing.NewLicenseServerClient("")
|
|
}
|
|
return &PersistentQuickstartCreditManager{
|
|
orgID: strings.TrimSpace(orgID),
|
|
persistence: persistence,
|
|
aiConfig: aiConfigGetter,
|
|
client: client,
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
hostname: os.Hostname,
|
|
}
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) EnsureBootstrap(ctx context.Context) error {
|
|
if m == nil {
|
|
return fmt.Errorf("quickstart: manager unavailable")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
state, err := m.loadStateLocked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bearerToken, fingerprint, err := m.secureBootstrapIdentityLocked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !m.bootstrapNeededLocked(state, m.now()) {
|
|
return nil
|
|
}
|
|
|
|
req, err := m.bootstrapRequestLocked(fingerprint)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := m.client.BootstrapQuickstart(ctx, bearerToken, req)
|
|
if err != nil {
|
|
if quickstartBootstrapCreditsExhausted(err) {
|
|
state.QuickstartCreditsRemaining = 0
|
|
nowUnix := m.now().Unix()
|
|
state.LastSyncedAt = &nowUnix
|
|
if persistErr := m.persistence.SaveQuickstartState(*config.NormalizeQuickstartState(state)); persistErr != nil {
|
|
log.Warn().Err(persistErr).Str("orgID", m.orgID).Msg("Quickstart: failed to persist exhausted bootstrap state")
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
m.applyBootstrapLocked(state, resp)
|
|
return m.persistence.SaveQuickstartState(*config.NormalizeQuickstartState(state))
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) HasCredits() bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
state, err := m.loadStateLocked()
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state")
|
|
return false
|
|
}
|
|
if state == nil || state.QuickstartCreditsRemaining <= 0 {
|
|
return false
|
|
}
|
|
_, _, err = m.secureBootstrapIdentityLocked()
|
|
return err == nil
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) CreditsRemaining() int {
|
|
if m == nil {
|
|
return 0
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
state, err := m.loadStateLocked()
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state")
|
|
return 0
|
|
}
|
|
if state == nil {
|
|
return 0
|
|
}
|
|
if _, _, err := m.secureBootstrapIdentityLocked(); err != nil {
|
|
return 0
|
|
}
|
|
return state.QuickstartCreditsRemaining
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) CreditsTotal() int {
|
|
if m == nil {
|
|
return 0
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
state, err := m.loadStateLocked()
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state")
|
|
return 0
|
|
}
|
|
if state == nil {
|
|
return 0
|
|
}
|
|
if _, _, err := m.secureBootstrapIdentityLocked(); err != nil {
|
|
return 0
|
|
}
|
|
return state.QuickstartCreditsTotal
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) HasBYOK() bool {
|
|
if m == nil || m.aiConfig == nil {
|
|
return false
|
|
}
|
|
cfg := m.aiConfig()
|
|
if cfg == nil {
|
|
return false
|
|
}
|
|
return len(cfg.GetConfiguredProviders()) > 0
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) GetProvider() providers.Provider {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
state, err := m.loadStateLocked()
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to load cached quickstart state")
|
|
return nil
|
|
}
|
|
if state == nil || state.QuickstartCreditsRemaining <= 0 {
|
|
return nil
|
|
}
|
|
if _, _, err := m.secureBootstrapIdentityLocked(); err != nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(state.QuickstartToken) == "" || state.TokenExpired(m.now()) {
|
|
return nil
|
|
}
|
|
|
|
token := state.QuickstartToken
|
|
return providers.NewQuickstartClientWithToken(
|
|
token,
|
|
m.syncServerState,
|
|
m.invalidateToken,
|
|
)
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) stateSnapshot() *config.QuickstartState {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
state, err := m.loadStateLocked()
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state")
|
|
return &config.QuickstartState{}
|
|
}
|
|
return config.NormalizeQuickstartState(state)
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) loadStateLocked() (*config.QuickstartState, error) {
|
|
if m.state != nil {
|
|
return m.state, nil
|
|
}
|
|
if m.persistence == nil {
|
|
m.state = &config.QuickstartState{}
|
|
return m.state, fmt.Errorf("quickstart: persistence unavailable")
|
|
}
|
|
state, err := m.persistence.LoadQuickstartState()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("quickstart: load quickstart state: %w", err)
|
|
}
|
|
m.state = config.NormalizeQuickstartState(state)
|
|
return m.state, nil
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) bootstrapNeededLocked(state *config.QuickstartState, now time.Time) bool {
|
|
if state == nil {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(state.QuickstartToken) == "" {
|
|
return true
|
|
}
|
|
if state.TokenExpired(now) {
|
|
return true
|
|
}
|
|
if state.LastSyncedAt == nil || *state.LastSyncedAt <= 0 {
|
|
return true
|
|
}
|
|
return now.Unix()-*state.LastSyncedAt >= int64(quickstartBootstrapRefreshWindow/time.Second)
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) bootstrapRequestLocked(fingerprint string) (pkglicensing.QuickstartBootstrapRequest, error) {
|
|
instanceName := ""
|
|
if hostname, err := m.hostname(); err == nil {
|
|
instanceName = strings.TrimSpace(hostname)
|
|
}
|
|
|
|
return pkglicensing.QuickstartBootstrapRequest{
|
|
InstanceFingerprint: strings.TrimSpace(fingerprint),
|
|
InstanceName: instanceName,
|
|
UseCase: quickstartBootstrapUseCase,
|
|
}, nil
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) applyBootstrapLocked(state *config.QuickstartState, resp *pkglicensing.QuickstartBootstrapResponse) {
|
|
if state == nil || resp == nil {
|
|
return
|
|
}
|
|
|
|
state.QuickstartToken = strings.TrimSpace(resp.QuickstartToken)
|
|
state.QuickstartCreditsRemaining = max(0, resp.CreditsRemaining)
|
|
state.QuickstartCreditsTotal = max(0, resp.CreditsTotal)
|
|
if expiry := parseQuickstartRFC3339(resp.QuickstartTokenExpiresAt); expiry != nil {
|
|
state.QuickstartTokenExpiresAt = expiry
|
|
} else {
|
|
state.QuickstartTokenExpiresAt = nil
|
|
}
|
|
nowUnix := m.now().Unix()
|
|
state.LastSyncedAt = &nowUnix
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) syncServerState(serverState providers.QuickstartServerState) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
state, err := m.loadStateLocked()
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to load cached state for sync")
|
|
return
|
|
}
|
|
|
|
updated := false
|
|
if serverState.CreditsRemaining != nil {
|
|
state.QuickstartCreditsRemaining = max(0, *serverState.CreditsRemaining)
|
|
updated = true
|
|
}
|
|
if serverState.CreditsTotal != nil {
|
|
state.QuickstartCreditsTotal = max(0, *serverState.CreditsTotal)
|
|
updated = true
|
|
}
|
|
if serverState.TokenExpiresAt != nil {
|
|
expiryUnix := serverState.TokenExpiresAt.UTC().Unix()
|
|
state.QuickstartTokenExpiresAt = &expiryUnix
|
|
updated = true
|
|
}
|
|
if !updated {
|
|
return
|
|
}
|
|
nowUnix := m.now().Unix()
|
|
state.LastSyncedAt = &nowUnix
|
|
|
|
if m.persistence == nil {
|
|
return
|
|
}
|
|
if err := m.persistence.SaveQuickstartState(*config.NormalizeQuickstartState(state)); err != nil {
|
|
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to persist synced server state")
|
|
}
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) invalidateToken() {
|
|
if m == nil {
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
state, err := m.loadStateLocked()
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to load cached state for token invalidation")
|
|
return
|
|
}
|
|
|
|
state.QuickstartToken = ""
|
|
state.QuickstartTokenExpiresAt = nil
|
|
nowUnix := m.now().Unix()
|
|
state.LastSyncedAt = &nowUnix
|
|
|
|
if m.persistence == nil {
|
|
return
|
|
}
|
|
if err := m.persistence.SaveQuickstartState(*config.NormalizeQuickstartState(state)); err != nil {
|
|
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to persist invalidated token state")
|
|
}
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) secureBootstrapIdentityLocked() (string, string, error) {
|
|
if m.persistence == nil {
|
|
return "", "", fmt.Errorf("quickstart: persistence unavailable")
|
|
}
|
|
|
|
if bearerToken, fingerprint, ok, err := m.installationBootstrapIdentityLocked(); err != nil {
|
|
return "", "", err
|
|
} else if ok {
|
|
return bearerToken, fingerprint, nil
|
|
}
|
|
|
|
return m.entitlementBootstrapIdentityLocked()
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) installationBootstrapIdentityLocked() (string, string, bool, error) {
|
|
if m == nil || m.persistence == nil {
|
|
return "", "", false, fmt.Errorf("quickstart: persistence unavailable")
|
|
}
|
|
|
|
licensePersistence, err := pkglicensing.NewPersistence(m.persistence.SharedInstallationDataDir())
|
|
if err != nil {
|
|
return "", "", false, fmt.Errorf("quickstart: load license persistence: %w", err)
|
|
}
|
|
activationState, err := licensePersistence.LoadActivationState()
|
|
if err != nil {
|
|
return "", "", false, fmt.Errorf("quickstart: load activation state: %w", err)
|
|
}
|
|
if activationState == nil {
|
|
return "", "", false, nil
|
|
}
|
|
|
|
installationToken := strings.TrimSpace(activationState.InstallationToken)
|
|
instanceFingerprint := strings.TrimSpace(activationState.InstanceFingerprint)
|
|
if installationToken == "" || instanceFingerprint == "" {
|
|
return "", "", false, nil
|
|
}
|
|
|
|
return installationToken, instanceFingerprint, true, nil
|
|
}
|
|
|
|
func (m *PersistentQuickstartCreditManager) entitlementBootstrapIdentityLocked() (string, string, error) {
|
|
if m == nil || m.persistence == nil {
|
|
return "", "", fmt.Errorf("quickstart: persistence unavailable")
|
|
}
|
|
|
|
state, _, err := config.LoadEffectiveEntitlementBillingState(m.persistence.SharedInstallationDataDir(), m.orgID)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("quickstart: load entitlement billing state: %w", err)
|
|
}
|
|
|
|
entitlementJWT := ""
|
|
if state != nil {
|
|
entitlementJWT = strings.TrimSpace(state.EntitlementJWT)
|
|
}
|
|
if entitlementJWT == "" {
|
|
return "", "", quickstartActivationRequiredError()
|
|
}
|
|
|
|
publicKey, err := pkglicensing.TrialActivationPublicKey()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("quickstart: load entitlement verification key: %w", err)
|
|
}
|
|
|
|
claims, err := pkglicensing.VerifyEntitlementLeaseToken(entitlementJWT, publicKey, "", m.now())
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("quickstart: verify entitlement lease: %w", err)
|
|
}
|
|
if !quickstartEntitlementSupportsPatrol(claims) {
|
|
return "", "", quickstartActivationRequiredError()
|
|
}
|
|
|
|
return entitlementJWT, "", nil
|
|
}
|
|
|
|
func quickstartEntitlementSupportsPatrol(claims *pkglicensing.EntitlementLeaseClaims) bool {
|
|
if claims == nil {
|
|
return false
|
|
}
|
|
switch claims.SubscriptionState {
|
|
case pkglicensing.SubStateActive, pkglicensing.SubStateGrace, pkglicensing.SubStateTrial:
|
|
default:
|
|
return false
|
|
}
|
|
for _, capability := range claims.Capabilities {
|
|
switch strings.TrimSpace(capability) {
|
|
case pkglicensing.FeatureAIPatrol, pkglicensing.FeatureAIAutoFix:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func quickstartBlockedReasonFromError(err error) string {
|
|
switch {
|
|
case err == nil:
|
|
return ""
|
|
case providers.IsQuickstartCreditsExhausted(err), quickstartBootstrapCreditsExhausted(err):
|
|
return patrolQuickstartCreditsExhaustedReason
|
|
case quickstartBootstrapActivationRequired(err):
|
|
return patrolQuickstartActivationRequiredReason
|
|
case providers.IsQuickstartUnavailable(err), quickstartBootstrapUnavailable(err):
|
|
return patrolQuickstartUnavailableReason
|
|
default:
|
|
return quickstartProviderUnavailableText
|
|
}
|
|
}
|
|
|
|
// QuickstartBlockedReasonForError exposes quickstart block classification to
|
|
// adjacent runtime/API layers without duplicating the mapping logic.
|
|
func QuickstartBlockedReasonForError(err error) string {
|
|
return quickstartBlockedReasonFromError(err)
|
|
}
|
|
|
|
// QuickstartCreditsExhaustedReason returns the canonical Patrol quickstart
|
|
// availability message shown when the install has consumed its quickstart runs.
|
|
func QuickstartCreditsExhaustedReason() string {
|
|
return patrolQuickstartCreditsExhaustedReason
|
|
}
|
|
|
|
// QuickstartUnavailableReason returns the canonical Patrol quickstart
|
|
// availability message shown when the server-authoritative bootstrap path
|
|
// cannot be reached.
|
|
func QuickstartUnavailableReason() string {
|
|
return patrolQuickstartUnavailableReason
|
|
}
|
|
|
|
// QuickstartActivationRequiredReason returns the canonical Patrol quickstart
|
|
// availability message shown when the install is not activated or trial-backed.
|
|
func QuickstartActivationRequiredReason() string {
|
|
return patrolQuickstartActivationRequiredReason
|
|
}
|
|
|
|
func quickstartActivationRequiredError() error {
|
|
return &pkglicensing.LicenseServerError{
|
|
StatusCode: http.StatusUnauthorized,
|
|
Code: "activation_required",
|
|
Message: "Quickstart bootstrap requires an activated or trial-backed installation",
|
|
Retryable: false,
|
|
}
|
|
}
|
|
|
|
func quickstartBootstrapCreditsExhausted(err error) bool {
|
|
var serverErr *pkglicensing.LicenseServerError
|
|
if !errors.As(err, &serverErr) {
|
|
return false
|
|
}
|
|
if serverErr.StatusCode == http.StatusPaymentRequired {
|
|
return true
|
|
}
|
|
return strings.EqualFold(strings.TrimSpace(serverErr.Code), "quickstart_credits_exhausted")
|
|
}
|
|
|
|
func quickstartBootstrapActivationRequired(err error) bool {
|
|
var serverErr *pkglicensing.LicenseServerError
|
|
if !errors.As(err, &serverErr) {
|
|
return false
|
|
}
|
|
switch serverErr.StatusCode {
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
return true
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(serverErr.Code)) {
|
|
case "activation_required", "installation_required", "invalid_installation_token", "invalid_token":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func quickstartBootstrapUnavailable(err error) bool {
|
|
var serverErr *pkglicensing.LicenseServerError
|
|
if errors.As(err, &serverErr) {
|
|
switch serverErr.StatusCode {
|
|
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
|
return true
|
|
}
|
|
return serverErr.Retryable
|
|
}
|
|
|
|
lower := strings.ToLower(strings.TrimSpace(err.Error()))
|
|
switch {
|
|
case strings.Contains(lower, "connection refused"),
|
|
strings.Contains(lower, "no such host"),
|
|
strings.Contains(lower, "dial tcp"),
|
|
strings.Contains(lower, "i/o timeout"),
|
|
strings.Contains(lower, "context deadline exceeded"),
|
|
strings.Contains(lower, "network is unreachable"),
|
|
strings.Contains(lower, "network unreachable"):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func parseQuickstartRFC3339(raw string) *int64 {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339, raw)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
unix := parsed.UTC().Unix()
|
|
return &unix
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|