diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index e1ad0359..63157a72 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -8,7 +8,7 @@ on: branches: ['main'] env: - GO_VERSION: '1.23' + GO_VERSION: '1.25' jobs: test: @@ -23,6 +23,13 @@ jobs: go-version: ${{ env.GO_VERSION }} cache: true + - name: Check formatting + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "Code is not formatted properly:" + gofmt -s -l . + exit 1 + fi - name: Install Clang run: sudo apt-get install -y clang @@ -40,14 +47,6 @@ jobs: go test ./... # - name: Run vet # run: go vet ./... - - - name: Check formatting - run: | - if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then - echo "Code is not formatted properly:" - gofmt -s -l . - exit 1 - fi build: needs: test runs-on: ubuntu-22.04 diff --git a/internal/api/policy.go b/internal/api/policy.go index 103b6e09..290243aa 100644 --- a/internal/api/policy.go +++ b/internal/api/policy.go @@ -17,9 +17,11 @@ import ( v1 "github.com/vultisig/commondata/go/vultisig/vault/v1" "github.com/vultisig/recipes/engine" rtypes "github.com/vultisig/recipes/types" + "golang.org/x/sync/errgroup" "google.golang.org/protobuf/proto" "github.com/vultisig/verifier/internal/sigutil" + itypes "github.com/vultisig/verifier/internal/types" "github.com/vultisig/verifier/types" vtypes "github.com/vultisig/verifier/types" "github.com/vultisig/vultisig-go/address" @@ -327,7 +329,22 @@ func (s *Server) GetPluginPolicyById(c echo.Context) error { if policy.PublicKey != publicKey { return c.JSON(http.StatusForbidden, NewErrorResponseWithMessage(msgPublicKeyMismatch)) } - return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, policy)) + prog, err := s.pluginService.GetPolicyProgress(c.Request().Context(), policy.PluginID.String(), policyUUID) + if err != nil { + s.logger.WithError(err).Warnf("failed to get progress from plugin for policy %s", policyUUID) + } + if prog == nil { + count, countErr := s.txIndexerService.CountByPolicyID(c.Request().Context(), policyUUID) + if countErr != nil { + s.logger.WithError(countErr).Warnf("failed to get tx count for policy %s", policyUUID) + } + prog = &itypes.Progress{Kind: itypes.ProgressCounter, Value: count} + } + resp := itypes.PluginPolicyResponse{ + PluginPolicy: *policy, + Progress: *prog, + } + return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, resp)) } func (s *Server) GetAllPluginPolicies(c echo.Context) error { @@ -368,5 +385,49 @@ func (s *Server) GetAllPluginPolicies(c echo.Context) error { return c.JSON(http.StatusInternalServerError, NewErrorResponseWithMessage(msgPoliciesGetFailed)) } - return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, policies)) + policyIDs := make([]uuid.UUID, len(policies.Policies)) + for i, p := range policies.Policies { + policyIDs[i] = p.ID + } + + progressMap, err := s.pluginService.GetPoliciesProgress(c.Request().Context(), pluginID, policyIDs) + if err != nil { + s.logger.WithError(err).Warnf("failed to get progress from plugin for plugin %s", pluginID) + } + + responses := make([]itypes.PluginPolicyResponse, len(policies.Policies)) + eg, egCtx := errgroup.WithContext(c.Request().Context()) + for i, p := range policies.Policies { + i, p := i, p + eg.Go(func() error { + var prog *itypes.Progress + if progressMap != nil { + if pluginProg, ok := progressMap[p.ID]; ok { + prog = pluginProg + } + } + if prog == nil { + count, countErr := s.txIndexerService.CountByPolicyID(egCtx, p.ID) + if countErr != nil { + s.logger.WithError(countErr).Warnf("failed to get tx count for policy %s", p.ID) + } + prog = &itypes.Progress{Kind: itypes.ProgressCounter, Value: count} + } + responses[i] = itypes.PluginPolicyResponse{ + PluginPolicy: p, + Progress: *prog, + } + return nil + }) + } + if err := eg.Wait(); err != nil { + s.logger.WithError(err).Error("failed to get policy progress") + return c.JSON(http.StatusInternalServerError, NewErrorResponseWithMessage(msgInternalError)) + } + + result := itypes.PluginPolicyResponsePaginatedList{ + Policies: responses, + TotalCount: policies.TotalCount, + } + return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, result)) } diff --git a/internal/portal/server.go b/internal/portal/server.go index 72159690..4e126bd5 100644 --- a/internal/portal/server.go +++ b/internal/portal/server.go @@ -374,7 +374,7 @@ func (s *Server) GetPluginPricings(c echo.Context) error { } response[i] = PluginPricingResponse{ ID: p.ID.String(), - PluginID: string(p.PluginID), + PluginID: p.PluginID, Type: string(p.Type), Frequency: freq, Amount: strconv.FormatInt(p.Amount, 10), @@ -442,7 +442,7 @@ func (s *Server) GetPluginApiKeys(c echo.Context) error { } response[i] = PluginApiKeyResponse{ ID: k.ID.String(), - PluginID: string(k.PluginID), + PluginID: k.PluginID, ApiKey: maskApiKey(k.Apikey), CreatedAt: k.CreatedAt.Time.Format(time.RFC3339), ExpiresAt: expiresAt, @@ -605,7 +605,7 @@ func (s *Server) CreatePluginApiKey(c echo.Context) error { // Return the full API key (only shown once) return c.JSON(http.StatusCreated, CreateApiKeyResponse{ ID: created.ID.String(), - PluginID: string(created.PluginID), + PluginID: created.PluginID, ApiKey: apiKey, // Full key returned only on creation CreatedAt: created.CreatedAt.Time.Format(time.RFC3339), ExpiresAt: expiresAtStr, @@ -675,7 +675,7 @@ func (s *Server) UpdatePluginApiKey(c echo.Context) error { s.logger.WithError(err).Error("failed to get API key") return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) } - if string(existingKey.PluginID) != pluginID { + if existingKey.PluginID != pluginID { return c.JSON(http.StatusForbidden, map[string]string{"error": "API key does not belong to this plugin"}) } @@ -803,7 +803,7 @@ func (s *Server) DeletePluginApiKey(c echo.Context) error { s.logger.WithError(err).Error("failed to get API key") return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) } - if string(existingKey.PluginID) != pluginID { + if existingKey.PluginID != pluginID { return c.JSON(http.StatusForbidden, map[string]string{"error": "API key does not belong to this plugin"}) } @@ -827,7 +827,7 @@ func (s *Server) DeletePluginApiKey(c echo.Context) error { return c.JSON(http.StatusOK, PluginApiKeyResponse{ ID: expired.ID.String(), - PluginID: string(expired.PluginID), + PluginID: expired.PluginID, ApiKey: maskApiKey(expired.Apikey), CreatedAt: expired.CreatedAt.Time.Format(time.RFC3339), ExpiresAt: expiresAt, diff --git a/internal/service/plugin.go b/internal/service/plugin.go index 4c899497..4da29889 100644 --- a/internal/service/plugin.go +++ b/internal/service/plugin.go @@ -3,12 +3,14 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "net/http" "sort" "strings" "time" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/sirupsen/logrus" rtypes "github.com/vultisig/recipes/types" @@ -39,6 +41,8 @@ type Plugin interface { GetPluginRecipeFunctions(ctx context.Context, pluginID string) (types.RecipeFunctions, error) GetPluginTitlesByIDs(ctx context.Context, ids []string) (map[string]string, error) GetPluginSkills(ctx context.Context, pluginID string) (*PluginSkills, error) + GetPolicyProgress(ctx context.Context, pluginID string, policyID uuid.UUID) (*types.Progress, error) + GetPoliciesProgress(ctx context.Context, pluginID string, policyIDs []uuid.UUID) (map[uuid.UUID]*types.Progress, error) } type PluginServiceStorage interface { @@ -434,6 +438,83 @@ func (s *PluginService) GetPluginSkills(ctx context.Context, pluginID string) (* return skills, nil } +func (s *PluginService) GetPolicyProgress(ctx context.Context, pluginID string, policyID uuid.UUID) (*types.Progress, error) { + plugin, err := s.db.FindPluginById(ctx, nil, ptypes.PluginID(pluginID)) + if err != nil { + return nil, fmt.Errorf("failed to find plugin: %w", err) + } + keyInfo, err := s.db.GetAPIKeyByPluginId(ctx, pluginID) + if err != nil || keyInfo == nil { + return nil, fmt.Errorf("failed to find plugin server info: %w", err) + } + + url := fmt.Sprintf("%s/plugin/policy/%s/progress", strings.TrimSuffix(plugin.ServerEndpoint, "/"), policyID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+keyInfo.ApiKey) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call plugin endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotImplemented || resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("plugin endpoint returned status %d", resp.StatusCode) + } + + var prog types.Progress + err = json.NewDecoder(resp.Body).Decode(&prog) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &prog, nil +} + +func (s *PluginService) GetPoliciesProgress(ctx context.Context, pluginID string, policyIDs []uuid.UUID) (map[uuid.UUID]*types.Progress, error) { + plugin, err := s.db.FindPluginById(ctx, nil, ptypes.PluginID(pluginID)) + if err != nil { + return nil, fmt.Errorf("failed to find plugin: %w", err) + } + keyInfo, err := s.db.GetAPIKeyByPluginId(ctx, pluginID) + if err != nil || keyInfo == nil { + return nil, fmt.Errorf("failed to find plugin server info: %w", err) + } + + url := fmt.Sprintf("%s/plugin/policies/progress", strings.TrimSuffix(plugin.ServerEndpoint, "/")) + + result, err := libhttp.Call[map[uuid.UUID]*types.Progress]( + ctx, + http.MethodPost, + url, + map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer " + keyInfo.ApiKey, + }, + struct { + PolicyIDs []uuid.UUID `json:"policy_ids"` + }{PolicyIDs: policyIDs}, + nil, + ) + if err != nil { + var httpErr *libhttp.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotImplemented || httpErr.StatusCode == http.StatusNotFound) { + return nil, nil + } + return nil, fmt.Errorf("failed to get policies progress: %w", err) + } + + return result, nil +} + func (s *PluginService) fetchSkillsFromPlugin(ctx context.Context, serverEndpoint, token string) (*PluginSkills, error) { url := fmt.Sprintf("%s/skills", strings.TrimSuffix(serverEndpoint, "/")) diff --git a/internal/types/plugin.go b/internal/types/plugin.go index b0499e92..fea3c8e8 100644 --- a/internal/types/plugin.go +++ b/internal/types/plugin.go @@ -8,6 +8,11 @@ import ( "github.com/vultisig/verifier/types" ) +const ( + ProgressPercent = "percent" + ProgressCounter = "counter" +) + type Plugin struct { ID types.PluginID `json:"id" validate:"required"` Title string `json:"title" validate:"required"` @@ -83,6 +88,21 @@ type PluginPolicyPaginatedList struct { TotalCount int `json:"total_count" validate:"required"` } +type Progress struct { + Kind string `json:"kind"` + Value uint32 `json:"value"` +} + +type PluginPolicyResponse struct { + types.PluginPolicy + Progress Progress `json:"progress"` +} + +type PluginPolicyResponsePaginatedList struct { + Policies []PluginPolicyResponse `json:"policies"` + TotalCount int `json:"total_count"` +} + type PluginTotalCount struct { ID types.PluginID `json:"id" validate:"required"` TotalCount int `json:"total_count" validate:"required"` diff --git a/internal/types/transaction.go b/internal/types/transaction.go index eb3f7c2e..90b943f3 100644 --- a/internal/types/transaction.go +++ b/internal/types/transaction.go @@ -30,6 +30,7 @@ type PluginTransactionResponse struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` BroadcastedAt *time.Time `json:"broadcasted_at"` + Progress Progress `json:"progress"` } // FromStorageTxs converts a slice of storage.Tx to a slice of PluginTransactionResponse @@ -55,11 +56,41 @@ func FromStorageTxs(txs []storage.Tx, titleMap map[string]string) []PluginTransa CreatedAt: tx.CreatedAt, UpdatedAt: tx.UpdatedAt, BroadcastedAt: tx.BroadcastedAt, + Progress: Progress{ + ProgressPercent, + getProgress(tx.Status, tx.StatusOnChain, tx.BroadcastedAt), + }, } } return result } +func getProgress(status storage.TxStatus, statusOnchain *rpc.TxOnChainStatus, broadcastedAt *time.Time) uint32 { + switch status { + case storage.TxProposed: + return 20 + case storage.TxVerified: + return 40 + case storage.TxSigned: + if statusOnchain == nil { + return 60 + } + switch *statusOnchain { + case rpc.TxOnChainSuccess, rpc.TxOnChainFail: + return 100 + case rpc.TxOnChainPending: + if broadcastedAt != nil { + return 80 + } + return 60 + default: + return 60 + } + default: + return 0 + } +} + type TransactionHistoryPaginatedList struct { History []PluginTransactionResponse `json:"history"` TotalCount uint32 `json:"total_count"` diff --git a/plugin/policy/service.go b/plugin/policy/service.go index e4898a2b..dc9136d0 100644 --- a/plugin/policy/service.go +++ b/plugin/policy/service.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" "github.com/sirupsen/logrus" + "github.com/vultisig/verifier/plugin/progress" "github.com/vultisig/verifier/plugin/scheduler" "github.com/vultisig/verifier/types" ) @@ -23,22 +24,27 @@ type Service interface { onlyActive bool, ) ([]types.PluginPolicy, error) GetPluginPolicy(ctx context.Context, policyID uuid.UUID) (*types.PluginPolicy, error) + GetProgress(ctx context.Context, policyID uuid.UUID) (*progress.Progress, error) + GetProgressBatch(ctx context.Context, policyIDs []uuid.UUID) (map[uuid.UUID]*progress.Progress, error) } type Policy struct { repo Storage scheduler scheduler.Service + progress progress.Service logger *logrus.Logger } func NewPolicyService( repo Storage, scheduler scheduler.Service, + progress progress.Service, logger *logrus.Logger, ) (*Policy, error) { return &Policy{ repo: repo, scheduler: scheduler, + progress: progress, logger: logger.WithField("pkg", "policy").Logger, }, nil } @@ -140,3 +146,11 @@ func (p *Policy) GetPluginPolicies( func (p *Policy) GetPluginPolicy(ctx context.Context, policyID uuid.UUID) (*types.PluginPolicy, error) { return p.repo.GetPluginPolicy(ctx, policyID) } + +func (p *Policy) GetProgress(ctx context.Context, policyID uuid.UUID) (*progress.Progress, error) { + return p.progress.GetProgress(ctx, policyID) +} + +func (p *Policy) GetProgressBatch(ctx context.Context, policyIDs []uuid.UUID) (map[uuid.UUID]*progress.Progress, error) { + return p.progress.GetProgressBatch(ctx, policyIDs) +} diff --git a/plugin/progress/service.go b/plugin/progress/service.go new file mode 100644 index 00000000..269f3449 --- /dev/null +++ b/plugin/progress/service.go @@ -0,0 +1,17 @@ +package progress + +import ( + "context" + + "github.com/google/uuid" +) + +type Progress struct { + Kind string `json:"kind"` + Value uint32 `json:"value"` +} + +type Service interface { + GetProgress(ctx context.Context, policyID uuid.UUID) (*Progress, error) + GetProgressBatch(ctx context.Context, policyIDs []uuid.UUID) (map[uuid.UUID]*Progress, error) +} diff --git a/plugin/progress/service_nil.go b/plugin/progress/service_nil.go new file mode 100644 index 00000000..20fa5411 --- /dev/null +++ b/plugin/progress/service_nil.go @@ -0,0 +1,22 @@ +package progress + +import ( + "context" + + "github.com/google/uuid" +) + +// NilService implements Service for plugins where progress tracking is not required +type NilService struct{} + +func NewNilService() *NilService { + return &NilService{} +} + +func (s *NilService) GetProgress(_ context.Context, _ uuid.UUID) (*Progress, error) { + return nil, nil +} + +func (s *NilService) GetProgressBatch(_ context.Context, _ []uuid.UUID) (map[uuid.UUID]*Progress, error) { + return nil, nil +} diff --git a/plugin/server/server.go b/plugin/server/server.go index 1cc3df33..ec50ada7 100644 --- a/plugin/server/server.go +++ b/plugin/server/server.go @@ -127,6 +127,8 @@ func (s *Server) GetRouter() *echo.Echo { plg.GET("/recipe-specification", s.handleGetRecipeSpecification) plg.POST("/recipe-specification/suggest", s.handleGetRecipeSpecificationSuggest) plg.DELETE("/policy/:policyId", s.handleDeletePluginPolicyById, s.VerifierAuthMiddleware) + plg.GET("/policy/:policyId/progress", s.handleGetPluginPolicyProgress, s.VerifierAuthMiddleware) + plg.POST("/policies/progress", s.handleGetPluginPoliciesProgress, s.VerifierAuthMiddleware) plg.PUT("/safety", s.handleSyncSafety, s.VerifierAuthMiddleware) e.GET("/skills", s.handleGetSkills) @@ -576,3 +578,46 @@ func (s *Server) handleSyncSafety(c echo.Context) error { "synced": len(flags), }) } + +func (s *Server) handleGetPluginPolicyProgress(c echo.Context) error { + policyID := c.Param("policyId") + if policyID == "" { + return c.JSON(http.StatusBadRequest, NewErrorResponse("invalid policy ID")) + } + uPolicyID, err := uuid.Parse(policyID) + if err != nil { + s.logger.WithError(err).WithField("policy_id", policyID).Error("failed to parse policy ID") + return c.JSON(http.StatusBadRequest, NewErrorResponse("invalid policy ID")) + } + + prog, err := s.policy.GetProgress(c.Request().Context(), uPolicyID) + if err != nil { + s.logger.WithError(err).WithField("policy_id", policyID).Error("failed to get policy progress") + return c.JSON(http.StatusInternalServerError, NewErrorResponse("failed to get policy progress")) + } + if prog == nil { + return c.JSON(http.StatusNotImplemented, NewErrorResponse("progress not available")) + } + + return c.JSON(http.StatusOK, prog) +} + +func (s *Server) handleGetPluginPoliciesProgress(c echo.Context) error { + var req struct { + PolicyIDs []uuid.UUID `json:"policy_ids"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, NewErrorResponse("invalid request")) + } + + result, err := s.policy.GetProgressBatch(c.Request().Context(), req.PolicyIDs) + if err != nil { + s.logger.WithError(err).Error("failed to get policies progress") + return c.JSON(http.StatusInternalServerError, NewErrorResponse("failed to get policies progress")) + } + if result == nil { + return c.JSON(http.StatusNotImplemented, NewErrorResponse("progress not available")) + } + + return c.JSON(http.StatusOK, result) +} diff --git a/plugin/tx_indexer/service.go b/plugin/tx_indexer/service.go index a36002f7..747470dd 100644 --- a/plugin/tx_indexer/service.go +++ b/plugin/tx_indexer/service.go @@ -195,6 +195,14 @@ func (t *Service) GetByPluginIDAndPublicKey( return txs, totalCount, nil } +func (t *Service) CountByPolicyID(ctx context.Context, policyID uuid.UUID) (uint32, error) { + count, err := t.repo.CountByPolicyID(ctx, policyID) + if err != nil { + return 0, fmt.Errorf("t.repo.CountByPolicyID: %w", err) + } + return count, nil +} + func (t *Service) GetByPublicKey( c context.Context, publicKey string,