diff --git a/api/server/handlers/billing/ingest.go b/api/server/handlers/billing/ingest.go index 4a476e963f..697984c152 100644 --- a/api/server/handlers/billing/ingest.go +++ b/api/server/handlers/billing/ingest.go @@ -2,6 +2,9 @@ package billing import ( + "bytes" + "context" + "encoding/json" "fmt" "net/http" @@ -80,5 +83,44 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } + // Call the ingest health endpoint + err = c.postIngestHealthEndpoint(ctx, proj.ID) + if err != nil { + err := telemetry.Error(ctx, span, err, "error calling ingest health endpoint") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + c.WriteResult(w, r, "") } + +func (c *IngestEventsHandler) postIngestHealthEndpoint(ctx context.Context, projectID uint) (err error) { + ctx, span := telemetry.NewSpan(ctx, "post-ingest-health-endpoint") + defer span.End() + + // Call the ingest check webhook + webhookUrl := c.Config().ServerConf.IngestStatusWebhookUrl + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "ingest-status-webhook-url", Value: webhookUrl}) + + if webhookUrl == "" { + return nil + } + + req := struct { + ProjectID uint `json:"project_id"` + }{ + ProjectID: projectID, + } + + reqBody, err := json.Marshal(req) + if err != nil { + return telemetry.Error(ctx, span, err, "error marshalling ingest status webhook request") + } + + client := &http.Client{} + resp, err := client.Post(webhookUrl, "application/json", bytes.NewBuffer(reqBody)) + if err != nil || resp.StatusCode != http.StatusOK { + return telemetry.Error(ctx, span, err, "error sending ingest status webhook request") + } + return nil +} diff --git a/api/server/handlers/oauth_callback/upstash.go b/api/server/handlers/oauth_callback/upstash.go index 3907eb9efc..828b688150 100644 --- a/api/server/handlers/oauth_callback/upstash.go +++ b/api/server/handlers/oauth_callback/upstash.go @@ -10,6 +10,7 @@ import ( "net/url" "time" + "github.com/golang-jwt/jwt" "github.com/porter-dev/porter/api/server/handlers" "github.com/porter-dev/porter/api/server/shared" "github.com/porter-dev/porter/api/server/shared/apierrors" @@ -100,6 +101,25 @@ func (p *OAuthCallbackUpstashHandler) ServeHTTP(w http.ResponseWriter, r *http.R return } + t, _, err := new(jwt.Parser).ParseUnverified(token.AccessToken, jwt.MapClaims{}) // safe to use because we validated the token above + if err != nil { + err = telemetry.Error(ctx, span, err, "error parsing token") + p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + var email string + if claims, ok := t.Claims.(jwt.MapClaims); ok { + if emailVal, ok := claims["https://user.io/email"].(string); ok { + email = emailVal + } + } + if email == "" { + err = telemetry.Error(ctx, span, nil, "email not found in token") + p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + // make an http call to https://api.upstash.com/apikey with authorization: bearer // to get the api key apiKey, err := fetchUpstashApiKey(ctx, token.AccessToken) @@ -118,6 +138,7 @@ func (p *OAuthCallbackUpstashHandler) ServeHTTP(w http.ResponseWriter, r *http.R }, ProjectID: projID, DeveloperApiKey: []byte(apiKey), + UpstashEmail: email, } _, err = p.Repo().UpstashIntegration().Insert(ctx, oauthInt) diff --git a/api/server/shared/config/env/envconfs.go b/api/server/shared/config/env/envconfs.go index 3003a4accc..683fdc1f03 100644 --- a/api/server/shared/config/env/envconfs.go +++ b/api/server/shared/config/env/envconfs.go @@ -75,6 +75,9 @@ type ServerConf struct { PorterCloudPlanID string `env:"PORTER_CLOUD_PLAN_ID"` PorterStandardPlanID string `env:"PORTER_STANDARD_PLAN_ID"` + // The URL of the webhook to verify ingesting events works + IngestStatusWebhookUrl string `env:"INGEST_STATUS_WEBHOOK_URL"` + // This endpoint will be passed to the porter-agent so that // the billing manager can query Prometheus. PrometheusUrl string `env:"PROMETHEUS_URL"` diff --git a/api/types/project.go b/api/types/project.go index 3f537cccf7..ecf3c48d13 100644 --- a/api/types/project.go +++ b/api/types/project.go @@ -41,6 +41,7 @@ type Project struct { BillingEnabled bool `json:"billing_enabled"` MetronomeEnabled bool `json:"metronome_enabled"` InfisicalEnabled bool `json:"infisical_enabled"` + FreezeEnabled bool `json:"freeze_enabled"` DBEnabled bool `json:"db_enabled"` EFSEnabled bool `json:"efs_enabled"` EnableReprovision bool `json:"enable_reprovision"` diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index f9c5ca098e..0f4034e571 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -400,13 +400,14 @@ const Home: React.FC = (props) => { {!currentProject?.sandbox_enabled && @@ -433,6 +434,14 @@ const Home: React.FC = (props) => { )} )} + {currentProject?.freeze_enabled && ( + + warning + This project has been disabled due to recurring issues with the + connected payment method. Please contact support@porter.run to + reenable this project. + + )} {showBillingModal && ( { @@ -704,6 +713,7 @@ export default withRouter(withAuth(Home)); const GlobalBanner = styled.div` width: 100vw; z-index: 999; + padding: 20px; position: fixed; top: 0; color: #fefefe; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx index fd184204d7..3d2eb2ae3a 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx @@ -135,7 +135,7 @@ const Resources: React.FC = ({ <> - Sleep Service + Sleep service = ({ render={({ field: { value, onChange } }) => ( { onChange({ ...value, @@ -158,7 +159,11 @@ const Resources: React.FC = ({ }); }} > - Pause all instances. + + {currentProject?.freeze_enabled + ? "Contact support@porter.run to re-enable your project and unsleep services." + : "Pause all instances."} + )} /> diff --git a/dashboard/src/shared/types.tsx b/dashboard/src/shared/types.tsx index f623a998a0..b39f0b36e8 100644 --- a/dashboard/src/shared/types.tsx +++ b/dashboard/src/shared/types.tsx @@ -289,15 +289,15 @@ export type FormElement = { export type RepoType = { FullName: string; } & ( - | { + | { Kind: "github"; GHRepoID: number; } - | { + | { Kind: "gitlab"; GitIntegrationId: number; } - ); +); export type FileType = { path: string; @@ -318,6 +318,7 @@ export type ProjectType = { billing_enabled: boolean; metronome_enabled: boolean; infisical_enabled: boolean; + freeze_enabled: boolean; capi_provisioner_enabled: boolean; db_enabled: boolean; efs_enabled: boolean; @@ -380,15 +381,15 @@ export type ActionConfigType = { image_repo_uri: string; dockerfile_path?: string; } & ( - | { + | { kind: "gitlab"; gitlab_integration_id: number; } - | { + | { kind: "github"; git_repo_id: number; } - ); +); export type GithubActionConfigType = ActionConfigType & { kind: "github"; diff --git a/go.mod b/go.mod index 4124f28a99..8d0f682104 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.0 github.com/glebarez/sqlite v1.6.0 github.com/go-chi/chi/v5 v5.0.8 + github.com/golang-jwt/jwt v3.2.1+incompatible github.com/gosimple/slug v1.13.1 github.com/honeycombio/otel-config-go v1.11.0 github.com/launchdarkly/go-sdk-common/v3 v3.0.1 @@ -151,7 +152,6 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang-jwt/jwt v3.2.1+incompatible // indirect github.com/google/gnostic v0.6.9 // indirect github.com/google/s2a-go v0.1.4 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect diff --git a/internal/models/integrations/upstash.go b/internal/models/integrations/upstash.go index f90d2ddbec..049b85e028 100644 --- a/internal/models/integrations/upstash.go +++ b/internal/models/integrations/upstash.go @@ -11,4 +11,6 @@ type UpstashIntegration struct { SharedOAuthModel DeveloperApiKey []byte `json:"developer_api_key"` + + UpstashEmail string `json:"upstash_email"` } diff --git a/internal/models/project.go b/internal/models/project.go index 4447d35c14..13c902f3ef 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -35,6 +35,9 @@ const ( // InfisicalEnabled enables the Infisical secrets operator integration InfisicalEnabled FeatureFlagLabel = "infisical_enabled" + // FreezeEnabled freezes the project + FreezeEnabled FeatureFlagLabel = "freeze_enabled" + // DBEnabled enables the "Databases" tab DBEnabled FeatureFlagLabel = "db_enabled" @@ -106,6 +109,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{ BillingEnabled: false, MetronomeEnabled: false, InfisicalEnabled: false, + FreezeEnabled: false, DBEnabled: false, EFSEnabled: false, EnableReprovision: false, @@ -319,6 +323,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje BillingEnabled: p.GetFeatureFlag(BillingEnabled, launchDarklyClient), MetronomeEnabled: p.GetFeatureFlag(MetronomeEnabled, launchDarklyClient), InfisicalEnabled: p.GetFeatureFlag(InfisicalEnabled, launchDarklyClient), + FreezeEnabled: p.GetFeatureFlag(FreezeEnabled, launchDarklyClient), DBEnabled: p.GetFeatureFlag(DBEnabled, launchDarklyClient), EFSEnabled: p.GetFeatureFlag(EFSEnabled, launchDarklyClient), EnableReprovision: p.GetFeatureFlag(EnableReprovision, launchDarklyClient),