Skip to content

Commit 1224482

Browse files
VitaliiShpitalStorj Robot
authored andcommitted
satellite/console: added new endpoint for the project pricing migration
Issue: storj/storj-private#1615 Change-Id: I4cabc56c3e81b11308449bbfa20d06c5434d728a
1 parent b95f9a7 commit 1224482

File tree

7 files changed

+363
-1
lines changed

7 files changed

+363
-1
lines changed

satellite/console/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,46 @@ STORJ_CONSOLE_PLACEMENT_SELF_SERVE_DETAILS: |
3838
- `title`: Title for displaying placement, used in UI
3939
- `description`: Description of the placement, used in UI
4040
- `wait-list-url`: Optional URL for a wait list if the placement is not available yet.
41+
42+
## Legacy Placement Product Mapping for Migration
43+
44+
### STORJ_CONSOLE_LEGACY_PLACEMENT_PRODUCT_MAPPING_FOR_MIGRATION
45+
46+
This configuration provides an override mapping from legacy placement IDs to product IDs,
47+
used during project pricing migration from classic to new pricing tiers.
48+
49+
#### Purpose
50+
51+
When migrating "classic" projects to the new pricing model,
52+
legacy placements need to be mapped to product IDs in the new billing system.
53+
This config allows satellite operators to define custom mappings for specific legacy placements that may require different product assignments than the default behavior.
54+
55+
#### How It Works
56+
57+
During project pricing migration (via the `/projects/{id}/migrate-pricing` endpoint),
58+
the system builds a composite mapping of placements to products in the following priority order:
59+
60+
1. Partner-specific mappings (if applicable)
61+
2. Default placement-to-product mappings (from `STORJ_PAYMENTS_PLACEMENT_PRICE_OVERRIDES`)
62+
3. **Override mappings from this config** (highest priority)
63+
64+
This config provides the final override layer,
65+
ensuring that specific legacy placements can be mapped to the correct product IDs regardless of partner or default settings.
66+
67+
#### Configuration Format
68+
69+
The value is a JSON object mapping placement IDs (as strings) to product IDs (as integers):
70+
71+
```
72+
STORJ_CONSOLE_LEGACY_PLACEMENT_PRODUCT_MAPPING_FOR_MIGRATION: '{"0":1,"12":2}'
73+
```
74+
75+
In this example:
76+
- Legacy placement `0` maps to product ID `1`
77+
- Legacy placement `12` maps to product ID `2`
78+
79+
#### Related Configuration
80+
81+
- `STORJ_CONSOLE_LEGACY_PLACEMENTS`: Defines which placement IDs are considered "legacy"
82+
- `STORJ_PAYMENTS_PLACEMENT_PRICE_OVERRIDES`: Default placement-to-product mappings for new placements
83+
- `STORJ_CONSOLE_PLACEMENT_ALLOWED_PLACEMENT_IDS_FOR_NEW_PROJECTS`: Placements available after migration

satellite/console/config.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ type Config struct {
6262
NewPricingStartDate string `help:"the date (YYYY-MM-DD) when new pricing tiers will be enabled" default:"2025-11-01"`
6363
ProductPriceSummaries []string `help:"the pricing summaries gotten from configured products" default:"" hidden:"true"`
6464
MemberAccountsEnabled bool `help:"whether member accounts are enabled" default:"false"`
65-
LegacyPlacements []string `help:"list of placement IDs that are considered legacy placements" default:""`
65+
66+
LegacyPlacements []string `help:"list of placement IDs that are considered legacy placements" default:""`
67+
LegacyPlacementProductMappingForMigration PlacementProductMappings `help:"mapping of legacy placement IDs to product IDs for migration" default:""`
6668

6769
ManagedEncryption SatelliteManagedEncryptionConfig
6870
RestAPIKeys RestAPIKeysConfig
@@ -338,3 +340,46 @@ func (pd *PlacementDetails) Get(placement storj.PlacementConstraint) (details Pl
338340
}
339341
return PlacementDetail{}, false
340342
}
343+
344+
// PlacementProductMappings represents a mapping between placement IDs and product IDs.
345+
type PlacementProductMappings struct {
346+
mappings map[storj.PlacementConstraint]int32
347+
}
348+
349+
// Ensure that PlacementProductMappings implements pflag.Value.
350+
var _ pflag.Value = (*PlacementProductMappings)(nil)
351+
352+
// Type returns the type of the pflag.Value.
353+
func (*PlacementProductMappings) Type() string { return "entitlements.PlacementProductMappings" }
354+
355+
// String returns a string representation of the PlacementProductMappings.
356+
func (ppm *PlacementProductMappings) String() string {
357+
if ppm == nil || len(ppm.mappings) == 0 {
358+
return ""
359+
}
360+
361+
data, err := json.Marshal(ppm.mappings)
362+
if err != nil {
363+
return ""
364+
}
365+
366+
return string(data)
367+
}
368+
369+
// Set parses and sets the PlacementProductMappings from a string.
370+
func (ppm *PlacementProductMappings) Set(value string) error {
371+
if value == "" {
372+
return nil
373+
}
374+
375+
value = strings.TrimSpace(value)
376+
377+
mappings := make(map[storj.PlacementConstraint]int32)
378+
if err := json.Unmarshal([]byte(value), &mappings); err != nil {
379+
return errs.New("failed to parse PlacementProductMappings: %w", err)
380+
}
381+
382+
ppm.mappings = mappings
383+
384+
return nil
385+
}

satellite/console/consoleweb/consoleapi/projects.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,41 @@ func (p *Projects) GetConfig(w http.ResponseWriter, r *http.Request) {
717717
}
718718
}
719719

720+
// MigratePricing migrates classic project to use new storage tiers.
721+
func (p *Projects) MigratePricing(w http.ResponseWriter, r *http.Request) {
722+
ctx := r.Context()
723+
var err error
724+
defer mon.Task()(&ctx)(&err)
725+
726+
w.Header().Set("Content-Type", "application/json")
727+
728+
idParam, ok := mux.Vars(r)["id"]
729+
if !ok {
730+
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing id route param"))
731+
return
732+
}
733+
734+
id, err := uuid.FromString(idParam)
735+
if err != nil {
736+
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
737+
}
738+
739+
err = p.service.MigrateProjectPricing(ctx, id)
740+
if err != nil {
741+
status := http.StatusInternalServerError
742+
743+
switch {
744+
case console.ErrUnauthorized.Has(err) || console.ErrNoMembership.Has(err):
745+
status = http.StatusUnauthorized
746+
case console.ErrConflict.Has(err):
747+
status = http.StatusConflict
748+
case console.ErrForbidden.Has(err):
749+
status = http.StatusForbidden
750+
}
751+
p.serveJSONError(ctx, w, status, err)
752+
}
753+
}
754+
720755
// InviteUser sends a project invitation to a user.
721756
func (p *Projects) InviteUser(w http.ResponseWriter, r *http.Request) {
722757
ctx := r.Context()

satellite/console/consoleweb/server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, cons
313313
projectsRouter.Handle("/{id}/invite-link", http.HandlerFunc(projectsController.GetInviteLink)).Methods(http.MethodGet, http.MethodOptions)
314314
projectsRouter.Handle("/{id}/emission", http.HandlerFunc(projectsController.GetEmissionImpact)).Methods(http.MethodGet, http.MethodOptions)
315315
projectsRouter.Handle("/{id}/config", http.HandlerFunc(projectsController.GetConfig)).Methods(http.MethodGet, http.MethodOptions)
316+
if entitlementsEnabled {
317+
projectsRouter.Handle("/{id}/migrate-pricing", server.withCSRFProtection(http.HandlerFunc(projectsController.MigratePricing))).Methods(http.MethodPost, http.MethodOptions)
318+
}
319+
316320
projectsRouter.Handle("/invitations", http.HandlerFunc(projectsController.GetUserInvitations)).Methods(http.MethodGet, http.MethodOptions)
317321
projectsRouter.Handle("/invitations/{id}/respond", server.withCSRFProtection(http.HandlerFunc(projectsController.RespondToInvitation))).Methods(http.MethodPost, http.MethodOptions)
318322

satellite/console/service.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4412,6 +4412,71 @@ func (s *Service) validateLimits(ctx context.Context, project *Project, updatedL
44124412
return nil
44134413
}
44144414

4415+
// MigrateProjectPricing is a method for migrating project pricing to new model.
4416+
func (s *Service) MigrateProjectPricing(ctx context.Context, publicProjectID uuid.UUID) (err error) {
4417+
defer mon.Task()(&ctx)(&err)
4418+
4419+
user, err := s.getUserAndAuditLog(ctx, "migrate project pricing")
4420+
if err != nil {
4421+
return ErrUnauthorized.Wrap(err)
4422+
}
4423+
4424+
isMember, err := s.isProjectMember(ctx, user.ID, publicProjectID)
4425+
if err != nil {
4426+
return ErrUnauthorized.Wrap(err)
4427+
}
4428+
if isMember.membership.Role != RoleAdmin {
4429+
return ErrForbidden.New("only project owner or admin may migrate project pricing")
4430+
}
4431+
4432+
if !s.entitlementsConfig.Enabled || s.legacyPlacements == nil {
4433+
return ErrForbidden.New("project pricing migration is not available")
4434+
}
4435+
4436+
p := isMember.project
4437+
4438+
if ent, err := s.entitlementsService.Projects().GetByPublicID(ctx, p.PublicID); err == nil && ent.NewBucketPlacements != nil {
4439+
if !slices.Equal(ent.NewBucketPlacements, s.legacyPlacements) {
4440+
return ErrConflict.New("project pricing migration is only available for classic projects")
4441+
}
4442+
}
4443+
4444+
partnerMap, defaultMap := s.accounts.GetPlacementProductMappings(string(p.UserAgent))
4445+
4446+
mapping := entitlements.PlacementProductMappings{}
4447+
for placement, productID := range partnerMap {
4448+
mapping[storj.PlacementConstraint(placement)] = productID
4449+
}
4450+
for placement, productID := range defaultMap {
4451+
if _, exists := mapping[storj.PlacementConstraint(placement)]; !exists {
4452+
mapping[storj.PlacementConstraint(placement)] = productID
4453+
}
4454+
}
4455+
for placement, productID := range s.config.LegacyPlacementProductMappingForMigration.mappings {
4456+
mapping[placement] = productID
4457+
}
4458+
4459+
feats := entitlements.ProjectFeatures{
4460+
NewBucketPlacements: s.config.Placement.AllowedPlacementIdsForNewProjects,
4461+
PlacementProductMappings: mapping,
4462+
}
4463+
featBytes, err := json.Marshal(feats)
4464+
if err != nil {
4465+
return Error.Wrap(err)
4466+
}
4467+
4468+
_, err = s.store.Entitlements().UpsertByScope(ctx, &entitlements.Entitlement{
4469+
Scope: entitlements.ConvertPublicIDToProjectScope(p.PublicID),
4470+
Features: featBytes,
4471+
UpdatedAt: s.nowFn(),
4472+
})
4473+
if err != nil {
4474+
return Error.Wrap(err)
4475+
}
4476+
4477+
return nil
4478+
}
4479+
44154480
// RequestLimitIncrease is a method for requesting limit increase for a project.
44164481
func (s *Service) RequestLimitIncrease(ctx context.Context, projectID uuid.UUID, info LimitRequestInfo) (err error) {
44174482
defer mon.Task()(&ctx)(&err)

0 commit comments

Comments
 (0)