ducky/subscriptions

Paddy 2015-06-30 Parent:b240b6123548 Child:c4cfceb2f2fb

5:fe8f092cc149 Go to Latest

ducky/subscriptions/subscription.go

Make subscriptions Kubernetes-ready. Update our .hgignore file to include the docker-ready subscripionsd binary (which differs from the local subscriptionsd binary only in that it's statically-compiled for linux). Add a replication controller for subscriptionsd that will spin up the appropriate pods for us and make sure our Stripe and Subscriptionsd secret volumes are attached, so the pods may configure themselves with that private info. Create a Stripe secret volume with a placeholder for the stripe secret key. Create a Subscriptionsd secret volume with the DSN sent to the base64 encoding of an empty string right now (which means subscriptionsd will store data in memory, but whatever.) Create a subscriptionsd service that will route traffic to the subscriptionsd pods created by the replication controller. Create a minimal Dockerfile to get the subscriptionsd binary running on kubernetes. Add a build-docker helper script that will compile a Docker-ready subscriptionsd binary (by compiling it in a Docker container). Copy a Ubuntu ca-certificates.crt file that we'll inject into our Dockerfile so the Stripe SSL can be resolved. Busybox doesn't have any certificates, by default. Create a wrapper script that will be run in the Docker container to read the secrets from our secret volumes and inject them as environment variables, which is what subscriptionsd understands.

History
paddy@0 1 package subscriptions
paddy@0 2
paddy@0 3 import (
paddy@0 4 "errors"
paddy@0 5 "time"
paddy@0 6
paddy@3 7 "code.secondbit.org/api.hg"
paddy@0 8 "code.secondbit.org/uuid.hg"
paddy@0 9 )
paddy@0 10
paddy@0 11 var (
paddy@0 12 // ErrSubscriptionAlreadyExists is returned when a Subscription
paddy@0 13 // with an identical ID already exists in the subscriptionStore.
paddy@0 14 ErrSubscriptionAlreadyExists = errors.New("Subscription already exists")
paddy@0 15 // ErrSubscriptionNotFound is returned when a single Subscription
paddy@0 16 // is acted upon or requested, but cannot be found.
paddy@0 17 ErrSubscriptionNotFound = errors.New("Subscription not found")
paddy@2 18 // ErrStripeSubscriptionAlreadyExists is returned when a Subscription
paddy@2 19 // is created or updates its StripeSubscription property, but that
paddy@2 20 // StripeSubscription is already associated with another Subscription.
paddy@2 21 ErrStripeSubscriptionAlreadyExists = errors.New("Stripe subscription already assigned to another Subscription")
paddy@0 22 // ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
paddy@0 23 // is empty but is passed to subscriptionStore.UpdateSubscription
paddy@0 24 // anyways.
paddy@0 25 ErrSubscriptionChangeEmpty = errors.New("SubscriptionChange is empty")
paddy@0 26 // ErrNoSubscriptionID is returned when one or more Subscription IDs
paddy@0 27 // are required, but none are provided.
paddy@0 28 ErrNoSubscriptionID = errors.New("no Subscription ID provided")
paddy@3 29
paddy@3 30 planOptions = []string{"basic_monthly", "basic_yearly", "supporter_monthly", "supporter_yearly"}
paddy@3 31
paddy@3 32 Version string
paddy@0 33 )
paddy@0 34
paddy@0 35 // Subscription represents the state of a user's payments. It holds
paddy@0 36 // metadata about the last time a user was charged, how much a user
paddy@0 37 // should be charged, how to charge a user and how much to charge
paddy@0 38 // the user.
paddy@0 39 type Subscription struct {
paddy@3 40 UserID uuid.ID `json:"user_id"`
paddy@3 41 StripeSubscription string `json:"stripe_subscription"`
paddy@3 42 Plan string `json:"plan"`
paddy@3 43 Status string `json:"status"`
paddy@3 44 Canceling bool `json:"canceling"`
paddy@3 45 Created time.Time `json:"created"`
paddy@3 46 TrialStart time.Time `json:"trial_start,omitempty"`
paddy@3 47 TrialEnd time.Time `json:"trial_end,omitempty"`
paddy@3 48 PeriodStart time.Time `json:"period_start,omitempty"`
paddy@3 49 PeriodEnd time.Time `json:"period_end,omitempty"`
paddy@3 50 CanceledAt time.Time `json:"canceled_at,omitempty"`
paddy@3 51 FailedChargeAttempts int `json:"failed_charge_attempts"`
paddy@3 52 LastFailedCharge time.Time `json:"last_failed_charge,omitempty"`
paddy@3 53 LastNotified time.Time `json:"last_notified,omitempty"`
paddy@0 54 }
paddy@0 55
paddy@0 56 // SubscriptionChange represents desired changes to a Subscription
paddy@0 57 // object. A nil value means that property should remain unchanged.
paddy@0 58 type SubscriptionChange struct {
paddy@2 59 StripeSubscription *string
paddy@2 60 Plan *string
paddy@2 61 Status *string
paddy@2 62 Canceling *bool
paddy@2 63 TrialStart *time.Time
paddy@2 64 TrialEnd *time.Time
paddy@2 65 PeriodStart *time.Time
paddy@2 66 PeriodEnd *time.Time
paddy@2 67 CanceledAt *time.Time
paddy@2 68 FailedChargeAttempts *int
paddy@2 69 LastFailedCharge *time.Time
paddy@2 70 LastNotified *time.Time
paddy@0 71 }
paddy@0 72
paddy@0 73 // IsEmpty returns true if the SubscriptionChange doesn't request
paddy@0 74 // a change to any property of the Subscription.
paddy@0 75 func (change SubscriptionChange) IsEmpty() bool {
paddy@2 76 if change.StripeSubscription != nil {
paddy@0 77 return false
paddy@0 78 }
paddy@2 79 if change.Plan != nil {
paddy@0 80 return false
paddy@0 81 }
paddy@2 82 if change.Status != nil {
paddy@0 83 return false
paddy@0 84 }
paddy@2 85 if change.Canceling != nil {
paddy@0 86 return false
paddy@0 87 }
paddy@2 88 if change.TrialStart != nil {
paddy@2 89 return false
paddy@2 90 }
paddy@2 91 if change.TrialEnd != nil {
paddy@2 92 return false
paddy@2 93 }
paddy@2 94 if change.PeriodStart != nil {
paddy@2 95 return false
paddy@2 96 }
paddy@2 97 if change.PeriodEnd != nil {
paddy@2 98 return false
paddy@2 99 }
paddy@2 100 if change.CanceledAt != nil {
paddy@0 101 return false
paddy@0 102 }
paddy@0 103 if change.LastNotified != nil {
paddy@0 104 return false
paddy@0 105 }
paddy@2 106 if change.LastFailedCharge != nil {
paddy@2 107 return false
paddy@2 108 }
paddy@2 109 if change.FailedChargeAttempts != nil {
paddy@0 110 return false
paddy@0 111 }
paddy@0 112 return true
paddy@0 113 }
paddy@0 114
paddy@0 115 // ApplyChange updates a Subscription based on the changes requested
paddy@0 116 // by a SubscriptionChange.
paddy@0 117 func (s *Subscription) ApplyChange(change SubscriptionChange) {
paddy@2 118 if change.StripeSubscription != nil {
paddy@2 119 s.StripeSubscription = *change.StripeSubscription
paddy@0 120 }
paddy@2 121 if change.Plan != nil {
paddy@2 122 s.Plan = *change.Plan
paddy@0 123 }
paddy@2 124 if change.Status != nil {
paddy@2 125 s.Status = *change.Status
paddy@0 126 }
paddy@2 127 if change.Canceling != nil {
paddy@2 128 s.Canceling = *change.Canceling
paddy@0 129 }
paddy@2 130 if change.TrialStart != nil {
paddy@2 131 s.TrialStart = *change.TrialStart
paddy@2 132 }
paddy@2 133 if change.TrialEnd != nil {
paddy@2 134 s.TrialEnd = *change.TrialEnd
paddy@2 135 }
paddy@2 136 if change.PeriodStart != nil {
paddy@2 137 s.PeriodStart = *change.PeriodStart
paddy@2 138 }
paddy@2 139 if change.PeriodEnd != nil {
paddy@2 140 s.PeriodEnd = *change.PeriodEnd
paddy@2 141 }
paddy@2 142 if change.CanceledAt != nil {
paddy@2 143 s.CanceledAt = *change.CanceledAt
paddy@2 144 }
paddy@2 145 if change.LastFailedCharge != nil {
paddy@2 146 s.LastFailedCharge = *change.LastFailedCharge
paddy@0 147 }
paddy@0 148 if change.LastNotified != nil {
paddy@0 149 s.LastNotified = *change.LastNotified
paddy@0 150 }
paddy@2 151 if change.FailedChargeAttempts != nil {
paddy@2 152 s.FailedChargeAttempts = *change.FailedChargeAttempts
paddy@0 153 }
paddy@0 154 }
paddy@0 155
paddy@3 156 type SubscriptionRequest struct {
paddy@3 157 UserID uuid.ID `json:"user_id"`
paddy@3 158 Email string `json:"email"`
paddy@3 159 StripeToken string `json:"stripe_token"`
paddy@3 160 Plan string `json:"plan"`
paddy@3 161 }
paddy@3 162
paddy@3 163 func (req SubscriptionRequest) Validate(user uuid.ID, admin bool) []api.RequestError {
paddy@3 164 var errors []api.RequestError
paddy@3 165 if req.UserID == nil {
paddy@3 166 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/user_id"})
paddy@3 167 } else if !req.UserID.Equal(user) && !admin {
paddy@3 168 errors = append(errors, api.RequestError{Slug: api.RequestErrAccessDenied, Field: "/user_id"})
paddy@3 169 }
paddy@3 170 if req.Email == "" {
paddy@3 171 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/email"})
paddy@3 172 }
paddy@3 173 if req.StripeToken == "" {
paddy@3 174 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/stripe_token"})
paddy@3 175 }
paddy@3 176 if req.Plan == "" {
paddy@3 177 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/plan"})
paddy@3 178 } else {
paddy@3 179 var acceptablePlan bool
paddy@3 180 for _, p := range planOptions {
paddy@3 181 if req.Plan == p {
paddy@3 182 acceptablePlan = true
paddy@3 183 break
paddy@3 184 }
paddy@3 185 }
paddy@3 186 if !acceptablePlan {
paddy@3 187 errors = append(errors, api.RequestError{Slug: api.RequestErrInvalidValue, Field: "/plan"})
paddy@3 188 }
paddy@3 189 }
paddy@3 190 return errors
paddy@3 191 }
paddy@3 192
paddy@3 193 func SubscriptionFromRequest(req SubscriptionRequest) Subscription {
paddy@3 194 return Subscription{
paddy@3 195 UserID: req.UserID,
paddy@3 196 StripeSubscription: req.StripeToken,
paddy@3 197 Plan: req.Plan,
paddy@3 198 Created: time.Now(),
paddy@3 199 TrialStart: time.Now(),
paddy@3 200 }
paddy@3 201 }
paddy@3 202
paddy@1 203 // SubscriptionStats represents a set of statistics about our Subscription
paddy@1 204 // data that will be exposed to the Prometheus scraper.
paddy@1 205 type SubscriptionStats struct {
paddy@2 206 Number int64
paddy@2 207 Canceling int64
paddy@2 208 Failing int64
paddy@2 209 Plans map[string]int64
paddy@1 210 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
paddy@1 211 // Because of this, we can only report stats that will be identical across nodes, e.g. stats
paddy@1 212 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
paddy@1 213 // In the future, we'll need per-node metrics. For now, we'll make do.
paddy@1 214 //
paddy@1 215 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set:
paddy@1 216 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port,
paddy@1 217 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local.
paddy@1 218 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local.
paddy@1 219 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and
paddy@1 220 // contains the port number and a CNAME of the pod with the format auto-generated-name.my-svc.my-namespace.svc.cluster.local
paddy@1 221 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment
paddy@1 222 // was omitted."
paddy@1 223 }
paddy@1 224
paddy@3 225 type SubscriptionStore interface {
paddy@3 226 Reset() error
paddy@3 227 CreateSubscription(sub Subscription) error
paddy@3 228 UpdateSubscription(id uuid.ID, change SubscriptionChange) error
paddy@3 229 DeleteSubscription(id uuid.ID) error
paddy@3 230 GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
paddy@3 231 GetSubscriptionStats() (SubscriptionStats, error)
paddy@0 232 }