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.
7 "code.secondbit.org/api.hg"
8 "code.secondbit.org/uuid.hg"
12 // ErrSubscriptionAlreadyExists is returned when a Subscription
13 // with an identical ID already exists in the subscriptionStore.
14 ErrSubscriptionAlreadyExists = errors.New("Subscription already exists")
15 // ErrSubscriptionNotFound is returned when a single Subscription
16 // is acted upon or requested, but cannot be found.
17 ErrSubscriptionNotFound = errors.New("Subscription not found")
18 // ErrStripeSubscriptionAlreadyExists is returned when a Subscription
19 // is created or updates its StripeSubscription property, but that
20 // StripeSubscription is already associated with another Subscription.
21 ErrStripeSubscriptionAlreadyExists = errors.New("Stripe subscription already assigned to another Subscription")
22 // ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
23 // is empty but is passed to subscriptionStore.UpdateSubscription
25 ErrSubscriptionChangeEmpty = errors.New("SubscriptionChange is empty")
26 // ErrNoSubscriptionID is returned when one or more Subscription IDs
27 // are required, but none are provided.
28 ErrNoSubscriptionID = errors.New("no Subscription ID provided")
30 planOptions = []string{"basic_monthly", "basic_yearly", "supporter_monthly", "supporter_yearly"}
35 // Subscription represents the state of a user's payments. It holds
36 // metadata about the last time a user was charged, how much a user
37 // should be charged, how to charge a user and how much to charge
39 type Subscription struct {
40 UserID uuid.ID `json:"user_id"`
41 StripeSubscription string `json:"stripe_subscription"`
42 Plan string `json:"plan"`
43 Status string `json:"status"`
44 Canceling bool `json:"canceling"`
45 Created time.Time `json:"created"`
46 TrialStart time.Time `json:"trial_start,omitempty"`
47 TrialEnd time.Time `json:"trial_end,omitempty"`
48 PeriodStart time.Time `json:"period_start,omitempty"`
49 PeriodEnd time.Time `json:"period_end,omitempty"`
50 CanceledAt time.Time `json:"canceled_at,omitempty"`
51 FailedChargeAttempts int `json:"failed_charge_attempts"`
52 LastFailedCharge time.Time `json:"last_failed_charge,omitempty"`
53 LastNotified time.Time `json:"last_notified,omitempty"`
56 // SubscriptionChange represents desired changes to a Subscription
57 // object. A nil value means that property should remain unchanged.
58 type SubscriptionChange struct {
59 StripeSubscription *string
65 PeriodStart *time.Time
68 FailedChargeAttempts *int
69 LastFailedCharge *time.Time
70 LastNotified *time.Time
73 // IsEmpty returns true if the SubscriptionChange doesn't request
74 // a change to any property of the Subscription.
75 func (change SubscriptionChange) IsEmpty() bool {
76 if change.StripeSubscription != nil {
79 if change.Plan != nil {
82 if change.Status != nil {
85 if change.Canceling != nil {
88 if change.TrialStart != nil {
91 if change.TrialEnd != nil {
94 if change.PeriodStart != nil {
97 if change.PeriodEnd != nil {
100 if change.CanceledAt != nil {
103 if change.LastNotified != nil {
106 if change.LastFailedCharge != nil {
109 if change.FailedChargeAttempts != nil {
115 // ApplyChange updates a Subscription based on the changes requested
116 // by a SubscriptionChange.
117 func (s *Subscription) ApplyChange(change SubscriptionChange) {
118 if change.StripeSubscription != nil {
119 s.StripeSubscription = *change.StripeSubscription
121 if change.Plan != nil {
122 s.Plan = *change.Plan
124 if change.Status != nil {
125 s.Status = *change.Status
127 if change.Canceling != nil {
128 s.Canceling = *change.Canceling
130 if change.TrialStart != nil {
131 s.TrialStart = *change.TrialStart
133 if change.TrialEnd != nil {
134 s.TrialEnd = *change.TrialEnd
136 if change.PeriodStart != nil {
137 s.PeriodStart = *change.PeriodStart
139 if change.PeriodEnd != nil {
140 s.PeriodEnd = *change.PeriodEnd
142 if change.CanceledAt != nil {
143 s.CanceledAt = *change.CanceledAt
145 if change.LastFailedCharge != nil {
146 s.LastFailedCharge = *change.LastFailedCharge
148 if change.LastNotified != nil {
149 s.LastNotified = *change.LastNotified
151 if change.FailedChargeAttempts != nil {
152 s.FailedChargeAttempts = *change.FailedChargeAttempts
156 type SubscriptionRequest struct {
157 UserID uuid.ID `json:"user_id"`
158 Email string `json:"email"`
159 StripeToken string `json:"stripe_token"`
160 Plan string `json:"plan"`
163 func (req SubscriptionRequest) Validate(user uuid.ID, admin bool) []api.RequestError {
164 var errors []api.RequestError
165 if req.UserID == nil {
166 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/user_id"})
167 } else if !req.UserID.Equal(user) && !admin {
168 errors = append(errors, api.RequestError{Slug: api.RequestErrAccessDenied, Field: "/user_id"})
171 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/email"})
173 if req.StripeToken == "" {
174 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/stripe_token"})
177 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/plan"})
179 var acceptablePlan bool
180 for _, p := range planOptions {
182 acceptablePlan = true
187 errors = append(errors, api.RequestError{Slug: api.RequestErrInvalidValue, Field: "/plan"})
193 func SubscriptionFromRequest(req SubscriptionRequest) Subscription {
196 StripeSubscription: req.StripeToken,
199 TrialStart: time.Now(),
203 // SubscriptionStats represents a set of statistics about our Subscription
204 // data that will be exposed to the Prometheus scraper.
205 type SubscriptionStats struct {
209 Plans map[string]int64
210 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
211 // Because of this, we can only report stats that will be identical across nodes, e.g. stats
212 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
213 // In the future, we'll need per-node metrics. For now, we'll make do.
215 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set:
216 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port,
217 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local.
218 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local.
219 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and
220 // contains the port number and a CNAME of the pod with the format auto-generated-name.my-svc.my-namespace.svc.cluster.local
221 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment
225 type SubscriptionStore interface {
227 CreateSubscription(sub Subscription) error
228 UpdateSubscription(id uuid.ID, change SubscriptionChange) error
229 DeleteSubscription(id uuid.ID) error
230 GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
231 GetSubscriptionStats() (SubscriptionStats, error)