Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,23 @@ Feature flags carry targeting rules and variants, so `feature-flags create` and
kaiten licenses entitlements associate team-v1 seats --number 25
kaiten licenses entitlements associate team-v1 sso --boolean=true
kaiten licenses entitlements associate team-v1 limits --object-payload '{"max": 10}'

# A soft limit: 1000 orders a month, refused past 1200
kaiten entitlements create --name "Monthly orders" --slug monthly-orders --type NUMBER \
--aggregation-method SUM --reset-period MONTH --warning-threshold-percent 80
kaiten licenses entitlements associate team-v1 monthly-orders --number 1000 --overage-percent 20

# Change the value; the grant keeps its 20 % allowance
kaiten licenses entitlements update team-v1 monthly-orders --number 1500
```

The API replaces an entitlement or a grant with what it is sent, resetting what the request
leaves out. So `entitlements update` and `licenses entitlements update` read the resource
first when given inline flags, and send back everything the flags do not change: a
periodic entitlement keeps its window, which the API refuses to see dropped, and a grant
keeps its allowance unless `--overage-percent` says otherwise. `--file` and `--payload` are
sent as written.

### Usage metering

```shell
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/kaitencloud/cli
go 1.25.0

require (
github.com/kaitencloud/sdk-go v0.0.1
github.com/kaitencloud/sdk-go v0.1.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
gopkg.in/yaml.v3 v3.0.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kaitencloud/sdk-go v0.0.1 h1:WfR1qFNG++WkcGu55pOcT1nIt520VPMT0a81aOSj03M=
github.com/kaitencloud/sdk-go v0.0.1/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM=
github.com/kaitencloud/sdk-go v0.1.0 h1:r75JTSnJAxrzGsMRTTt2qxJ3wWpKrAgV5wLZ2CnZycU=
github.com/kaitencloud/sdk-go v0.1.0/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM=
github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g=
github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
183 changes: 168 additions & 15 deletions internal/cmd/entitlements.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,52 @@ func newEntitlementsUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <entitlement-slug>",
Short: "Update an entitlement from --file, --payload, or inline flags",
Args: cobra.ExactArgs(1),
Long: "Update an entitlement. The API replaces an entitlement with what it is sent: a field\n" +
"left out is reset, and a periodic entitlement's reset period and anchor have to be sent\n" +
"back as they are. Inline flags change only the fields they name: the command reads the\n" +
"entitlement first and sends every other field back unchanged. --file and --payload are\n" +
"sent as written, so they carry every field the entitlement keeps.",
Args: cobra.ExactArgs(1),
Example: ` kaiten entitlements update monthly-orders --warning-threshold-percent 80
kaiten entitlements update menu-items --description "Dishes on the menu" --display-order 1
kaiten entitlements update locations --icon ""`,
RunE: func(cmd *cobra.Command, args []string) error {
inputValue, err := resolveInput(file, payload, inline.changed(cmd), inline.build(cmd))
ctx, cancel := commandContext(cmd)
defer cancel()

// The client is built on first use, so that a usage error -- no input
// source, or two -- is reported before a configuration one.
var client *sdk.Client
connect := func() (*sdk.Client, error) {
if client == nil {
built, _, err := newClient(cmd)
if err != nil {
return nil, err
}
client = built
}
return client, nil
}

inputValue, err := resolveInput(file, payload, inline.changed(cmd), func() (sdk.EntitlementInput, error) {
connected, err := connect()
if err != nil {
return sdk.EntitlementInput{}, err
}
current, err := connected.Entitlements.Get(ctx, args[0])
if err != nil {
return sdk.EntitlementInput{}, err
}
return inline.applyTo(cmd, current), nil
})
if err != nil {
return err
}
client, _, err := newClient(cmd)
connected, err := connect()
if err != nil {
return err
}
ctx, cancel := commandContext(cmd)
defer cancel()
if err := client.Entitlements.Update(ctx, args[0], inputValue); err != nil {
if err := connected.Entitlements.Update(ctx, args[0], inputValue); err != nil {
return err
}
return writeMessage(cmd, "Entitlement updated")
Expand Down Expand Up @@ -162,12 +195,31 @@ func newEntitlementsDeleteCommand() *cobra.Command {
// entitlementInputFlags is the inline form of an entitlement payload, shared by create and
// update. The type flag is stored as kind because type is a Go keyword.
type entitlementInputFlags struct {
name string
description string
kind string
aggregationMethod string
groupSlugs []string
slug string
name string
description string
kind string
aggregationMethod string
groupSlugs []string
slug string
resetPeriod string
resetAnchor string
warningThresholdPercent int32
icon string
unitSingular string
unitPlural string
saleUnitSingular string
saleUnitPlural string
saleUnitFactor float64
userFacing bool
displayOrder int32
}

// optionalFlags are the inline flags of the optional fields, the ones build and applyTo both
// set only when the invocation names them.
var optionalFlags = []string{
"reset-period", "reset-anchor", "warning-threshold-percent",
"icon", "unit-singular", "unit-plural", "sale-unit-singular", "sale-unit-plural", "sale-unit-factor",
"user-facing", "display-order",
}

func (f *entitlementInputFlags) register(cmd *cobra.Command) {
Expand All @@ -176,6 +228,20 @@ func (f *entitlementInputFlags) register(cmd *cobra.Command) {
cmd.Flags().StringVar(&f.kind, "type", "", "Entitlement type")
cmd.Flags().StringVar(&f.aggregationMethod, "aggregation-method", "", "Entitlement aggregation method")
cmd.Flags().StringArrayVar(&f.groupSlugs, "group-slug", nil, "Entitlement group slug, repeat for multiple values")
cmd.Flags().StringVar(&f.resetPeriod, "reset-period", "",
"How often a NUMBER entitlement's usage resets: HOUR, DAY, WEEK, MONTH or YEAR (set once, never changed)")
cmd.Flags().StringVar(&f.resetAnchor, "reset-anchor", "",
"What the reset windows align on: CALENDAR (the default) or LICENSE_START (set once, never changed)")
cmd.Flags().Int32Var(&f.warningThresholdPercent, "warning-threshold-percent", 0,
"Share of a grant's value, 0 to 100, at which usage raises an early warning; 0 disables it")
cmd.Flags().StringVar(&f.icon, "icon", "", `Icon token such as "lucide:rocket"; an empty value removes it`)
cmd.Flags().StringVar(&f.unitSingular, "unit-singular", "", "Base unit label, singular; an empty value removes it")
cmd.Flags().StringVar(&f.unitPlural, "unit-plural", "", "Base unit label, plural; an empty value removes it")
cmd.Flags().StringVar(&f.saleUnitSingular, "sale-unit-singular", "", "Sale unit label, singular; an empty value removes it")
cmd.Flags().StringVar(&f.saleUnitPlural, "sale-unit-plural", "", "Sale unit label, plural; an empty value removes it")
cmd.Flags().Float64Var(&f.saleUnitFactor, "sale-unit-factor", 0, "Base units in one sale unit")
cmd.Flags().BoolVar(&f.userFacing, "user-facing", false, "Show the entitlement in customer-facing components")
cmd.Flags().Int32Var(&f.displayOrder, "display-order", 0, "Sort order in customer-facing components, ascending")
}

// registerSlug adds --slug to create only. The API never renames an entitlement:
Expand All @@ -186,7 +252,8 @@ func (f *entitlementInputFlags) registerSlug(cmd *cobra.Command) {
}

func (f *entitlementInputFlags) changed(cmd *cobra.Command) bool {
return anyFlagChanged(cmd, "name", "description", "type", "aggregation-method", "group-slug", "slug")
return anyFlagChanged(cmd, append([]string{"name", "description", "type", "aggregation-method", "group-slug", "slug"},
optionalFlags...)...)
}

func (f *entitlementInputFlags) build(cmd *cobra.Command) func() (sdk.EntitlementInput, error) {
Expand All @@ -195,13 +262,99 @@ func (f *entitlementInputFlags) build(cmd *cobra.Command) func() (sdk.Entitlemen
return sdk.EntitlementInput{}, err
}

return sdk.EntitlementInput{
input := sdk.EntitlementInput{
Name: f.name,
Description: optionalString(cmd, "description", f.description),
Type: optionalEnum[sdk.EntitlementType](cmd, "type", f.kind),
AggregationMethod: optionalEnum[sdk.EntitlementAggregationMethod](cmd, "aggregation-method", f.aggregationMethod),
GroupSlugs: f.groupSlugs,
Slug: optionalString(cmd, "slug", f.slug),
}, nil
}
f.setOptional(cmd, &input)
return input, nil
}
}

// applyTo is the update counterpart of build: the entitlement as it stands, with only the
// fields the invocation names changed. The API replaces an entitlement with what it is sent,
// resetting a field left out and refusing an update that drops a periodic entitlement's
// window, so every field the operator did not mention is sent back as it was read. Groups
// are the exception: left out, they stay as they are.
func (f *entitlementInputFlags) applyTo(cmd *cobra.Command, current sdk.Entitlement) sdk.EntitlementInput {
input := sdk.EntitlementInput{
Name: current.Name,
Description: current.Description,
Type: current.Type,
AggregationMethod: current.AggregationMethod,
ResetPeriod: current.ResetPeriod,
ResetAnchor: current.ResetAnchor,
WarningThresholdPercent: current.WarningThresholdPercent,
Icon: current.Icon,
UnitSingular: current.UnitSingular,
UnitPlural: current.UnitPlural,
SaleUnitSingular: current.SaleUnitSingular,
SaleUnitPlural: current.SaleUnitPlural,
SaleUnitFactor: current.SaleUnitFactor,
UserFacing: current.UserFacing,
DisplayOrder: current.DisplayOrder,
}

if flagChanged(cmd, "name") {
input.Name = f.name
}
if flagChanged(cmd, "description") {
input.Description = &f.description
}
if flagChanged(cmd, "type") {
input.Type = optionalEnum[sdk.EntitlementType](cmd, "type", f.kind)
}
if flagChanged(cmd, "aggregation-method") {
input.AggregationMethod = optionalEnum[sdk.EntitlementAggregationMethod](cmd, "aggregation-method", f.aggregationMethod)
}
if flagChanged(cmd, "group-slug") {
input.GroupSlugs = f.groupSlugs
}
f.setOptional(cmd, &input)

return input
}

// setOptional writes onto input the optional fields the invocation names, for create and
// update alike. An empty label or icon removes it: the field is left out of the request,
// which is how the API is told it has no value.
func (f *entitlementInputFlags) setOptional(cmd *cobra.Command, input *sdk.EntitlementInput) {
label := func(flag, value string, field **string) {
if !flagChanged(cmd, flag) {
return
}
if value == "" {
*field = nil
return
}
*field = &value
}
label("icon", f.icon, &input.Icon)
label("unit-singular", f.unitSingular, &input.UnitSingular)
label("unit-plural", f.unitPlural, &input.UnitPlural)
label("sale-unit-singular", f.saleUnitSingular, &input.SaleUnitSingular)
label("sale-unit-plural", f.saleUnitPlural, &input.SaleUnitPlural)

if flagChanged(cmd, "reset-period") {
input.ResetPeriod = optionalEnum[sdk.EntitlementResetPeriod](cmd, "reset-period", f.resetPeriod)
}
if flagChanged(cmd, "reset-anchor") {
input.ResetAnchor = optionalEnum[sdk.EntitlementResetAnchor](cmd, "reset-anchor", f.resetAnchor)
}
if flagChanged(cmd, "warning-threshold-percent") {
input.WarningThresholdPercent = &f.warningThresholdPercent
}
if flagChanged(cmd, "sale-unit-factor") {
input.SaleUnitFactor = &f.saleUnitFactor
}
if flagChanged(cmd, "user-facing") {
input.UserFacing = &f.userFacing
}
if flagChanged(cmd, "display-order") {
input.DisplayOrder = &f.displayOrder
}
}
Loading
Loading