mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
feat: allow templates to specify max_ttl or autostop_requirement (#10920)
This commit is contained in:
@ -492,12 +492,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
codersdk.FeatureExternalTokenEncryption: len(api.ExternalTokenEncryption) > 0,
|
||||
codersdk.FeatureExternalProvisionerDaemons: true,
|
||||
codersdk.FeatureAdvancedTemplateScheduling: true,
|
||||
// FeatureTemplateAutostopRequirement depends on
|
||||
// FeatureAdvancedTemplateScheduling.
|
||||
codersdk.FeatureTemplateAutostopRequirement: api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) && api.DefaultQuietHoursSchedule != "",
|
||||
codersdk.FeatureWorkspaceProxy: true,
|
||||
codersdk.FeatureUserRoleManagement: true,
|
||||
codersdk.FeatureAccessControl: true,
|
||||
codersdk.FeatureWorkspaceProxy: true,
|
||||
codersdk.FeatureUserRoleManagement: true,
|
||||
codersdk.FeatureAccessControl: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@ -516,18 +513,6 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if entitlements.Features[codersdk.FeatureTemplateAutostopRequirement].Enabled && !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled {
|
||||
api.entitlements.Errors = []string{
|
||||
`Your license is entitled to the feature "template autostop ` +
|
||||
`requirement" (and you have it enabled by setting the ` +
|
||||
"default quiet hours schedule), but you are not entitled to " +
|
||||
`the dependency feature "advanced template scheduling". ` +
|
||||
"Please contact support for a new license.",
|
||||
}
|
||||
api.Logger.Error(ctx, "license is entitled to template autostop requirement but not advanced template scheduling")
|
||||
return nil
|
||||
}
|
||||
|
||||
featureChanged := func(featureName codersdk.FeatureName) (initial, changed, enabled bool) {
|
||||
if api.entitlements.Features == nil {
|
||||
return true, false, entitlements.Features[featureName].Enabled
|
||||
@ -579,21 +564,11 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
templateStore := schedule.NewEnterpriseTemplateScheduleStore(api.AGPL.UserQuietHoursScheduleStore)
|
||||
templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore)
|
||||
api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface)
|
||||
} else {
|
||||
templateStore := agplschedule.NewAGPLTemplateScheduleStore()
|
||||
api.AGPL.TemplateScheduleStore.Store(&templateStore)
|
||||
}
|
||||
}
|
||||
|
||||
if initial, changed, enabled := featureChanged(codersdk.FeatureTemplateAutostopRequirement); shouldUpdate(initial, changed, enabled) {
|
||||
if enabled {
|
||||
templateStore := *(api.AGPL.TemplateScheduleStore.Load())
|
||||
enterpriseTemplateStore, ok := templateStore.(*schedule.EnterpriseTemplateScheduleStore)
|
||||
if !ok {
|
||||
api.Logger.Error(ctx, "unable to set up enterprise template schedule store, template autostop requirements will not be applied to workspace builds")
|
||||
if api.DefaultQuietHoursSchedule == "" {
|
||||
api.Logger.Warn(ctx, "template autostop requirement will default to UTC midnight as the default user quiet hours schedule. Set a custom default quiet hours schedule using CODER_QUIET_HOURS_DEFAULT_SCHEDULE to avoid this warning")
|
||||
api.DefaultQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *"
|
||||
}
|
||||
enterpriseTemplateStore.UseAutostopRequirement.Store(true)
|
||||
|
||||
quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "unable to set up enterprise user quiet hours schedule store, template autostop requirements will not be applied to workspace builds", slog.Error(err))
|
||||
@ -601,16 +576,8 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore)
|
||||
}
|
||||
} else {
|
||||
if api.DefaultQuietHoursSchedule != "" {
|
||||
api.Logger.Warn(ctx, "template autostop requirements are not enabled (due to setting default quiet hours schedule) as your license is not entitled to this feature")
|
||||
}
|
||||
|
||||
templateStore := *(api.AGPL.TemplateScheduleStore.Load())
|
||||
enterpriseTemplateStore, ok := templateStore.(*schedule.EnterpriseTemplateScheduleStore)
|
||||
if ok {
|
||||
enterpriseTemplateStore.UseAutostopRequirement.Store(false)
|
||||
}
|
||||
|
||||
templateStore := agplschedule.NewAGPLTemplateScheduleStore()
|
||||
api.AGPL.TemplateScheduleStore.Store(&templateStore)
|
||||
quietHoursStore := agplschedule.NewAGPLUserQuietHoursScheduleStore()
|
||||
api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore)
|
||||
}
|
||||
|
@ -47,19 +47,18 @@ func init() {
|
||||
|
||||
type Options struct {
|
||||
*coderdtest.Options
|
||||
AuditLogging bool
|
||||
BrowserOnly bool
|
||||
EntitlementsUpdateInterval time.Duration
|
||||
SCIMAPIKey []byte
|
||||
UserWorkspaceQuota int
|
||||
ProxyHealthInterval time.Duration
|
||||
LicenseOptions *LicenseOptions
|
||||
NoDefaultQuietHoursSchedule bool
|
||||
DontAddLicense bool
|
||||
DontAddFirstUser bool
|
||||
ReplicaSyncUpdateInterval time.Duration
|
||||
ExternalTokenEncryption []dbcrypt.Cipher
|
||||
ProvisionerDaemonPSK string
|
||||
AuditLogging bool
|
||||
BrowserOnly bool
|
||||
EntitlementsUpdateInterval time.Duration
|
||||
SCIMAPIKey []byte
|
||||
UserWorkspaceQuota int
|
||||
ProxyHealthInterval time.Duration
|
||||
LicenseOptions *LicenseOptions
|
||||
DontAddLicense bool
|
||||
DontAddFirstUser bool
|
||||
ReplicaSyncUpdateInterval time.Duration
|
||||
ExternalTokenEncryption []dbcrypt.Cipher
|
||||
ProvisionerDaemonPSK string
|
||||
}
|
||||
|
||||
// New constructs a codersdk client connected to an in-memory Enterprise API instance.
|
||||
@ -86,10 +85,6 @@ func NewWithAPI(t *testing.T, options *Options) (
|
||||
}
|
||||
require.False(t, options.DontAddFirstUser && !options.DontAddLicense, "DontAddFirstUser requires DontAddLicense")
|
||||
setHandler, cancelFunc, serverURL, oop := coderdtest.NewOptions(t, options.Options)
|
||||
if !options.NoDefaultQuietHoursSchedule && oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value() == "" {
|
||||
err := oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Set("0 0 * * *")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
|
||||
RBAC: true,
|
||||
AuditLogging: options.AuditLogging,
|
||||
|
@ -2,6 +2,7 @@ package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@ -21,12 +22,6 @@ import (
|
||||
// EnterpriseTemplateScheduleStore provides an agpl.TemplateScheduleStore that
|
||||
// has all fields implemented for enterprise customers.
|
||||
type EnterpriseTemplateScheduleStore struct {
|
||||
// UseAutostopRequirement decides whether the AutostopRequirement field
|
||||
// should be used instead of the MaxTTL field for determining the max
|
||||
// deadline of a workspace build. This value is determined by a feature
|
||||
// flag, licensing, and whether a default user quiet hours schedule is set.
|
||||
UseAutostopRequirement atomic.Bool
|
||||
|
||||
// UserQuietHoursScheduleStore is used when recalculating build deadlines on
|
||||
// update.
|
||||
UserQuietHoursScheduleStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore]
|
||||
@ -51,7 +46,7 @@ func (s *EnterpriseTemplateScheduleStore) now() time.Time {
|
||||
}
|
||||
|
||||
// Get implements agpl.TemplateScheduleStore.
|
||||
func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) {
|
||||
func (*EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
@ -77,11 +72,11 @@ func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.S
|
||||
}
|
||||
|
||||
return agpl.TemplateScheduleOptions{
|
||||
UserAutostartEnabled: tpl.AllowUserAutostart,
|
||||
UserAutostopEnabled: tpl.AllowUserAutostop,
|
||||
DefaultTTL: time.Duration(tpl.DefaultTTL),
|
||||
MaxTTL: time.Duration(tpl.MaxTTL),
|
||||
UseAutostopRequirement: s.UseAutostopRequirement.Load(),
|
||||
UserAutostartEnabled: tpl.AllowUserAutostart,
|
||||
UserAutostopEnabled: tpl.AllowUserAutostop,
|
||||
DefaultTTL: time.Duration(tpl.DefaultTTL),
|
||||
MaxTTL: time.Duration(tpl.MaxTTL),
|
||||
UseMaxTTL: tpl.UseMaxTtl,
|
||||
AutostopRequirement: agpl.TemplateAutostopRequirement{
|
||||
DaysOfWeek: uint8(tpl.AutostopRequirementDaysOfWeek),
|
||||
Weeks: tpl.AutostopRequirementWeeks,
|
||||
@ -108,6 +103,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
|
||||
}
|
||||
|
||||
if int64(opts.DefaultTTL) == tpl.DefaultTTL &&
|
||||
opts.UseMaxTTL != tpl.UseMaxTtl &&
|
||||
int64(opts.MaxTTL) == tpl.MaxTTL &&
|
||||
int16(opts.AutostopRequirement.DaysOfWeek) == tpl.AutostopRequirementDaysOfWeek &&
|
||||
opts.AutostartRequirement.DaysOfWeek == tpl.AutostartAllowedDays() &&
|
||||
@ -142,6 +138,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
|
||||
AllowUserAutostart: opts.UserAutostartEnabled,
|
||||
AllowUserAutostop: opts.UserAutostopEnabled,
|
||||
DefaultTTL: int64(opts.DefaultTTL),
|
||||
UseMaxTtl: opts.UseMaxTTL,
|
||||
MaxTTL: int64(opts.MaxTTL),
|
||||
AutostopRequirementDaysOfWeek: int16(opts.AutostopRequirement.DaysOfWeek),
|
||||
AutostopRequirementWeeks: opts.AutostopRequirement.Weeks,
|
||||
@ -184,7 +181,6 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: update all workspace max_deadlines to be within new bounds
|
||||
template, err = tx.GetTemplateByID(ctx, tpl.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get updated template schedule: %w", err)
|
||||
@ -192,11 +188,9 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
|
||||
|
||||
// Recalculate max_deadline and deadline for all running workspace
|
||||
// builds on this template.
|
||||
if s.UseAutostopRequirement.Load() {
|
||||
err = s.updateWorkspaceBuilds(ctx, tx, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace builds: %w", err)
|
||||
}
|
||||
err = s.updateWorkspaceBuilds(ctx, tx, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace builds: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -218,6 +212,9 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuilds(ctx context.Cont
|
||||
ctx = dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
builds, err := db.GetActiveWorkspaceBuildsByTemplateID(ctx, template.ID)
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get active workspace builds: %w", err)
|
||||
}
|
||||
|
@ -214,16 +214,15 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) {
|
||||
|
||||
// Set the template policy.
|
||||
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr)
|
||||
templateScheduleStore.UseAutostopRequirement.Store(true)
|
||||
templateScheduleStore.TimeNowFn = func() time.Time {
|
||||
return c.now
|
||||
}
|
||||
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
||||
UserAutostartEnabled: false,
|
||||
UserAutostopEnabled: false,
|
||||
DefaultTTL: 0,
|
||||
MaxTTL: 0,
|
||||
UseAutostopRequirement: true,
|
||||
UserAutostartEnabled: false,
|
||||
UserAutostopEnabled: false,
|
||||
DefaultTTL: 0,
|
||||
MaxTTL: 0,
|
||||
UseMaxTTL: false,
|
||||
AutostopRequirement: agplschedule.TemplateAutostopRequirement{
|
||||
// Every day
|
||||
DaysOfWeek: 0b01111111,
|
||||
@ -498,16 +497,15 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
|
||||
|
||||
// Set the template policy.
|
||||
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr)
|
||||
templateScheduleStore.UseAutostopRequirement.Store(true)
|
||||
templateScheduleStore.TimeNowFn = func() time.Time {
|
||||
return now
|
||||
}
|
||||
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
||||
UserAutostartEnabled: false,
|
||||
UserAutostopEnabled: false,
|
||||
DefaultTTL: 0,
|
||||
MaxTTL: 0,
|
||||
UseAutostopRequirement: true,
|
||||
UserAutostartEnabled: false,
|
||||
UserAutostopEnabled: false,
|
||||
DefaultTTL: 0,
|
||||
MaxTTL: 0,
|
||||
UseMaxTTL: false,
|
||||
AutostopRequirement: agplschedule.TemplateAutostopRequirement{
|
||||
// Every day
|
||||
DaysOfWeek: 0b01111111,
|
||||
|
@ -13,26 +13,20 @@ import (
|
||||
|
||||
func (api *API) autostopRequirementEnabledMW(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
// The experiment must be enabled.
|
||||
if !api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) {
|
||||
httpapi.RouteNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
// Entitlement must be enabled.
|
||||
api.entitlementsMu.RLock()
|
||||
entitled := api.entitlements.Features[codersdk.FeatureTemplateAutostopRequirement].Entitlement != codersdk.EntitlementNotEntitled
|
||||
enabled := api.entitlements.Features[codersdk.FeatureTemplateAutostopRequirement].Enabled
|
||||
entitled := api.entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Entitlement != codersdk.EntitlementNotEntitled
|
||||
enabled := api.entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled
|
||||
api.entitlementsMu.RUnlock()
|
||||
if !entitled {
|
||||
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Template autostop requirement is an Enterprise feature. Contact sales!",
|
||||
Message: "Advanced template scheduling (and user quiet hours schedule) is an Enterprise feature. Contact sales!",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !enabled {
|
||||
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Template autostop requirement feature is not enabled. Please specify a default user quiet hours schedule to use this feature.",
|
||||
Message: "Advanced template scheduling (and user quiet hours schedule) is not enabled.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -18,6 +18,26 @@ import (
|
||||
func TestUserQuietHours(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("DefaultToUTC", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
client, user := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := client.UserQuietHoursSchedule(ctx, user.ID.String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "UTC", res.Timezone)
|
||||
require.Equal(t, "00:00", res.Time)
|
||||
require.Equal(t, "CRON_TZ=UTC 0 0 * * *", res.RawSchedule)
|
||||
})
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@ -35,7 +55,6 @@ func TestUserQuietHours(t *testing.T) {
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.UserQuietHoursSchedule.DefaultSchedule.Set(defaultQuietHoursSchedule)
|
||||
dv.Experiments.Set(string(codersdk.ExperimentTemplateAutostopRequirement))
|
||||
|
||||
adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
@ -43,8 +62,7 @@ func TestUserQuietHours(t *testing.T) {
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
codersdk.FeatureTemplateAutostopRequirement: 1,
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -137,7 +155,6 @@ func TestUserQuietHours(t *testing.T) {
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *")
|
||||
dv.Experiments.Set(string(codersdk.ExperimentTemplateAutostopRequirement))
|
||||
|
||||
client, user := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
@ -145,9 +162,8 @@ func TestUserQuietHours(t *testing.T) {
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
// Not entitled.
|
||||
// codersdk.FeatureTemplateAutostopRequirement: 1,
|
||||
// codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -160,61 +176,4 @@ func TestUserQuietHours(t *testing.T) {
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("NotEnabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.UserQuietHoursSchedule.DefaultSchedule.Set("")
|
||||
dv.Experiments.Set(string(codersdk.ExperimentTemplateAutostopRequirement))
|
||||
|
||||
client, user := coderdenttest.New(t, &coderdenttest.Options{
|
||||
NoDefaultQuietHoursSchedule: true,
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
codersdk.FeatureTemplateAutostopRequirement: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
//nolint:gocritic // We want to test the lack of feature, not RBAC.
|
||||
_, err := client.UserQuietHoursSchedule(ctx, user.UserID.String())
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("NoFeatureFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *")
|
||||
dv.UserQuietHoursSchedule.DefaultSchedule.Set("")
|
||||
|
||||
client, user := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
codersdk.FeatureTemplateAutostopRequirement: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
//nolint:gocritic // We want to test the lack of feature, not RBAC.
|
||||
_, err := client.UserQuietHoursSchedule(ctx, user.UserID.String())
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user