feat(license): two deployments times three tiers, and two new limits
Plans are keyed on (deployment, tier) rather than tier alone, which is what ends Free being cloud-only by construction - there is a self-hosted Free plan now, so the one-per-account rule has to be enforced per deployment instead of falling out of the plan table. tier self_hosted becomes a legacy value no new licence carries. NormaliseTier maps it to self-hosted Professional, which is what it always granted, so blobs we cannot re-sign keep working. Limits.FillUnset exists because a licence signed before a field existed decodes it as 0, and 0 would read as no monitors and an audit log trimmed to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -68,7 +68,8 @@ func issue(args []string) {
|
||||
instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)")
|
||||
instanceName := fs.String("instance-name", "", "display name")
|
||||
accountID := fs.String("account-id", "", "admin-side account id, optional")
|
||||
tier := fs.String("tier", "", "free | professional | self_hosted (required)")
|
||||
tier := fs.String("tier", "", "free | professional | enterprise (required)")
|
||||
deployment := fs.String("deployment", "cloud", "cloud | self_hosted")
|
||||
term := fs.String("term", "1y", "1m or 1y")
|
||||
expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term")
|
||||
out := fs.String("out", "", "write the blob to this file instead of stdout")
|
||||
@@ -78,9 +79,9 @@ func issue(args []string) {
|
||||
fatal("--instance-id and --tier are required")
|
||||
}
|
||||
|
||||
plan, ok := license.PlanFor(*tier)
|
||||
plan, ok := license.PlanFor(*deployment, *tier)
|
||||
if !ok {
|
||||
fatal("unknown tier %q", *tier)
|
||||
fatal("no plan for deployment %q tier %q", *deployment, *tier)
|
||||
}
|
||||
|
||||
key := os.Getenv("LICENSE_SIGNING_KEY")
|
||||
@@ -107,7 +108,7 @@ func issue(args []string) {
|
||||
|
||||
// Self Hosted is sold annually only, so the window in which a cancelled
|
||||
// licence keeps working is bounded at a year.
|
||||
if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" {
|
||||
if plan.Deployment == license.DeploymentSelfHosted && *term == "1m" && *expires == "" {
|
||||
fatal("self_hosted is annual only; use --term=1y or an explicit --expires")
|
||||
}
|
||||
|
||||
@@ -123,6 +124,7 @@ func issue(args []string) {
|
||||
InstanceName: name,
|
||||
Tier: plan.Tier,
|
||||
Deployment: plan.Deployment,
|
||||
SupportLevel: plan.SupportLevel,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: exp,
|
||||
Limits: plan.Limits,
|
||||
|
||||
@@ -16,7 +16,15 @@ import "time"
|
||||
const (
|
||||
TierFree = "free"
|
||||
TierProfessional = "professional"
|
||||
TierSelfHosted = "self_hosted"
|
||||
TierEnterprise = "enterprise"
|
||||
|
||||
// TierSelfHosted is LEGACY and no new licence carries it.
|
||||
//
|
||||
// It was a tier when self-hosting was a tier rather than a deployment. Blobs
|
||||
// already signed with it exist and cannot be rewritten, so it stays a
|
||||
// recognised value that NormaliseTier maps forward. Never put it in a plan
|
||||
// row and never offer it in a UI.
|
||||
TierSelfHosted = "self_hosted"
|
||||
|
||||
DeploymentCloud = "cloud"
|
||||
DeploymentSelfHosted = "self_hosted"
|
||||
@@ -25,14 +33,59 @@ const (
|
||||
FeatureOIDC = "oidc" // per-instance single sign-on
|
||||
)
|
||||
|
||||
// Support levels. Carried for display and enforced by nothing — there is no code
|
||||
// path anywhere that branches on these, and there must not be one. They are here
|
||||
// so an air-gapped install can tell its operator who to call without reaching
|
||||
// Vantage HQ.
|
||||
const (
|
||||
SupportCommunity = "community"
|
||||
SupportEmail24x5 = "email_24_5"
|
||||
SupportEmailCall24x7 = "email_call_24_7"
|
||||
)
|
||||
|
||||
// Unlimited is the sentinel for "no cap" in every Limits field.
|
||||
const Unlimited = -1
|
||||
|
||||
// Limits are the countable caps a licence grants.
|
||||
//
|
||||
// Every field is a plain int with Unlimited as the sentinel. AuditRetentionDays
|
||||
// is the odd one out: it bounds a duration rather than a count, and Unlimited
|
||||
// there means "never trim" rather than "no cap".
|
||||
type Limits struct {
|
||||
MaxServers int `json:"max_servers"`
|
||||
MaxSecretGroups int `json:"max_secret_groups"`
|
||||
MaxChannels int `json:"max_channels"`
|
||||
MaxServers int `json:"max_servers"`
|
||||
MaxMonitors int `json:"max_monitors"`
|
||||
MaxSecretGroups int `json:"max_secret_groups"`
|
||||
MaxChannels int `json:"max_channels"`
|
||||
AuditRetentionDays int `json:"audit_retention_days"`
|
||||
}
|
||||
|
||||
// FillUnset replaces any zero field with the same field from base.
|
||||
//
|
||||
// This exists for one reason: a licence signed before a field existed decodes it
|
||||
// as 0, and 0 would read as the most restrictive possible value — no monitors,
|
||||
// and an audit log trimmed to nothing. A blob we cannot re-sign must not be
|
||||
// allowed to mean that.
|
||||
//
|
||||
// The cost is that 0 stops being expressible as a real allowance. No plan grants
|
||||
// zero of anything, so nothing is lost today; a plan that genuinely means zero
|
||||
// must use a negative-free sentinel of its own rather than reintroducing 0 here.
|
||||
func (l Limits) FillUnset(base Limits) Limits {
|
||||
if l.MaxServers == 0 {
|
||||
l.MaxServers = base.MaxServers
|
||||
}
|
||||
if l.MaxMonitors == 0 {
|
||||
l.MaxMonitors = base.MaxMonitors
|
||||
}
|
||||
if l.MaxSecretGroups == 0 {
|
||||
l.MaxSecretGroups = base.MaxSecretGroups
|
||||
}
|
||||
if l.MaxChannels == 0 {
|
||||
l.MaxChannels = base.MaxChannels
|
||||
}
|
||||
if l.AuditRetentionDays == 0 {
|
||||
l.AuditRetentionDays = base.AuditRetentionDays
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// License is the signed payload.
|
||||
@@ -47,6 +100,7 @@ type License struct {
|
||||
InstanceName string `json:"instance_name"` // display only
|
||||
Tier string `json:"tier"`
|
||||
Deployment string `json:"deployment"`
|
||||
SupportLevel string `json:"support_level,omitempty"` // display only
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Limits Limits `json:"limits"`
|
||||
|
||||
+112
-32
@@ -1,49 +1,129 @@
|
||||
package license
|
||||
|
||||
// Plan is the contents of a tier at issue time.
|
||||
// Plan is the contents of one (deployment, tier) pair at issue time.
|
||||
//
|
||||
// This table is the seed. Once the admin service exists (spec 3) it owns the
|
||||
// authoritative copy in its `plans` collection, and every issued licence
|
||||
// snapshots the plan it was cut from — so editing a plan never rewrites an
|
||||
// existing licence, the same rule as workflow_runs.steps_snapshot.
|
||||
// This table is the seed. The admin service owns the authoritative copy in its
|
||||
// `plans` collection, and every issued licence snapshots the plan it was cut
|
||||
// from — so editing a plan never rewrites an existing licence, the same rule as
|
||||
// workflow_runs.steps_snapshot.
|
||||
//
|
||||
// lkctl uses this table to issue by hand until then.
|
||||
// Limits here are the BASE allowance: what the tier grants before anything is
|
||||
// bought. A metered dimension adds to it, which is why max_servers is a real
|
||||
// number at Professional and Enterprise rather than Unlimited.
|
||||
type Plan struct {
|
||||
Tier string
|
||||
Name string
|
||||
Deployment string
|
||||
Limits Limits
|
||||
Features []string
|
||||
Tier string
|
||||
Name string
|
||||
Deployment string
|
||||
SupportLevel string
|
||||
Limits Limits
|
||||
Features []string
|
||||
}
|
||||
|
||||
var plans = map[string]Plan{
|
||||
TierFree: {
|
||||
Tier: TierFree,
|
||||
Name: "Free",
|
||||
Deployment: DeploymentCloud, // cloud only, by construction
|
||||
Limits: Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1},
|
||||
// planKey is the composite the table is keyed on.
|
||||
//
|
||||
// Keying on tier alone was what made Free cloud-only by construction. There is
|
||||
// now a self-hosted Free plan, so that guarantee is gone and the Free limit is
|
||||
// enforced per account AND deployment instead. See licensing.checkFreeLimit.
|
||||
type planKey struct {
|
||||
Deployment string
|
||||
Tier string
|
||||
}
|
||||
|
||||
// baseFree, baseProfessional and baseEnterprise are shared by both deployments.
|
||||
//
|
||||
// The allowances are deliberately identical across cloud and self-hosted: what
|
||||
// differs between the two is the term on offer, not what you get. Duplicating
|
||||
// them per deployment would be four places to forget.
|
||||
var (
|
||||
baseFree = Limits{
|
||||
MaxServers: 3, MaxMonitors: 3, MaxSecretGroups: 1,
|
||||
MaxChannels: 1, AuditRetentionDays: 30,
|
||||
}
|
||||
baseProfessional = Limits{
|
||||
MaxServers: 3, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
|
||||
MaxChannels: Unlimited, AuditRetentionDays: 365,
|
||||
}
|
||||
baseEnterprise = Limits{
|
||||
MaxServers: 10, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
|
||||
MaxChannels: Unlimited, AuditRetentionDays: Unlimited,
|
||||
}
|
||||
)
|
||||
|
||||
var plans = map[planKey]Plan{
|
||||
planKey{DeploymentCloud, TierFree}: {
|
||||
Tier: TierFree, Name: "Free", Deployment: DeploymentCloud,
|
||||
SupportLevel: SupportCommunity, Limits: baseFree,
|
||||
// Empty rather than nil: nil marshals as JSON null, and this table is
|
||||
// the seed every plan and licence is cut from.
|
||||
Features: []string{},
|
||||
},
|
||||
TierProfessional: {
|
||||
Tier: TierProfessional,
|
||||
Name: "Professional",
|
||||
Deployment: DeploymentCloud,
|
||||
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
|
||||
Features: []string{FeatureConsole, FeatureOIDC},
|
||||
planKey{DeploymentCloud, TierProfessional}: {
|
||||
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentCloud,
|
||||
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
|
||||
// Console and SSO are opt-in per customer, so no tier bundles them. The
|
||||
// field stays because a future tier might.
|
||||
Features: []string{},
|
||||
},
|
||||
TierSelfHosted: {
|
||||
Tier: TierSelfHosted,
|
||||
Name: "Self Hosted",
|
||||
Deployment: DeploymentSelfHosted,
|
||||
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
|
||||
Features: []string{FeatureConsole, FeatureOIDC},
|
||||
planKey{DeploymentCloud, TierEnterprise}: {
|
||||
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentCloud,
|
||||
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
|
||||
Features: []string{},
|
||||
},
|
||||
planKey{DeploymentSelfHosted, TierFree}: {
|
||||
Tier: TierFree, Name: "Free", Deployment: DeploymentSelfHosted,
|
||||
SupportLevel: SupportCommunity, Limits: baseFree,
|
||||
Features: []string{},
|
||||
},
|
||||
planKey{DeploymentSelfHosted, TierProfessional}: {
|
||||
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentSelfHosted,
|
||||
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
|
||||
Features: []string{},
|
||||
},
|
||||
planKey{DeploymentSelfHosted, TierEnterprise}: {
|
||||
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentSelfHosted,
|
||||
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
|
||||
Features: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
// PlanFor returns the seed plan for a tier.
|
||||
func PlanFor(tier string) (Plan, bool) {
|
||||
p, ok := plans[tier]
|
||||
// PlanFor returns the seed plan for one deployment and tier.
|
||||
//
|
||||
// It normalises first, so a legacy self_hosted licence resolves to the plan that
|
||||
// replaced it rather than to nothing.
|
||||
func PlanFor(deployment, tier string) (Plan, bool) {
|
||||
deployment, tier = NormaliseTier(deployment, tier)
|
||||
p, ok := plans[planKey{deployment, tier}]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// NormaliseTier maps a legacy tier forward.
|
||||
//
|
||||
// tier "self_hosted" predates deployments being separate from tiers. Such a
|
||||
// licence granted what Professional now grants, on a self-hosted install, so it
|
||||
// maps to exactly that. Called by PlanFor and by anything reading a tier off an
|
||||
// already-signed payload.
|
||||
func NormaliseTier(deployment, tier string) (string, string) {
|
||||
if tier == TierSelfHosted {
|
||||
return DeploymentSelfHosted, TierProfessional
|
||||
}
|
||||
return deployment, tier
|
||||
}
|
||||
|
||||
// Tiers is the offer order, for any UI that lists them.
|
||||
func Tiers() []string { return []string{TierFree, TierProfessional, TierEnterprise} }
|
||||
|
||||
// Deployments is the offer order.
|
||||
func Deployments() []string { return []string{DeploymentCloud, DeploymentSelfHosted} }
|
||||
|
||||
// TermsFor reports which billing terms a deployment sells.
|
||||
//
|
||||
// Self-hosted is annual only, and the reason is in this package's doc comment: an
|
||||
// offline licence cannot be revoked, so the term length IS the revocation
|
||||
// window. A self-hosted monthly licence would renew that window twelve times a
|
||||
// year for no commercial gain.
|
||||
func TermsFor(deployment string) []string {
|
||||
if deployment == DeploymentSelfHosted {
|
||||
return []string{"annual"}
|
||||
}
|
||||
return []string{"monthly", "annual"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user