From 1c911284264a40a91834339d21f980a2ebda2b76 Mon Sep 17 00:00:00 2001 From: Daniel Reuter Date: Thu, 15 Oct 2026 12:39:16 +0200 Subject: [PATCH 1/3] feat: user list and user delete Lists the users of the tenant, with role, teams, what manages them and last login, and removes a user from the tenant. Both call the platform's new GET /api/users and DELETE /api/users/{username}; the client is regenerated from the dev spec, which has them. --- CHANGELOG.md | 7 + README.md | 6 +- api/platform.gen.go | 492 ++++++++++++++++++++++++++++++++++++- internal/cli/user.go | 26 +- internal/user/user.go | 86 +++++++ internal/user/user_test.go | 63 +++++ openapi/platform-api.json | 423 +++++++++++++++++++++++++------ 7 files changed, 1010 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fde0f76..65316e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- `user list` lists the users of the tenant, with their role, teams, what manages them + (SCIM, an LDAP sync, or Steadybit) and their last login; `--query` narrows it down. + `user delete` removes a user from the tenant. Both need an admin access token, and + the platform refuses to remove users that SCIM or an LDAP sync manages. + ## v6.2.3 - Built with Go 1.27.2, which fixes CVE-2026-78667, CVE-2026-78669 and CVE-2026-97031 diff --git a/README.md b/README.md index ddab399..8163daf 100644 --- a/README.md +++ b/README.md @@ -251,14 +251,16 @@ steadybit team environment add -k ADM --environment Global ```bash steadybit access-token create --name ci --type TEAM --team ADM --expires-at 2026-12-31 steadybit user invite --email jane@example.com --team ADM +steadybit user list --query jane +steadybit user delete --username steadybit killswitch status steadybit audit-log --from 2026-09-01 -t json steadybit license show steadybit report experiments-executed --group-by STATE --rollup MONTHLY ``` -Commands that cannot be undone, such as `killswitch activate`, `access-token delete` or -`team member set`, ask for confirmation on a terminal; `--yes` skips the question. +Commands that cannot be undone, such as `killswitch activate`, `access-token delete`, +`user delete` or `team member set`, ask for confirmation on a terminal; `--yes` skips the question. ### Targets and actions diff --git a/api/platform.gen.go b/api/platform.gen.go index c0eee8c..2978f75 100644 --- a/api/platform.gen.go +++ b/api/platform.gen.go @@ -45,6 +45,7 @@ func (e AccessTokenPrincipalALPrincipalType) Valid() bool { // Defines values for AccessTokenPrincipalALTokenType. const ( AccessTokenPrincipalALTokenTypeADMIN AccessTokenPrincipalALTokenType = "ADMIN" + AccessTokenPrincipalALTokenTypeSCIM AccessTokenPrincipalALTokenType = "SCIM" AccessTokenPrincipalALTokenTypeTEAM AccessTokenPrincipalALTokenType = "TEAM" AccessTokenPrincipalALTokenTypeWILDCARD AccessTokenPrincipalALTokenType = "WILDCARD" ) @@ -54,6 +55,8 @@ func (e AccessTokenPrincipalALTokenType) Valid() bool { switch e { case AccessTokenPrincipalALTokenTypeADMIN: return true + case AccessTokenPrincipalALTokenTypeSCIM: + return true case AccessTokenPrincipalALTokenTypeTEAM: return true case AccessTokenPrincipalALTokenTypeWILDCARD: @@ -539,6 +542,7 @@ const ( MemberAOManagedByLDAP MemberAOManagedBy = "LDAP" MemberAOManagedByMANUAL MemberAOManagedBy = "MANUAL" MemberAOManagedByOIDC MemberAOManagedBy = "OIDC" + MemberAOManagedBySCIM MemberAOManagedBy = "SCIM" ) // Valid indicates whether the value is a known member of the MemberAOManagedBy enum. @@ -550,6 +554,8 @@ func (e MemberAOManagedBy) Valid() bool { return true case MemberAOManagedByOIDC: return true + case MemberAOManagedBySCIM: + return true default: return false } @@ -977,6 +983,7 @@ const ( TeamAOManagedByLDAP TeamAOManagedBy = "LDAP" TeamAOManagedByMANUAL TeamAOManagedBy = "MANUAL" TeamAOManagedByOIDC TeamAOManagedBy = "OIDC" + TeamAOManagedBySCIM TeamAOManagedBy = "SCIM" ) // Valid indicates whether the value is a known member of the TeamAOManagedBy enum. @@ -988,6 +995,8 @@ func (e TeamAOManagedBy) Valid() bool { return true case TeamAOManagedByOIDC: return true + case TeamAOManagedBySCIM: + return true default: return false } @@ -1139,6 +1148,7 @@ const ( UpsertTeamAOManagedByLDAP UpsertTeamAOManagedBy = "LDAP" UpsertTeamAOManagedByMANUAL UpsertTeamAOManagedBy = "MANUAL" UpsertTeamAOManagedByOIDC UpsertTeamAOManagedBy = "OIDC" + UpsertTeamAOManagedBySCIM UpsertTeamAOManagedBy = "SCIM" ) // Valid indicates whether the value is a known member of the UpsertTeamAOManagedBy enum. @@ -1150,6 +1160,41 @@ func (e UpsertTeamAOManagedBy) Valid() bool { return true case UpsertTeamAOManagedByOIDC: return true + case UpsertTeamAOManagedBySCIM: + return true + default: + return false + } +} + +// Defines values for UserAOManagedBy. +const ( + UserAOManagedBySCIM UserAOManagedBy = "SCIM" +) + +// Valid indicates whether the value is a known member of the UserAOManagedBy enum. +func (e UserAOManagedBy) Valid() bool { + switch e { + case UserAOManagedBySCIM: + return true + default: + return false + } +} + +// Defines values for UserAORole. +const ( + UserAORoleADMIN UserAORole = "ADMIN" + UserAORoleUSER UserAORole = "USER" +) + +// Valid indicates whether the value is a known member of the UserAORole enum. +func (e UserAORole) Valid() bool { + switch e { + case UserAORoleADMIN: + return true + case UserAORoleUSER: + return true default: return false } @@ -1991,6 +2036,11 @@ type CreateAndRunExperimentAO struct { // // Example: ADM Team string `json:"team"` + + // Version Version for optimistic locking (optional in the API) + // + // Example: 1 + Version *int32 `json:"version,omitempty"` } // CreateAndRunExperimentFromTemplateAO Create or update an experiment based on an experiment template and run it. @@ -2085,6 +2135,11 @@ type CreateExperimentAO struct { // // Example: ADM Team string `json:"team"` + + // Version Version for optimistic locking (optional in the API) + // + // Example: 1 + Version *int32 `json:"version,omitempty"` } // CreateExperimentFromTemplateAO Create or update an experiment based on an experiment template. @@ -2337,7 +2392,7 @@ type EnvironmentAO struct { // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // EnvironmentAOState State of the environment to indicate current background tasks. @@ -2486,10 +2541,10 @@ type ExperimentAO struct { // TemplateTitle The title of the template that was used to create this experiment TemplateTitle *string `json:"templateTitle,omitempty"` - // Version Experiment database version. + // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // ExperimentExecutionAO A single experiment execution that was triggered from a single experiment. @@ -3240,18 +3295,23 @@ type ExperimentStepWaitAO = BaseExperimentStepAO // ExperimentSummariesAO List of experiments. // -// Example: {"experiments":[{"key":"ADM-1","name":"Shop survives unavailability of hot-deals products"},{"key":"SHOP-2","name":"Network latency of Message Broker doesn't interfere with Online shop"}]} +// Example: {"experiments":[{"externalId":"1234567","key":"ADM-1","name":"Shop survives unavailability of hot-deals products","tags":["myTag","myOtherTag"]},{"externalId":"test-id","key":"SHOP-2","name":"Network latency of Message Broker doesn't interfere with Online shop","tags":["yourTag","yourOtherTag"]}]} type ExperimentSummariesAO struct { // Experiments List of experiment summaries // - // Example: [{"key":"ADM-1","name":"Shop survives unavailability of hot-deals products"},{"key":"SHOP-2","name":"Network latency of Message Broker doesn't interfere with Online shop"}] + // Example: [{"externalId":"1234567","key":"ADM-1","name":"Shop survives unavailability of hot-deals products","tags":["myTag","myOtherTag"]},{"externalId":"test-id","key":"SHOP-2","name":"Network latency of Message Broker doesn't interfere with Online shop","tags":["yourTag","yourOtherTag"]}] Experiments *[]ExperimentSummaryAO `json:"experiments,omitempty"` } // ExperimentSummaryAO Summary of a single experiment. // -// Example: {"key":"ADM-1","name":"Shop survives unavailability of hot-deals products"} +// Example: {"externalId":"1234567","key":"ADM-1","name":"Shop survives unavailability of hot-deals products","tags":["myTag","myOtherTag"]} type ExperimentSummaryAO struct { + // ExternalId An optional external identifier used for create-or-update semantics. + // + // Example: 1234567 + ExternalId *string `json:"externalId,omitempty"` + // Key Unique experiment key that identifies the experiment. Combination of `team key` and increasing number // // Example: ADM-2 @@ -3261,6 +3321,11 @@ type ExperimentSummaryAO struct { // // Example: Shop survives unavailability of hot-deals products Name *string `json:"name,omitempty"` + + // Tags An optional set of tags you can use to search for. + // + // Example: ["myTag","myOtherTag"] + Tags *[]string `json:"tags,omitempty"` } // ExperimentTemplateAO Example: {"created":"2024-03-22T09:24:12.802961166Z","createdBy":{"name":"Daniel","pictureUrl":"https://s.gravatar.com/avatar/4f27f3856530f8f2e4ec050b1d594306?s=480\u0026r=pg\u0026d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fda.png","username":"2bd1c2d7-4051-46ad-9f05-315062edd85e"},"edited":"2024-03-22T09:24:12.802961166Z","editedBy":{"name":"Daniel","pictureUrl":"https://s.gravatar.com/avatar/4f27f3856530f8f2e4ec050b1d594306?s=480\u0026r=pg\u0026d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fda.png","username":"2bd1c2d7-4051-46ad-9f05-315062edd85e"},"hidden":false,"id":"6bea7aec-3572-44cf-9151-c6ada57d08ca","lanes":[{"steps":[{"actionType":"com.steadybit.extension_http.check.periodically","ignoreFailure":false,"parameters":{"connectTimeout":"5s","duration":"60s","followRedirects":false,"headers":[],"maxConcurrent":5,"method":"GET","readTimeout":"5s","requestsPerSecond":1,"statusCode":"200-299","successRate":"100","url":"[[HTTP_ENDPOINT]]"},"radius":{},"type":"action"}]},{"steps":[{"ignoreFailure":false,"parameters":{"duration":"10s"},"type":"wait"},{"actionType":"com.steadybit.extension_kubernetes.rollout-restart","ignoreFailure":false,"parameters":{"wait":false},"radius":{"percentage":50,"predicate":{"operator":"AND","predicates":[{"key":"k8s.cluster-name","operator":"EQUALS","values":["[[CLUSTER]]"]},{"key":"k8s.namespace","operator":"EQUALS","values":["[[NAMESPACE]]"]},{"key":"k8s.deployment","operator":"EQUALS","values":["[[DEPLOYMENT]]"]}]},"query":null,"targetType":"com.steadybit.extension_kubernetes.kubernetes-deployment"},"type":"action"},{"actionType":"com.steadybit.extension_kubernetes.rollout-status","ignoreFailure":false,"parameters":{"duration":"10m"},"radius":{"percentage":50,"predicate":{"operator":"AND","predicates":[{"key":"k8s.cluster-name","operator":"EQUALS","values":["[[CLUSTER]]"]},{"key":"k8s.namespace","operator":"EQUALS","values":["[[NAMESPACE]]"]},{"key":"k8s.deployment","operator":"EQUALS","values":["[[DEPLOYMENT]]"]}]},"query":null,"targetType":"com.steadybit.extension_kubernetes.kubernetes-deployment"},"type":"action"}]}],"placeholders":[{"description":"Which HTTP Endpoint should be checked during experiment execution?","key":"HTTP_ENDPOINT","name":"HTTP Endpoint"},{"description":"Which Kubernetes deployment do you want to restart?","key":"DEPLOYMENT","name":"Kubernetes Deployment"},{"description":"In which Kubernetes cluster is the deployment deployed to?","key":"CLUSTER","name":"Kubernetes Cluster"},{"description":"In which Kubernetes namespace is the deployment deployed to?","key":"NAMESPACE","name":"Kubernetes Namespace"}],"properties":{"EXAMPLE_CUSTOM_PROPERTY":"I like this experiment!"},"propertiesMetadata":[{"editableInExecution":false,"key":"EXAMPLE_CUSTOM_PROPERTY","required":true}],"tags":["Kubernetes"],"templateDescription":"Test if a given HTTP Endpoint remains funcitonal if a Kubernetes deployment is restarted.","templateTitle":"HTTP Endpoint remains functional during Kubernetes Rollout Restart","version":0} @@ -3333,7 +3398,7 @@ type ExperimentTemplateAO struct { // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // ExperimentTemplatePlaceholderAO A list of placeholders used in this experiment template. @@ -3992,6 +4057,21 @@ type PagedResponseAOString struct { TotalItems *int64 `json:"totalItems,omitempty"` } +// PagedResponseAOUserAO defines model for PagedResponseAOUserAO. +type PagedResponseAOUserAO struct { + Items *[]UserAO `json:"items,omitempty"` + + // NextPage Next page to query for next page of runs or null if there are none. + // + // Example: 4 + NextPage *int32 `json:"nextPage,omitempty"` + + // TotalItems Total amount of runs matching your query + // + // Example: 241 + TotalItems *int64 `json:"totalItems,omitempty"` +} + // ParameterAO Parameters that describe how to fetch metrics for this action. type ParameterAO struct { AcceptedFileTypes *[]string `json:"acceptedFileTypes,omitempty"` @@ -4554,10 +4634,10 @@ type ServiceAO struct { // Example: {"httpEndpoint":"http://dev.shop.products.internal","targetServices":["gateway","hot-deals","fashion-bestseller"]} Variables *map[string]VariableExpressionAO `json:"variables,omitempty"` - // Version Version for optimistic locking + // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // ServiceExperimentAO defines model for ServiceExperimentAO. @@ -4631,7 +4711,7 @@ type ServiceProfileAO struct { // Templates Template entries in this profile Templates []ServiceProfileCategoryAO `json:"templates"` - // Version Version for optimistic locking + // Version Version for optimistic locking (optional in the API) // // Example: 1 Version int32 `json:"version"` @@ -5201,7 +5281,7 @@ type TeamAO struct { // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // TeamAOManagedBy How a team or team membership is managed @@ -5391,6 +5471,11 @@ type UpdateExperimentAO struct { // // Example: ADM Team string `json:"team"` + + // Version Version for optimistic locking (optional in the API) + // + // Example: 1 + Version *int32 `json:"version,omitempty"` } // UpdateExperimentExecutionPropertiesAO Experiment execution data that should be used only for that specific experiment execution and will not update the experiment design. @@ -5462,6 +5547,11 @@ type UpdateExperimentExecutionPropertiesAO struct { type UpdateExperimentFromTemplateAO struct { // Placeholders List of template placeholder values Placeholders *[]ExperimentTemplatePlaceholderValueAO `json:"placeholders,omitempty"` + + // Version Version for optimistic locking (optional in the API) + // + // Example: 1 + Version *int32 `json:"version,omitempty"` } // UpsertEnvironmentAO Update or insert environment. @@ -5909,6 +5999,51 @@ type UpsertTeamAO struct { // Example: MANUAL type UpsertTeamAOManagedBy string +// UserAO defines model for UserAO. +type UserAO struct { + // Created When the user was created in Steadybit + // + // Example: 2026-01-15T09:30:00Z + Created *time.Time `json:"created,omitempty"` + + // Email Email address of the user + // + // Example: jane.doe@example.com + Email *string `json:"email,omitempty"` + + // LastLogin When the user last signed in; empty if the user never signed in + // + // Example: 2026-10-13T08:12:00Z + LastLogin *time.Time `json:"lastLogin,omitempty"` + + // ManagedBy What manages a user's membership in the tenant + ManagedBy *UserAOManagedBy `json:"managedBy,omitempty"` + + // Name Name of the user + // + // Example: Jane Doe + Name *string `json:"name,omitempty"` + + // Role Role of the user in the tenant + Role UserAORole `json:"role"` + + // Teams Keys of the teams the user is a member of; empty if the user is in no team + // + // Example: ["ADM","PAYMENTS"] + Teams []string `json:"teams"` + + // Username Username of the user, internal identifier of Steadybit + // + // Example: 13av2737-b318-4048-a79d-4789d645bc31 + Username string `json:"username"` +} + +// UserAOManagedBy What manages a user's membership in the tenant +type UserAOManagedBy string + +// UserAORole Role of the user in the tenant +type UserAORole string + // UserPrincipalAL A user has performed the logged event e.g. via UI // // Example: {"email":"example@example.com","name":"Jane Doe","principalType":"USER","role":"ADMIN","username":"1ava2afg-xju33-4c6a-9451-2854584c15be"} @@ -6526,6 +6661,15 @@ type AddTeamMembersParams struct { ValidateMembers *bool `form:"validateMembers,omitempty" json:"validateMembers,omitempty"` } +// GetUsersParams defines parameters for GetUsers. +type GetUsersParams struct { + // Query Only users whose username, name or email contains this text + Query *string `form:"query,omitempty" json:"query,omitempty"` + + // Page The number of the page and its size (at most 100, default 50) + Page PageRequestAO `form:"page" json:"page"` +} + // CreateAccessTokenJSONRequestBody defines body for CreateAccessToken for application/json ContentType. // // Deprecated: this type has been marked as deprecated upstream, but no `x-deprecated-reason` was set @@ -12920,6 +13064,13 @@ type ClientInterface interface { // Corresponds with POST /api/teams/{key}/members/remove (the `RemoveTeamMembers` operationId). RemoveTeamMembers(ctx context.Context, key string, body RemoveTeamMembersJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetUsers List the users of the tenant + // + // Lists all members of the tenant, including users that are in no team, sorted by username. Admins only. + // + // Corresponds with GET /api/users (the `GetUsers` operationId). + GetUsers(ctx context.Context, params *GetUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // InviteUserWithBody Invite users to a tenant // // Invite users to a tenant. The invited users will receive an email with an invitation link to join the tenant. @@ -12937,6 +13088,13 @@ type ClientInterface interface { // // Corresponds with POST /api/users/invite (the `InviteUser` operationId). InviteUser(ctx context.Context, body InviteUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RemoveUser Remove a user from the tenant + // + // Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only. + // + // Corresponds with DELETE /api/users/{username} (the `RemoveUser` operationId). + RemoveUser(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error) } // GetAccessTokens Get access token list @@ -16399,6 +16557,23 @@ func (c *Client) RemoveTeamMembers(ctx context.Context, key string, body RemoveT return c.Client.Do(req) } +// GetUsers List the users of the tenant +// +// Lists all members of the tenant, including users that are in no team, sorted by username. Admins only. +// +// Corresponds with GET /api/users (the `GetUsers` operationId). +func (c *Client) GetUsers(ctx context.Context, params *GetUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetUsersRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // InviteUserWithBody Invite users to a tenant // // Invite users to a tenant. The invited users will receive an email with an invitation link to join the tenant. @@ -16437,6 +16612,23 @@ func (c *Client) InviteUser(ctx context.Context, body InviteUserJSONRequestBody, return c.Client.Do(req) } +// RemoveUser Remove a user from the tenant +// +// Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only. +// +// Corresponds with DELETE /api/users/{username} (the `RemoveUser` operationId). +func (c *Client) RemoveUser(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRemoveUserRequest(c.Server, username) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewGetAccessTokensRequest constructs an http.Request for the GetAccessTokens method func NewGetAccessTokensRequest(server string, params *GetAccessTokensParams) (*http.Request, error) { var err error @@ -23454,6 +23646,68 @@ func NewRemoveTeamMembersRequestWithBody(server string, key string, contentType return req, nil } +// NewGetUsersRequest constructs an http.Request for the GetUsers method +func NewGetUsersRequest(server string, params *GetUsersParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/users") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Query != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "query", *params.Query, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "object", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewInviteUserRequest calls the generic InviteUser builder with application/json body func NewInviteUserRequest(server string, body InviteUserJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -23494,6 +23748,40 @@ func NewInviteUserRequestWithBody(server string, contentType string, body io.Rea return req, nil } +// NewRemoveUserRequest constructs an http.Request for the RemoveUser method +func NewRemoveUserRequest(server string, username string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "username", username, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/users/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -25275,6 +25563,15 @@ type ClientWithResponsesInterface interface { // Corresponds with POST /api/teams/{key}/members/remove (the `RemoveTeamMembers` operationId). RemoveTeamMembersWithResponse(ctx context.Context, key string, body RemoveTeamMembersJSONRequestBody, reqEditors ...RequestEditorFn) (*RemoveTeamMembersResponse, error) + // GetUsersWithResponse List the users of the tenant + // + // Lists all members of the tenant, including users that are in no team, sorted by username. Admins only. + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with GET /api/users (the `GetUsers` operationId). + GetUsersWithResponse(ctx context.Context, params *GetUsersParams, reqEditors ...RequestEditorFn) (*GetUsersResponse, error) + // InviteUserWithBodyWithResponse Invite users to a tenant // // Invite users to a tenant. The invited users will receive an email with an invitation link to join the tenant. @@ -25292,6 +25589,15 @@ type ClientWithResponsesInterface interface { // // Corresponds with POST /api/users/invite (the `InviteUser` operationId). InviteUserWithResponse(ctx context.Context, body InviteUserJSONRequestBody, reqEditors ...RequestEditorFn) (*InviteUserResponse, error) + + // RemoveUserWithResponse Remove a user from the tenant + // + // Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only. + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with DELETE /api/users/{username} (the `RemoveUser` operationId). + RemoveUserWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*RemoveUserResponse, error) } type GetAccessTokensResponse struct { @@ -31529,6 +31835,54 @@ func (r RemoveTeamMembersResponse) ContentType() string { return "" } +type GetUsersResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *PagedResponseAOUserAO + // YAML200 the response for an HTTP 200 `application/yaml` response + YAML200 *PagedResponseAOUserAO +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r GetUsersResponse) GetJSON200() *PagedResponseAOUserAO { + return r.JSON200 +} + +// GetYAML200 returns the response for an HTTP 200 `application/yaml` response +func (r GetUsersResponse) GetYAML200() *PagedResponseAOUserAO { + return r.YAML200 +} + +// GetBody returns the raw response body bytes +func (r GetUsersResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r GetUsersResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetUsersResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetUsersResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type InviteUserResponse struct { Body []byte HTTPResponse *http.Response @@ -31563,6 +31917,40 @@ func (r InviteUserResponse) ContentType() string { return "" } +type RemoveUserResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// GetBody returns the raw response body bytes +func (r RemoveUserResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r RemoveUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RemoveUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RemoveUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + // GetAccessTokensWithResponse Get access token list // // Deprecated, use v2 instead. Get a list of all access tokens. The access token itself is abbreviated for security reasons. Access tokens with v2 features are not returned, as they can not be represented cleanly in the old format. @@ -34433,6 +34821,21 @@ func (c *ClientWithResponses) RemoveTeamMembersWithResponse(ctx context.Context, return ParseRemoveTeamMembersResponse(rsp) } +// GetUsersWithResponse List the users of the tenant +// +// Lists all members of the tenant, including users that are in no team, sorted by username. Admins only. +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with GET /api/users (the `GetUsers` operationId). +func (c *ClientWithResponses) GetUsersWithResponse(ctx context.Context, params *GetUsersParams, reqEditors ...RequestEditorFn) (*GetUsersResponse, error) { + rsp, err := c.GetUsers(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetUsersResponse(rsp) +} + // InviteUserWithBodyWithResponse Invite users to a tenant // // Invite users to a tenant. The invited users will receive an email with an invitation link to join the tenant. @@ -34463,6 +34866,21 @@ func (c *ClientWithResponses) InviteUserWithResponse(ctx context.Context, body I return ParseInviteUserResponse(rsp) } +// RemoveUserWithResponse Remove a user from the tenant +// +// Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only. +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with DELETE /api/users/{username} (the `RemoveUser` operationId). +func (c *ClientWithResponses) RemoveUserWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*RemoveUserResponse, error) { + rsp, err := c.RemoveUser(ctx, username, reqEditors...) + if err != nil { + return nil, err + } + return ParseRemoveUserResponse(rsp) +} + // ParseGetAccessTokensResponse parses an HTTP response from a GetAccessTokensWithResponse call func ParseGetAccessTokensResponse(rsp *http.Response) (*GetAccessTokensResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -38769,6 +39187,42 @@ func ParseRemoveTeamMembersResponse(rsp *http.Response) (*RemoveTeamMembersRespo return response, nil } +// ParseGetUsersResponse parses an HTTP response from a GetUsersWithResponse call +func ParseGetUsersResponse(rsp *http.Response) (*GetUsersResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetUsersResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PagedResponseAOUserAO + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "yaml") && rsp.StatusCode == 200: + var dest PagedResponseAOUserAO + if err := yaml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.YAML200 = &dest + + case rsp.StatusCode == 403: + break // No content-type + + } + + return response, nil +} + // ParseInviteUserResponse parses an HTTP response from a InviteUserWithResponse call func ParseInviteUserResponse(rsp *http.Response) (*InviteUserResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -38784,3 +39238,19 @@ func ParseInviteUserResponse(rsp *http.Response) (*InviteUserResponse, error) { return response, nil } + +// ParseRemoveUserResponse parses an HTTP response from a RemoveUserWithResponse call +func ParseRemoveUserResponse(rsp *http.Response) (*RemoveUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RemoveUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} diff --git a/internal/cli/user.go b/internal/cli/user.go index 10e65b6..5318619 100644 --- a/internal/cli/user.go +++ b/internal/cli/user.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/v6/internal/platform" + "github.com/steadybit/cli/v6/internal/resource" "github.com/steadybit/cli/v6/internal/user" ) @@ -28,6 +29,29 @@ func newUser() *cobra.Command { _ = invite.MarkFlagRequired("email") variadic(invite, "email") - cmd.AddCommand(invite) + var l user.ListOptions + list := &cobra.Command{ + Use: "list", + Short: "List the users of the tenant. Needs an admin access token.", + Args: cobra.NoArgs, + Example: examples("steadybit user list", "steadybit user list --query jane", `steadybit user list --jq '.[] | select(.managedBy == "SCIM") | .username'`), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return user.List(ctx, c, l) }), + } + list.Flags().StringVar(&l.Query, "query", "", "Only list users whose username, name or email contains this text.") + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) + + var d user.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Remove a user from the tenant. Users managed by SCIM or an LDAP sync are removed there instead. Needs an admin access token.", + Args: cobra.NoArgs, + Example: examples("steadybit user delete --username 13av2737-b318-4048-a79d-4789d645bc31"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return user.Delete(ctx, c, d) }), + } + del.Flags().StringVarP(&d.Username, "username", "u", "", "The username, as user list shows it.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + _ = del.MarkFlagRequired("username") + + cmd.AddCommand(invite, list, del) return cmd } diff --git a/internal/user/user.go b/internal/user/user.go index 1aeecc2..2d7d933 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -14,6 +14,8 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" "github.com/steadybit/cli/v6/api" "github.com/steadybit/cli/v6/internal/platform" + "github.com/steadybit/cli/v6/internal/resource" + "github.com/steadybit/cli/v6/internal/table" ) type InviteOptions struct { @@ -54,3 +56,87 @@ func Invite(ctx context.Context, c *platform.Client, o InviteOptions) error { fmt.Printf("%d user(s) invited. They receive an email to join.\n", len(o.Emails)) return nil } + +type ListOptions struct { + Query string + Type string +} + +func List(ctx context.Context, c *platform.Client, o ListOptions) error { + params := api.GetUsersParams{} + if o.Query != "" { + params.Query = &o.Query + } + raw, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) { + params.Page = api.PageRequestAO{Page: &page, Size: &size} + return c.GetUsers(ctx, ¶ms) + }) + if platform.IsStatus(err, http.StatusForbidden) { + return errors.New("Listing users needs an admin access token.") + } + if err != nil { + return platform.Failed(err, "Failed to get the users") + } + return resource.List(raw, o.Type, func() error { + var users []struct { + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + Teams []string `json:"teams"` + ManagedBy string `json:"managedBy"` + LastLogin *string `json:"lastLogin"` + } + if err := resource.DecodeEach(raw, &users); err != nil { + return err + } + if len(users) == 0 { + fmt.Println("No users found.") + return nil + } + t := table.New( + table.Column{Name: "username", Title: "Username", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "email", Title: "Email", Alignment: table.Left}, + table.Column{Name: "role", Title: "Role", Alignment: table.Left}, + table.Column{Name: "teams", Title: "Teams", Alignment: table.Left}, + table.Column{Name: "managedBy", Title: "Managed by", Alignment: table.Left}, + table.Column{Name: "lastLogin", Title: "Last login", Alignment: table.Left}, + ) + for _, u := range users { + managedBy, lastLogin := u.ManagedBy, "never" + if managedBy == "" { + managedBy = "Steadybit" + } + if u.LastLogin != nil && *u.LastLogin != "" { + lastLogin = *u.LastLogin + } + t.AddRow(table.Default, table.Cell("username", u.Username), table.Cell("name", u.Name), table.Cell("email", u.Email), + table.Cell("role", u.Role), table.Cell("teams", strings.Join(u.Teams, ", ")), table.Cell("managedBy", managedBy), table.Cell("lastLogin", lastLogin)) + } + t.Print() + return nil + }) +} + +type DeleteOptions struct { + Username string + Yes bool +} + +// Delete removes a user from the tenant. Users managed by SCIM or an LDAP sync are +// removed in the identity provider instead; the platform refuses them. +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Remove user %s from the tenant? They lose access to it.", o.Username)); !ok || err != nil { + return err + } + _, _, err := platform.Read(c.RemoveUser(ctx, o.Username)) + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("User %s is not a member of the tenant.", o.Username) + } + if err != nil { + return platform.Failed(err, "Failed to remove user %s", o.Username) + } + fmt.Printf("User %s removed from the tenant.\n", o.Username) + return nil +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go index eecbfde..1d5efbb 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -54,3 +54,66 @@ func TestInviteRefusals(t *testing.T) { assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{}), "No one to invite. Pass --email.") assert.Len(t, p.Requests("POST /api/users/invite"), 1) } + +var users = map[string]any{"items": []any{ + map[string]any{"username": "u-1", "name": "Jane Doe", "email": "jane@example.com", "role": "ADMIN", "teams": []any{"ADM"}, "lastLogin": "2026-10-13T08:12:00Z"}, + map[string]any{"username": "u-2", "name": "Joe", "email": "joe@example.com", "role": "USER", "teams": []any{}, "managedBy": "SCIM"}, +}} + +func TestList(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/users", platformtest.Reply{JSON: users}) + + out, err := platformtest.Stdout(t, func() error { return user.List(ctx, p.Client, user.ListOptions{Query: "jo"}) }) + + require.NoError(t, err) + assert.Contains(t, out, "Jane Doe") + assert.Contains(t, out, "2026-10-13T08:12:00Z") + assert.Contains(t, out, "Steadybit") + assert.Contains(t, out, "SCIM") + assert.Contains(t, out, "never") + assert.Equal(t, []string{"jo"}, p.Requests("GET /api/users")[0].Query["query"]) +} + +func TestListAsJSON(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/users", platformtest.Reply{JSON: users}) + + out, err := platformtest.Stdout(t, func() error { return user.List(ctx, p.Client, user.ListOptions{Type: "json"}) }) + + require.NoError(t, err) + assert.Contains(t, out, `"username": "u-2"`) + assert.Contains(t, out, `"managedBy": "SCIM"`) + assert.Empty(t, p.Requests("GET /api/users")[0].Query["query"]) +} + +func TestListNeedsAnAdminToken(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/users", platformtest.Reply{Status: http.StatusForbidden}) + + assert.EqualError(t, user.List(ctx, p.Client, user.ListOptions{}), "Listing users needs an admin access token.") +} + +func TestDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/users/u-1", platformtest.Reply{Status: http.StatusNoContent}) + + out, err := platformtest.Stdout(t, func() error { return user.Delete(ctx, p.Client, user.DeleteOptions{Username: "u-1", Yes: true}) }) + + require.NoError(t, err) + assert.Equal(t, "User u-1 removed from the tenant.\n", out) + assert.Len(t, p.Requests("DELETE /api/users/u-1"), 1) +} + +func TestDeleteRefusals(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/users/nobody", platformtest.Reply{Status: http.StatusNotFound}) + p.Reply("DELETE /api/users/u-2", platformtest.Reply{Status: http.StatusForbidden, + JSON: map[string]any{"title": "Forbidden", "detail": "The user is managed by SCIM"}}) + + assert.EqualError(t, user.Delete(ctx, p.Client, user.DeleteOptions{Username: "nobody", Yes: true}), "User nobody is not a member of the tenant.") + err := user.Delete(ctx, p.Client, user.DeleteOptions{Username: "u-2", Yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Failed to remove user u-2") + assert.Contains(t, err.Error(), "managed by SCIM") +} diff --git a/openapi/platform-api.json b/openapi/platform-api.json index 0a2cd21..fc39dee 100644 --- a/openapi/platform-api.json +++ b/openapi/platform-api.json @@ -8,7 +8,7 @@ "url": "https://steadybit.com", "email": "support@steadybit.com" }, - "version": "2.8.7" + "version": "1.0.0-SNAPSHOT" }, "externalDocs": { "description": "Steadybit Docs - Integrate via API", @@ -16,7 +16,7 @@ }, "servers": [ { - "url": "https://platform.steadybit.com", + "url": "https://platform.dev.steadybit.com", "description": "Generated server url" } ], @@ -442,11 +442,11 @@ } } }, - "400": { - "description": "The given `id` is not a uuid." - }, "404": { "description": "Saved view with given `id` was not found." + }, + "400": { + "description": "The given `id` is not a uuid." } } }, @@ -3954,12 +3954,12 @@ } ], "responses": { - "200": { - "description": "Experiment execution was already canceled, errored or completed." - }, "202": { "description": "Request to cancel the experiment was accepted and will be performed by communicating to the experiment's agents." }, + "200": { + "description": "Experiment execution was already canceled, errored or completed." + }, "404": { "description": "Experiment execution not found." } @@ -4559,6 +4559,57 @@ } } }, + "/api/users": { + "get": { + "tags": [ + "Users" + ], + "summary": "List the users of the tenant", + "description": "Lists all members of the tenant, including users that are in no team, sorted by username. Admins only.", + "operationId": "getUsers", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "Only users whose username, name or email contains this text", + "required": false, + "schema": { + "type": "string" + }, + "example": "jane" + }, + { + "name": "page", + "in": "query", + "description": "The number of the page and its size (at most 100, default 50)", + "required": true, + "schema": { + "$ref": "#/components/schemas/PageRequestAO" + } + } + ], + "responses": { + "200": { + "description": "A page of users", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedResponseAOUserAO" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/PagedResponseAOUserAO" + } + } + } + }, + "403": { + "description": "Only admins can list users" + } + } + } + }, "/api/teams/{key}": { "get": { "tags": [ @@ -6406,11 +6457,11 @@ } } }, - "400": { - "description": "The given `id` is not a uuid" - }, "404": { "description": "Environment with given `id` was not found" + }, + "400": { + "description": "The given `id` is not a uuid" } } }, @@ -6739,6 +6790,42 @@ } } }, + "/api/users/{username}": { + "delete": { + "tags": [ + "Users" + ], + "summary": "Remove a user from the tenant", + "description": "Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only.", + "operationId": "removeUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The username of the user to remove", + "required": true, + "schema": { + "type": "string" + }, + "example": "jane.doe" + } + ], + "responses": { + "204": { + "description": "The user was removed from the tenant" + }, + "400": { + "description": "Admins cannot remove themselves" + }, + "403": { + "description": "Only admins can remove users, and not users managed via SCIM or an LDAP sync" + }, + "404": { + "description": "The user is not a member of the tenant" + } + } + } + }, "/api/access-tokens/{id}": { "delete": { "tags": [ @@ -6926,7 +7013,8 @@ "enum": [ "MANUAL", "OIDC", - "LDAP" + "LDAP", + "SCIM" ] } }, @@ -7449,6 +7537,14 @@ "$ref": "#/components/schemas/MemberUpdateAO" } }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 + }, "id": { "type": "string", "format": "uuid" @@ -7474,14 +7570,6 @@ "type": "string", "description": "An optional description of a team" }, - "version": { - "minimum": 0, - "type": "integer", - "description": "Version for optimistic locking (optional in the API)", - "format": "int32", - "nullable": true, - "example": 1 - }, "logoId": { "maxLength": 255, "minLength": 0, @@ -7537,7 +7625,8 @@ "enum": [ "MANUAL", "OIDC", - "LDAP" + "LDAP", + "SCIM" ] } }, @@ -7577,7 +7666,8 @@ "allowedEnvironments", "key", "members", - "name" + "name", + "version" ], "type": "object", "properties": { @@ -7606,14 +7696,6 @@ "type": "string", "description": "An optional description of a team" }, - "version": { - "minimum": 0, - "type": "integer", - "description": "Version for optimistic locking (optional in the API)", - "format": "int32", - "nullable": true, - "example": 1 - }, "logoId": { "maxLength": 255, "minLength": 0, @@ -7669,9 +7751,17 @@ "enum": [ "MANUAL", "OIDC", - "LDAP" + "LDAP", + "SCIM" ] }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "example": 1 + }, "members": { "maxItems": 512, "minItems": 0, @@ -8485,7 +8575,8 @@ "query", "serviceProfile", "team", - "validations" + "validations", + "version" ], "type": "object", "properties": { @@ -8580,7 +8671,7 @@ "version": { "minimum": 0, "type": "integer", - "description": "Version for optimistic locking", + "description": "Version for optimistic locking (optional in the API)", "format": "int32", "example": 1 }, @@ -8895,7 +8986,7 @@ "version": { "minimum": 0, "type": "integer", - "description": "Version for optimistic locking", + "description": "Version for optimistic locking (optional in the API)", "format": "int32", "example": 1 }, @@ -10683,6 +10774,14 @@ "example": { "EXAMPLE_CUSTOM_PROPERTY": "I like this experiment!" } + }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 } }, "description": "Create or update the experiment with the given experiment design.", @@ -10992,6 +11091,14 @@ "example": { "EXAMPLE_CUSTOM_PROPERTY": "I like this experiment!" } + }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 } }, "description": "Update the experiment with the given experiment design.", @@ -11158,14 +11265,6 @@ "format": "uuid", "nullable": true }, - "version": { - "minimum": 0, - "type": "integer", - "description": "Version for optimistic locking (optional in the API)", - "format": "int32", - "nullable": true, - "example": 1 - }, "templateTitle": { "maxLength": 255, "minLength": 0, @@ -11280,6 +11379,14 @@ "type": "boolean", "description": "Should the experiment template be hidden", "example": false + }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 } }, "description": "Insert or update the experiment template in Steadybit. The `id` will be used to identify whether the template already exists and should be updated or newly inserted.", @@ -11442,7 +11549,8 @@ "editedBy", "lanes", "templateDescription", - "templateTitle" + "templateTitle", + "version" ], "type": "object", "properties": { @@ -11451,14 +11559,6 @@ "format": "uuid", "nullable": true }, - "version": { - "minimum": 0, - "type": "integer", - "description": "Version for optimistic locking (optional in the API)", - "format": "int32", - "nullable": true, - "example": 1 - }, "templateTitle": { "maxLength": 255, "minLength": 0, @@ -11574,6 +11674,13 @@ "description": "Should the experiment template be hidden", "example": false }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "example": 1 + }, "created": { "type": "string", "description": "Timestamp when the experiment template was created", @@ -11765,6 +11872,14 @@ "UpdateExperimentFromTemplateAO": { "type": "object", "properties": { + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 + }, "placeholders": { "type": "array", "description": "List of template placeholder values", @@ -12623,6 +12738,14 @@ "EXAMPLE_CUSTOM_PROPERTY": "I like this experiment!" } }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 + }, "executionVariables": { "type": "object", "additionalProperties": { @@ -12698,6 +12821,7 @@ "type": "integer", "description": "Version for optimistic locking (optional in the API)", "format": "int32", + "nullable": true, "example": 1 }, "predicate": { @@ -12724,7 +12848,8 @@ "required": [ "name", "predicate", - "state" + "state", + "version" ], "type": "object", "properties": { @@ -13181,6 +13306,115 @@ "enabled": false } }, + "PageRequestAO": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "format": "int32" + } + } + }, + "PagedResponseAOUserAO": { + "type": "object", + "properties": { + "totalItems": { + "type": "integer", + "description": "Total amount of runs matching your query", + "format": "int64", + "example": 241 + }, + "nextPage": { + "type": "integer", + "description": "Next page to query for next page of runs or null if there are none.", + "format": "int32", + "nullable": true, + "example": 4 + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserAO" + } + } + } + }, + "UserAO": { + "required": [ + "role", + "teams", + "username" + ], + "type": "object", + "properties": { + "username": { + "minLength": 1, + "type": "string", + "description": "Username of the user, internal identifier of Steadybit", + "example": "13av2737-b318-4048-a79d-4789d645bc31" + }, + "name": { + "type": "string", + "description": "Name of the user", + "example": "Jane Doe" + }, + "email": { + "type": "string", + "description": "Email address of the user", + "nullable": true, + "example": "jane.doe@example.com" + }, + "role": { + "type": "string", + "description": "Role of the user in the tenant", + "enum": [ + "ADMIN", + "USER" + ] + }, + "managedBy": { + "type": "string", + "description": "What manages a user's membership in the tenant", + "nullable": true, + "enum": [ + "SCIM" + ] + }, + "teams": { + "type": "array", + "description": "Keys of the teams the user is a member of; empty if the user is in no team", + "example": [ + "ADM", + "PAYMENTS" + ], + "items": { + "type": "string", + "description": "Keys of the teams the user is a member of; empty if the user is in no team", + "example": "[\"ADM\",\"PAYMENTS\"]" + } + }, + "created": { + "type": "string", + "description": "When the user was created in Steadybit", + "format": "date-time", + "nullable": true, + "example": "2026-01-15T09:30:00Z" + }, + "lastLogin": { + "type": "string", + "description": "When the user last signed in; empty if the user never signed in", + "format": "date-time", + "nullable": true, + "example": "2026-10-13T08:12:00Z" + } + } + }, "TeamSummariesAO": { "type": "object", "properties": { @@ -13333,21 +13567,6 @@ } } }, - "PageRequestAO": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "maximum": 100, - "minimum": 1, - "type": "integer", - "format": "int32" - } - } - }, "PagedResponseAOServiceSummaryAO": { "type": "object", "properties": { @@ -13971,11 +14190,21 @@ "example": [ { "key": "ADM-1", - "name": "Shop survives unavailability of hot-deals products" + "name": "Shop survives unavailability of hot-deals products", + "externalId": "1234567", + "tags": [ + "myTag", + "myOtherTag" + ] }, { "key": "SHOP-2", - "name": "Network latency of Message Broker doesn't interfere with Online shop" + "name": "Network latency of Message Broker doesn't interfere with Online shop", + "externalId": "test-id", + "tags": [ + "yourTag", + "yourOtherTag" + ] } ], "items": { @@ -13988,11 +14217,21 @@ "experiments": [ { "key": "ADM-1", - "name": "Shop survives unavailability of hot-deals products" + "name": "Shop survives unavailability of hot-deals products", + "externalId": "1234567", + "tags": [ + "myTag", + "myOtherTag" + ] }, { "key": "SHOP-2", - "name": "Network latency of Message Broker doesn't interfere with Online shop" + "name": "Network latency of Message Broker doesn't interfere with Online shop", + "externalId": "test-id", + "tags": [ + "yourTag", + "yourOtherTag" + ] } ] } @@ -14009,12 +14248,36 @@ "type": "string", "description": "Name of the experiment to easily identify the experiment", "example": "Shop survives unavailability of hot-deals products" + }, + "externalId": { + "type": "string", + "description": "An optional external identifier used for create-or-update semantics.", + "example": "1234567" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "description": "An optional set of tags you can use to search for.", + "example": [ + "myTag", + "myOtherTag" + ], + "items": { + "type": "string", + "description": "An optional set of tags you can use to search for.", + "example": "[\"myTag\",\"myOtherTag\"]" + } } }, "description": "Summary of a single experiment.", "example": { "key": "ADM-1", - "name": "Shop survives unavailability of hot-deals products" + "name": "Shop survives unavailability of hot-deals products", + "externalId": "1234567", + "tags": [ + "myTag", + "myOtherTag" + ] } }, "ExperimentAO": { @@ -14026,7 +14289,8 @@ "key", "lanes", "name", - "team" + "team", + "version" ], "type": "object", "properties": { @@ -14174,8 +14438,9 @@ "example": "ADM-2" }, "version": { + "minimum": 0, "type": "integer", - "description": "Experiment database version.", + "description": "Version for optimistic locking (optional in the API)", "format": "int32", "example": 1 }, @@ -15351,7 +15616,8 @@ "enum": [ "ADMIN", "TEAM", - "WILDCARD" + "WILDCARD", + "SCIM" ] }, "principalType": { @@ -17144,10 +17410,9 @@ }, "securitySchemes": { "AccessToken": { - "type": "apiKey", - "description": "Learn more about [API Access Token in our docs](https://docs.steadybit.com/integrate-with-steadybit/api#access-tokens).
\nTo generate a new API Access Token, go to [Platform Settings / API Access Token](/settings/api-tokens).\nWhen using the API Access Token in the HTTP Header 'Authorization', use the following value format:

\n```\naccessToken \n```\n\n", - "name": "Authorization", - "in": "header" + "type": "http", + "description": "Learn more about [API Access Token in our docs](https://docs.steadybit.com/integrate-with-steadybit/api#access-tokens).
\nTo generate a new API Access Token, go to [Platform Settings / API Access Token](/settings/api-tokens).\nPaste the token as-is below, the `Bearer` prefix is added for you. Outside of this page, send it in the HTTP Header 'Authorization':

\n```\nBearer \n```\nThe legacy format `accessToken ` is still supported.\n\n", + "scheme": "bearer" } } } From d0f891910ed7146376b79df489574e28f9d1aabc Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Wed, 21 Oct 2026 09:31:35 +0200 Subject: [PATCH 2/3] test: e2e checks for user list and user delete Lists users, as a table and as JSON, queries for no one and deletes a user no one has; the suite removes no real user. Also fixes the example username of user delete, which was not a valid UUID. --- e2e/platform.sh | 8 ++++++++ internal/cli/user.go | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/e2e/platform.sh b/e2e/platform.sh index fba1f68..d5f91c9 100755 --- a/e2e/platform.sh +++ b/e2e/platform.sh @@ -408,6 +408,14 @@ EOF check "team member add skips an unknown user" prints "0 member(s)" admin team member add -k "$SCOPE_TEAM" --email nobody@cli-e2e.invalid check "team member add --validate fails on an unknown user" exits_with 1 admin team member add -k "$SCOPE_TEAM" --email nobody@cli-e2e.invalid --validate + # Users are only listed, and only a user no one has is deleted: the suite removes no one. + check "user list shows the users of the tenant" prints "Username" admin user list + check "user list -t json prints the platform's items" prints '"username"' admin user list -t json + check "user list --query finds no one for an unknown text" prints "No users found." admin user list --query "$MARK-$RUN-nobody" + check "user delete fails on a user who is not a member" exits_with 1 admin user delete --username "$MARK-$RUN-nobody" --yes + check "user delete says the user is not a member" grep -qF "is not a member of the tenant" out.log + check "user list needs an admin access token" exits_with 1 steadybit user list + # A token of the new team, which expires tomorrow at the latest. tomorrow=$(date -u -d tomorrow +%F 2>/dev/null || date -u -v+1d +%F) admin access-token create --name "$MARK-$RUN-token" --type TEAM --team "$SCOPE_TEAM" --expires-at "$tomorrow" -t json >token.json 2>out.log diff --git a/internal/cli/user.go b/internal/cli/user.go index 5318619..cda6b69 100644 --- a/internal/cli/user.go +++ b/internal/cli/user.go @@ -45,7 +45,7 @@ func newUser() *cobra.Command { Use: "delete", Short: "Remove a user from the tenant. Users managed by SCIM or an LDAP sync are removed there instead. Needs an admin access token.", Args: cobra.NoArgs, - Example: examples("steadybit user delete --username 13av2737-b318-4048-a79d-4789d645bc31"), + Example: examples("steadybit user delete --username 13a52737-b318-4048-a79d-4789d645bc31"), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return user.Delete(ctx, c, d) }), } del.Flags().StringVarP(&d.Username, "username", "u", "", "The username, as user list shows it.") From 9071100ae48dc3ca614eb93a9a1de11a94a677fc Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 23 Oct 2026 13:48:13 +0200 Subject: [PATCH 3/3] chore: regenerate the client from the production spec --- api/platform.gen.go | 390 +++++++++++++++++++++++++++++++++++--- openapi/platform-api.json | 174 +++++++++++++++-- 2 files changed, 518 insertions(+), 46 deletions(-) diff --git a/api/platform.gen.go b/api/platform.gen.go index 2978f75..f4f06a8 100644 --- a/api/platform.gen.go +++ b/api/platform.gen.go @@ -3166,13 +3166,18 @@ type ExperimentRiskAO struct { // ExperimentScheduleAO A schedule for an experiment. // -// Example: {"allowParallel":true,"cron":"30 * * * * ? *","editedBy":{"email":"manuel@example.org","name":"Manuel","pictureUrl":"https://.../picture.png","username":"ag1hb7ap-d299-47ab-998f-c2a53b433820"},"enabled":true,"experimentKey":"ADM-8","id":"01951394-727f-76a0-8675-c7519ebd0ff5","lastUpdated":"2025-02-17T11:04:10.623486Z","nextExecution":"2025-02-20T05:54:30Z","timezone":"Europe/Berlin","variables":{}} +// Example: {"allowParallel":true,"allowRunningAgain":false,"cron":"30 * * * * ? *","editedBy":{"email":"manuel@example.org","name":"Manuel","pictureUrl":"https://.../picture.png","username":"ag1hb7ap-d299-47ab-998f-c2a53b433820"},"enabled":true,"experimentKey":"ADM-8","id":"01951394-727f-76a0-8675-c7519ebd0ff5","lastUpdated":"2025-02-17T11:04:10.623486Z","nextExecution":"2025-02-20T05:54:30Z","timezone":"Europe/Berlin","variables":{}} type ExperimentScheduleAO struct { - // AllowParallel Should the experiment run if another experiment is running? Default is true. + // AllowParallel Should the experiment run if another experiment is running? Does not apply to runs of this experiment itself - see `allowRunningAgain`. Default is true. // // Example: true AllowParallel *bool `json:"allowParallel,omitempty"` + // AllowRunningAgain Should the experiment run while a run of this same experiment is still in progress? `allowParallel` only covers other experiments. Default is false. + // + // Example: false + AllowRunningAgain *bool `json:"allowRunningAgain,omitempty"` + // Cron Cron expression for the experiment schedule. Can't be used in combination with `startAt`. // // Example: 0 15 10 ? * * @@ -3408,7 +3413,7 @@ type ExperimentTemplatePlaceholderAO struct { Name string `json:"name"` } -// ExperimentTemplatePlaceholderValueAO List of template placeholder values +// ExperimentTemplatePlaceholderValueAO List of template placeholder values. The `SERVICE` placeholder defaults to the id of the service and only needs to be passed to override it. type ExperimentTemplatePlaceholderValueAO struct { // Key The key of a template placeholder // @@ -4099,11 +4104,16 @@ type ParameterAO struct { // // Example: {"enabled":false} type PatchExperimentScheduleAO struct { - // AllowParallel Should the experiment run if another experiment is running? + // AllowParallel Should the experiment run if another experiment is running? Does not apply to runs of this experiment itself - see `allowRunningAgain`. // // Example: true AllowParallel *bool `json:"allowParallel,omitempty"` + // AllowRunningAgain Should the experiment run while a run of this same experiment is still in progress? + // + // Example: false + AllowRunningAgain *bool `json:"allowRunningAgain,omitempty"` + // Cron Cron expression for the experiment schedule. If provided, startAt will be cleared. // // Example: 0 15 10 ? * * @@ -5586,11 +5596,16 @@ type UpsertEnvironmentAO struct { // // Example: {"cron":"30 * * * * ? *","experimentKey":"ADM-8"} type UpsertExperimentScheduleAO struct { - // AllowParallel Should the experiment run if another experiment is running? Default is true. + // AllowParallel Should the experiment run if another experiment is running? Does not apply to runs of this experiment itself - see `allowRunningAgain`. Default is true. // // Example: true AllowParallel *bool `json:"allowParallel,omitempty"` + // AllowRunningAgain Should the experiment run while a run of this same experiment is still in progress? `allowParallel` only covers other experiments. Default is false. + // + // Example: false + AllowRunningAgain *bool `json:"allowRunningAgain,omitempty"` + // Cron Cron expression for the experiment schedule. Can't be used in combination with `startAt`. // // Example: 0 15 10 ? * * @@ -5841,7 +5856,7 @@ type UpsertProvidedExperimentRequestAO struct { // Example: ADM-18 ExperimentKey *string `json:"experimentKey,omitempty"` - // Placeholders List of template placeholder values + // Placeholders List of template placeholder values. The `SERVICE` placeholder defaults to the id of the service and only needs to be passed to override it. Placeholders *[]ExperimentTemplatePlaceholderValueAO `json:"placeholders,omitempty"` // TemplateId The templateId that should be used for the provided experiment (needs to be included in the used service profile) @@ -6102,7 +6117,7 @@ type UserSummaryAO struct { Username string `json:"username"` } -// VariableExpressionAO A variable value: a constant string (≤5000 chars), an array of constant strings, or a select expression object. +// VariableExpressionAO A variable value: a constant string (≤10000 chars), an array of constant strings, or a select expression object. type VariableExpressionAO struct { union json.RawMessage } @@ -6251,11 +6266,17 @@ type GetExperimentsParamsKind string // SaveAndRunParams defines parameters for SaveAndRun. type SaveAndRunParams struct { - // AllowParallel Should this experiment also be executed when there is already at least one experiment running? + // AllowParallel Should this experiment also be executed when there is already at least one other experiment running? Does not apply to runs of this experiment itself - see `allowRunningAgain`. AllowParallel *bool `form:"allowParallel,omitempty" json:"allowParallel,omitempty"` + // AllowRunningAgain By default, while this experiment is already running, the run in progress is returned. Set to `true` to start another run of it. Requires an `Idempotency-Key` header, so a retried request cannot start yet another run. Other experiments running are still governed by `allowParallel`. + AllowRunningAgain *bool `form:"allowRunningAgain,omitempty" json:"allowRunningAgain,omitempty"` + // ForcePersist Optional parameter to always store runs on any failure. If false, won´t be stored on validation errors (default behaviour). ForcePersist *bool `form:"forcePersist,omitempty" json:"forcePersist,omitempty"` + + // IdempotencyKey Optional key making the request safe to retry: while the run started with this key is in progress, the same request returns that run instead of starting another one. Any unique value, e.g. a UUID, up to 255 characters. Required with `allowRunningAgain=true`. + IdempotencyKey *string `json:"Idempotency-Key,omitempty"` } // GetExperimentExecutions1Params defines parameters for GetExperimentExecutions1. @@ -6332,11 +6353,17 @@ type SaveAndRunFromTemplateParams struct { // ResetProperties If `true`, all properties will be reset to properties specified in the template either with their fixed values in the template or via template placeholder. If `false`, existing properties will stay untouched, only new properties will be added. Only relevant for experiment updates via `externalId`. ResetProperties *bool `form:"resetProperties,omitempty" json:"resetProperties,omitempty"` - // AllowParallel Should this experiment also be executed when there is already another experiment running? + // AllowParallel Should this experiment also be executed when there is already another experiment running? Does not apply to runs of this experiment itself - see `allowRunningAgain`. AllowParallel *bool `form:"allowParallel,omitempty" json:"allowParallel,omitempty"` + // AllowRunningAgain By default, while this experiment is already running, the run in progress is returned. Set to `true` to start another run of it. Requires an `Idempotency-Key` header, so a retried request cannot start yet another run. Other experiments running are still governed by `allowParallel`. + AllowRunningAgain *bool `form:"allowRunningAgain,omitempty" json:"allowRunningAgain,omitempty"` + // ForcePersist Optional parameter to always store runs on any failure. If false, won´t be stored on validation errors (default behaviour). ForcePersist *bool `form:"forcePersist,omitempty" json:"forcePersist,omitempty"` + + // IdempotencyKey Optional key making the request safe to retry: while the run started with this key is in progress, the same request returns that run instead of starting another one. Any unique value, e.g. a UUID, up to 255 characters. Required with `allowRunningAgain=true`. + IdempotencyKey *string `json:"Idempotency-Key,omitempty"` } // UpdateExperimentByTemplateParams defines parameters for UpdateExperimentByTemplate. @@ -6380,11 +6407,17 @@ type GetExperimentBadgeParams struct { // ExecuteExperimentParams defines parameters for ExecuteExperiment. type ExecuteExperimentParams struct { - // AllowParallel By default an experiment is only executed when no other experiment is running. This can be overriden by starting the new experiment execution although another one is currently running + // AllowParallel By default an experiment is only executed when no other experiment is running. Set to `true` to start the execution anyway. Does not apply to runs of this experiment itself - see `allowRunningAgain`. AllowParallel *bool `form:"allowParallel,omitempty" json:"allowParallel,omitempty"` + // AllowRunningAgain By default, while this experiment is already running, the run in progress is returned. Set to `true` to start another run of it. Requires an `Idempotency-Key` header, so a retried request cannot start yet another run. Other experiments running are still governed by `allowParallel`. + AllowRunningAgain *bool `form:"allowRunningAgain,omitempty" json:"allowRunningAgain,omitempty"` + // ForcePersist Optional parameter to always store runs on any failure. If false, won´t be stored on validation errors (default behaviour). ForcePersist *bool `form:"forcePersist,omitempty" json:"forcePersist,omitempty"` + + // IdempotencyKey Optional key making the request safe to retry: while the run started with this key is in progress, the same request returns that run instead of starting another one. Any unique value, e.g. a UUID, up to 255 characters. Required with `allowRunningAgain=true`. + IdempotencyKey *string `json:"Idempotency-Key,omitempty"` } // GetExperimentExecutions3Params defines parameters for GetExperimentExecutions3. @@ -11735,7 +11768,7 @@ type ClientInterface interface { // SaveAndRunWithBody Save and run experiment // - // Save the given experiment and immediately run it. + // Save the given experiment and immediately run it. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes any type of body and a specified content type. // @@ -11744,7 +11777,7 @@ type ClientInterface interface { // SaveAndRun Save and run experiment // - // Save the given experiment and immediately run it. + // Save the given experiment and immediately run it. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes a body of the `application/json` content type. // @@ -11977,7 +12010,7 @@ type ClientInterface interface { // SaveAndRunFromTemplateWithBody Create an experiment based on an experiment template and run experiment // - // Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. + // Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes any type of body and a specified content type. // @@ -11986,7 +12019,7 @@ type ClientInterface interface { // SaveAndRunFromTemplate Create an experiment based on an experiment template and run experiment // - // Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. + // Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes a body of the `application/json` content type. // @@ -12070,6 +12103,20 @@ type ClientInterface interface { // } // ``` // + // While another experiment is running, depending on the platform's configuration, the execution is refused unless `allowParallel` is `true`. + // + // While this experiment is already running, the run in progress is returned instead of starting another one. To start another run of the same experiment, pass `allowRunningAgain=true` together with an `Idempotency-Key` header (any unique value, e.g. a UUID, up to 255 characters): + // ``` + // curl -X POST "https:///api/experiments/ADM-2/execute?allowRunningAgain=true" \ + // -H "Authorization: Bearer " \ + // -H "Idempotency-Key: $(uuidgen)" + // ``` + // `allowRunningAgain` only concerns runs of this experiment. While another experiment is running, the request is still refused unless `allowParallel` is `true` as well. + // + // The optional `Idempotency-Key` makes any execute request safe to retry: while the run started with that key is in progress, repeating the request with the same key returns that run instead of starting another one. Once the run has ended, the same key starts a new run. Use a new key for every run you want to start, and the same key only for retries of that request. + // + // Both a new run and a run returned instead answer with `201`. To tell them apart, compare the `executionId` of the response with the runs you already know of. + // // Takes any type of body and a specified content type. // // Corresponds with POST /api/experiments/{key}/execute (the `ExecuteExperiment` operationId). @@ -12095,6 +12142,20 @@ type ClientInterface interface { // } // ``` // + // While another experiment is running, depending on the platform's configuration, the execution is refused unless `allowParallel` is `true`. + // + // While this experiment is already running, the run in progress is returned instead of starting another one. To start another run of the same experiment, pass `allowRunningAgain=true` together with an `Idempotency-Key` header (any unique value, e.g. a UUID, up to 255 characters): + // ``` + // curl -X POST "https:///api/experiments/ADM-2/execute?allowRunningAgain=true" \ + // -H "Authorization: Bearer " \ + // -H "Idempotency-Key: $(uuidgen)" + // ``` + // `allowRunningAgain` only concerns runs of this experiment. While another experiment is running, the request is still refused unless `allowParallel` is `true` as well. + // + // The optional `Idempotency-Key` makes any execute request safe to retry: while the run started with that key is in progress, repeating the request with the same key returns that run instead of starting another one. Once the run has ended, the same key starts a new run. Use a new key for every run you want to start, and the same key only for retries of that request. + // + // Both a new run and a run returned instead answer with `201`. To tell them apart, compare the `executionId` of the response with the runs you already know of. + // // Takes a body of the `application/json` content type. // // Corresponds with POST /api/experiments/{key}/execute (the `ExecuteExperiment` operationId). @@ -13639,7 +13700,7 @@ func (c *Client) CreateOrUpdateExperiment(ctx context.Context, body CreateOrUpda // SaveAndRunWithBody Save and run experiment // -// Save the given experiment and immediately run it. +// Save the given experiment and immediately run it. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes any type of body and a specified content type. // @@ -13658,7 +13719,7 @@ func (c *Client) SaveAndRunWithBody(ctx context.Context, params *SaveAndRunParam // SaveAndRun Save and run experiment // -// Save the given experiment and immediately run it. +// Save the given experiment and immediately run it. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes a body of the `application/json` content type. // @@ -14180,7 +14241,7 @@ func (c *Client) CreateExperimentByTemplate(ctx context.Context, id openapi_type // SaveAndRunFromTemplateWithBody Create an experiment based on an experiment template and run experiment // -// Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. +// Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes any type of body and a specified content type. // @@ -14199,7 +14260,7 @@ func (c *Client) SaveAndRunFromTemplateWithBody(ctx context.Context, id openapi_ // SaveAndRunFromTemplate Create an experiment based on an experiment template and run experiment // -// Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. +// Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes a body of the `application/json` content type. // @@ -14363,6 +14424,20 @@ func (c *Client) GetExperimentBadge(ctx context.Context, key string, params *Get // } // ``` // +// While another experiment is running, depending on the platform's configuration, the execution is refused unless `allowParallel` is `true`. +// +// While this experiment is already running, the run in progress is returned instead of starting another one. To start another run of the same experiment, pass `allowRunningAgain=true` together with an `Idempotency-Key` header (any unique value, e.g. a UUID, up to 255 characters): +// ``` +// curl -X POST "https:///api/experiments/ADM-2/execute?allowRunningAgain=true" \ +// -H "Authorization: Bearer " \ +// -H "Idempotency-Key: $(uuidgen)" +// ``` +// `allowRunningAgain` only concerns runs of this experiment. While another experiment is running, the request is still refused unless `allowParallel` is `true` as well. +// +// The optional `Idempotency-Key` makes any execute request safe to retry: while the run started with that key is in progress, repeating the request with the same key returns that run instead of starting another one. Once the run has ended, the same key starts a new run. Use a new key for every run you want to start, and the same key only for retries of that request. +// +// Both a new run and a run returned instead answer with `201`. To tell them apart, compare the `executionId` of the response with the runs you already know of. +// // Takes any type of body and a specified content type. // // Corresponds with POST /api/experiments/{key}/execute (the `ExecuteExperiment` operationId). @@ -14398,6 +14473,20 @@ func (c *Client) ExecuteExperimentWithBody(ctx context.Context, key string, para // } // ``` // +// While another experiment is running, depending on the platform's configuration, the execution is refused unless `allowParallel` is `true`. +// +// While this experiment is already running, the run in progress is returned instead of starting another one. To start another run of the same experiment, pass `allowRunningAgain=true` together with an `Idempotency-Key` header (any unique value, e.g. a UUID, up to 255 characters): +// ``` +// curl -X POST "https:///api/experiments/ADM-2/execute?allowRunningAgain=true" \ +// -H "Authorization: Bearer " \ +// -H "Idempotency-Key: $(uuidgen)" +// ``` +// `allowRunningAgain` only concerns runs of this experiment. While another experiment is running, the request is still refused unless `allowParallel` is `true` as well. +// +// The optional `Idempotency-Key` makes any execute request safe to retry: while the run started with that key is in progress, repeating the request with the same key returns that run instead of starting another one. Once the run has ended, the same key starts a new run. Use a new key for every run you want to start, and the same key only for retries of that request. +// +// Both a new run and a run returned instead answer with `201`. To tell them apart, compare the `executionId` of the response with the runs you already know of. +// // Takes a body of the `application/json` content type. // // Corresponds with POST /api/experiments/{key}/execute (the `ExecuteExperiment` operationId). @@ -17965,6 +18054,18 @@ func NewSaveAndRunRequestWithBody(server string, params *SaveAndRunParams, conte } + if params.AllowRunningAgain != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "allowRunningAgain", *params.AllowRunningAgain, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.ForcePersist != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "forcePersist", *params.ForcePersist, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { @@ -17990,6 +18091,21 @@ func NewSaveAndRunRequestWithBody(server string, params *SaveAndRunParams, conte req.Header.Add("Content-Type", contentType) + if params != nil { + + if params.IdempotencyKey != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Idempotency-Key", *params.IdempotencyKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("Idempotency-Key", headerParam0) + } + + } + return req, nil } @@ -19063,6 +19179,18 @@ func NewSaveAndRunFromTemplateRequestWithBody(server string, id openapi_types.UU } + if params.AllowRunningAgain != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "allowRunningAgain", *params.AllowRunningAgain, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.ForcePersist != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "forcePersist", *params.ForcePersist, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { @@ -19088,6 +19216,21 @@ func NewSaveAndRunFromTemplateRequestWithBody(server string, id openapi_types.UU req.Header.Add("Content-Type", contentType) + if params != nil { + + if params.IdempotencyKey != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Idempotency-Key", *params.IdempotencyKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("Idempotency-Key", headerParam0) + } + + } + return req, nil } @@ -19510,6 +19653,18 @@ func NewExecuteExperimentRequestWithBody(server string, key string, params *Exec } + if params.AllowRunningAgain != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "allowRunningAgain", *params.AllowRunningAgain, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.ForcePersist != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "forcePersist", *params.ForcePersist, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { @@ -19535,6 +19690,21 @@ func NewExecuteExperimentRequestWithBody(server string, key string, params *Exec req.Header.Add("Content-Type", contentType) + if params != nil { + + if params.IdempotencyKey != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Idempotency-Key", *params.IdempotencyKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("Idempotency-Key", headerParam0) + } + + } + return req, nil } @@ -24100,7 +24270,7 @@ type ClientWithResponsesInterface interface { // SaveAndRunWithBodyWithResponse Save and run experiment // - // Save the given experiment and immediately run it. + // Save the given experiment and immediately run it. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -24109,7 +24279,7 @@ type ClientWithResponsesInterface interface { // SaveAndRunWithResponse Save and run experiment // - // Save the given experiment and immediately run it. + // Save the given experiment and immediately run it. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -24362,7 +24532,7 @@ type ClientWithResponsesInterface interface { // SaveAndRunFromTemplateWithBodyWithResponse Create an experiment based on an experiment template and run experiment // - // Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. + // Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -24371,7 +24541,7 @@ type ClientWithResponsesInterface interface { // SaveAndRunFromTemplateWithResponse Create an experiment based on an experiment template and run experiment // - // Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. + // Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -24461,6 +24631,20 @@ type ClientWithResponsesInterface interface { // } // ``` // + // While another experiment is running, depending on the platform's configuration, the execution is refused unless `allowParallel` is `true`. + // + // While this experiment is already running, the run in progress is returned instead of starting another one. To start another run of the same experiment, pass `allowRunningAgain=true` together with an `Idempotency-Key` header (any unique value, e.g. a UUID, up to 255 characters): + // ``` + // curl -X POST "https:///api/experiments/ADM-2/execute?allowRunningAgain=true" \ + // -H "Authorization: Bearer " \ + // -H "Idempotency-Key: $(uuidgen)" + // ``` + // `allowRunningAgain` only concerns runs of this experiment. While another experiment is running, the request is still refused unless `allowParallel` is `true` as well. + // + // The optional `Idempotency-Key` makes any execute request safe to retry: while the run started with that key is in progress, repeating the request with the same key returns that run instead of starting another one. Once the run has ended, the same key starts a new run. Use a new key for every run you want to start, and the same key only for retries of that request. + // + // Both a new run and a run returned instead answer with `201`. To tell them apart, compare the `executionId` of the response with the runs you already know of. + // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // // Corresponds with POST /api/experiments/{key}/execute (the `ExecuteExperiment` operationId). @@ -24486,6 +24670,20 @@ type ClientWithResponsesInterface interface { // } // ``` // + // While another experiment is running, depending on the platform's configuration, the execution is refused unless `allowParallel` is `true`. + // + // While this experiment is already running, the run in progress is returned instead of starting another one. To start another run of the same experiment, pass `allowRunningAgain=true` together with an `Idempotency-Key` header (any unique value, e.g. a UUID, up to 255 characters): + // ``` + // curl -X POST "https:///api/experiments/ADM-2/execute?allowRunningAgain=true" \ + // -H "Authorization: Bearer " \ + // -H "Idempotency-Key: $(uuidgen)" + // ``` + // `allowRunningAgain` only concerns runs of this experiment. While another experiment is running, the request is still refused unless `allowParallel` is `true` as well. + // + // The optional `Idempotency-Key` makes any execute request safe to retry: while the run started with that key is in progress, repeating the request with the same key returns that run instead of starting another one. Once the run has ended, the same key starts a new run. Use a new key for every run you want to start, and the same key only for retries of that request. + // + // Both a new run and a run returned instead answer with `201`. To tell them apart, compare the `executionId` of the response with the runs you already know of. + // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // // Corresponds with POST /api/experiments/{key}/execute (the `ExecuteExperiment` operationId). @@ -26572,6 +26770,10 @@ type SaveAndRunResponse struct { JSON200 *ExecuteExperimentResponseAO // YAML200 the response for an HTTP 200 `application/yaml` response YAML200 *ExecuteExperimentResponseAO + // JSON400 the response for an HTTP 400 `application/json` response + JSON400 *ExecuteExperimentResponseAO + // YAML400 the response for an HTTP 400 `application/yaml` response + YAML400 *ExecuteExperimentResponseAO // JSON422 the response for an HTTP 422 `application/json` response JSON422 *ExecuteExperimentResponseAO // YAML422 the response for an HTTP 422 `application/yaml` response @@ -26588,6 +26790,16 @@ func (r SaveAndRunResponse) GetYAML200() *ExecuteExperimentResponseAO { return r.YAML200 } +// GetJSON400 returns the response for an HTTP 400 `application/json` response +func (r SaveAndRunResponse) GetJSON400() *ExecuteExperimentResponseAO { + return r.JSON400 +} + +// GetYAML400 returns the response for an HTTP 400 `application/yaml` response +func (r SaveAndRunResponse) GetYAML400() *ExecuteExperimentResponseAO { + return r.YAML400 +} + // GetJSON422 returns the response for an HTTP 422 `application/json` response func (r SaveAndRunResponse) GetJSON422() *ExecuteExperimentResponseAO { return r.JSON422 @@ -27448,6 +27660,10 @@ type SaveAndRunFromTemplateResponse struct { JSON200 *ExecuteExperimentResponseAO // YAML200 the response for an HTTP 200 `application/yaml` response YAML200 *ExecuteExperimentResponseAO + // JSON400 the response for an HTTP 400 `application/json` response + JSON400 *ExecuteExperimentResponseAO + // YAML400 the response for an HTTP 400 `application/yaml` response + YAML400 *ExecuteExperimentResponseAO // JSON422 the response for an HTTP 422 `application/json` response JSON422 *ExecuteExperimentResponseAO // YAML422 the response for an HTTP 422 `application/yaml` response @@ -27464,6 +27680,16 @@ func (r SaveAndRunFromTemplateResponse) GetYAML200() *ExecuteExperimentResponseA return r.YAML200 } +// GetJSON400 returns the response for an HTTP 400 `application/json` response +func (r SaveAndRunFromTemplateResponse) GetJSON400() *ExecuteExperimentResponseAO { + return r.JSON400 +} + +// GetYAML400 returns the response for an HTTP 400 `application/yaml` response +func (r SaveAndRunFromTemplateResponse) GetYAML400() *ExecuteExperimentResponseAO { + return r.YAML400 +} + // GetJSON422 returns the response for an HTTP 422 `application/json` response func (r SaveAndRunFromTemplateResponse) GetJSON422() *ExecuteExperimentResponseAO { return r.JSON422 @@ -27694,10 +27920,18 @@ type ExecuteExperimentResponse struct { JSON201 *ExecuteExperimentResponseAO // YAML201 the response for an HTTP 201 `application/yaml` response YAML201 *ExecuteExperimentResponseAO + // JSON400 the response for an HTTP 400 `application/json` response + JSON400 *ExecuteExperimentResponseAO + // YAML400 the response for an HTTP 400 `application/yaml` response + YAML400 *ExecuteExperimentResponseAO // JSON404 the response for an HTTP 404 `application/json` response JSON404 *ExecuteExperimentResponseAO // YAML404 the response for an HTTP 404 `application/yaml` response YAML404 *ExecuteExperimentResponseAO + // JSON422 the response for an HTTP 422 `application/json` response + JSON422 *ExecuteExperimentResponseAO + // YAML422 the response for an HTTP 422 `application/yaml` response + YAML422 *ExecuteExperimentResponseAO } // GetJSON201 returns the response for an HTTP 201 `application/json` response @@ -27710,6 +27944,16 @@ func (r ExecuteExperimentResponse) GetYAML201() *ExecuteExperimentResponseAO { return r.YAML201 } +// GetJSON400 returns the response for an HTTP 400 `application/json` response +func (r ExecuteExperimentResponse) GetJSON400() *ExecuteExperimentResponseAO { + return r.JSON400 +} + +// GetYAML400 returns the response for an HTTP 400 `application/yaml` response +func (r ExecuteExperimentResponse) GetYAML400() *ExecuteExperimentResponseAO { + return r.YAML400 +} + // GetJSON404 returns the response for an HTTP 404 `application/json` response func (r ExecuteExperimentResponse) GetJSON404() *ExecuteExperimentResponseAO { return r.JSON404 @@ -27720,6 +27964,16 @@ func (r ExecuteExperimentResponse) GetYAML404() *ExecuteExperimentResponseAO { return r.YAML404 } +// GetJSON422 returns the response for an HTTP 422 `application/json` response +func (r ExecuteExperimentResponse) GetJSON422() *ExecuteExperimentResponseAO { + return r.JSON422 +} + +// GetYAML422 returns the response for an HTTP 422 `application/yaml` response +func (r ExecuteExperimentResponse) GetYAML422() *ExecuteExperimentResponseAO { + return r.YAML422 +} + // GetBody returns the raw response body bytes func (r ExecuteExperimentResponse) GetBody() []byte { return r.Body @@ -32404,7 +32658,7 @@ func (c *ClientWithResponses) CreateOrUpdateExperimentWithResponse(ctx context.C // SaveAndRunWithBodyWithResponse Save and run experiment // -// Save the given experiment and immediately run it. +// Save the given experiment and immediately run it. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -32419,7 +32673,7 @@ func (c *ClientWithResponses) SaveAndRunWithBodyWithResponse(ctx context.Context // SaveAndRunWithResponse Save and run experiment // -// Save the given experiment and immediately run it. +// Save the given experiment and immediately run it. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -32846,7 +33100,7 @@ func (c *ClientWithResponses) CreateExperimentByTemplateWithResponse(ctx context // SaveAndRunFromTemplateWithBodyWithResponse Create an experiment based on an experiment template and run experiment // -// Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. +// Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -32861,7 +33115,7 @@ func (c *ClientWithResponses) SaveAndRunFromTemplateWithBodyWithResponse(ctx con // SaveAndRunFromTemplateWithResponse Create an experiment based on an experiment template and run experiment // -// Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. +// Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -32999,6 +33253,20 @@ func (c *ClientWithResponses) GetExperimentBadgeWithResponse(ctx context.Context // } // ``` // +// While another experiment is running, depending on the platform's configuration, the execution is refused unless `allowParallel` is `true`. +// +// While this experiment is already running, the run in progress is returned instead of starting another one. To start another run of the same experiment, pass `allowRunningAgain=true` together with an `Idempotency-Key` header (any unique value, e.g. a UUID, up to 255 characters): +// ``` +// curl -X POST "https:///api/experiments/ADM-2/execute?allowRunningAgain=true" \ +// -H "Authorization: Bearer " \ +// -H "Idempotency-Key: $(uuidgen)" +// ``` +// `allowRunningAgain` only concerns runs of this experiment. While another experiment is running, the request is still refused unless `allowParallel` is `true` as well. +// +// The optional `Idempotency-Key` makes any execute request safe to retry: while the run started with that key is in progress, repeating the request with the same key returns that run instead of starting another one. Once the run has ended, the same key starts a new run. Use a new key for every run you want to start, and the same key only for retries of that request. +// +// Both a new run and a run returned instead answer with `201`. To tell them apart, compare the `executionId` of the response with the runs you already know of. +// // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // // Corresponds with POST /api/experiments/{key}/execute (the `ExecuteExperiment` operationId). @@ -33030,6 +33298,20 @@ func (c *ClientWithResponses) ExecuteExperimentWithBodyWithResponse(ctx context. // } // ``` // +// While another experiment is running, depending on the platform's configuration, the execution is refused unless `allowParallel` is `true`. +// +// While this experiment is already running, the run in progress is returned instead of starting another one. To start another run of the same experiment, pass `allowRunningAgain=true` together with an `Idempotency-Key` header (any unique value, e.g. a UUID, up to 255 characters): +// ``` +// curl -X POST "https:///api/experiments/ADM-2/execute?allowRunningAgain=true" \ +// -H "Authorization: Bearer " \ +// -H "Idempotency-Key: $(uuidgen)" +// ``` +// `allowRunningAgain` only concerns runs of this experiment. While another experiment is running, the request is still refused unless `allowParallel` is `true` as well. +// +// The optional `Idempotency-Key` makes any execute request safe to retry: while the run started with that key is in progress, repeating the request with the same key returns that run instead of starting another one. Once the run has ended, the same key starts a new run. Use a new key for every run you want to start, and the same key only for retries of that request. +// +// Both a new run and a run returned instead answer with `201`. To tell them apart, compare the `executionId` of the response with the runs you already know of. +// // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // // Corresponds with POST /api/experiments/{key}/execute (the `ExecuteExperiment` operationId). @@ -35547,6 +35829,20 @@ func ParseSaveAndRunResponse(rsp *http.Response) (*SaveAndRunResponse, error) { } response.YAML200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ExecuteExperimentResponseAO + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "yaml") && rsp.StatusCode == 400: + var dest ExecuteExperimentResponseAO + if err := yaml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.YAML400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: var dest ExecuteExperimentResponseAO if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -36114,6 +36410,20 @@ func ParseSaveAndRunFromTemplateResponse(rsp *http.Response) (*SaveAndRunFromTem } response.YAML200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ExecuteExperimentResponseAO + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "yaml") && rsp.StatusCode == 400: + var dest ExecuteExperimentResponseAO + if err := yaml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.YAML400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: var dest ExecuteExperimentResponseAO if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -36261,6 +36571,20 @@ func ParseExecuteExperimentResponse(rsp *http.Response) (*ExecuteExperimentRespo } response.YAML201 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ExecuteExperimentResponseAO + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "yaml") && rsp.StatusCode == 400: + var dest ExecuteExperimentResponseAO + if err := yaml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.YAML400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ExecuteExperimentResponseAO if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -36275,6 +36599,20 @@ func ParseExecuteExperimentResponse(rsp *http.Response) (*ExecuteExperimentRespo } response.YAML404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ExecuteExperimentResponseAO + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "yaml") && rsp.StatusCode == 422: + var dest ExecuteExperimentResponseAO + if err := yaml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.YAML422 = &dest + } return response, nil diff --git a/openapi/platform-api.json b/openapi/platform-api.json index fc39dee..e06c17f 100644 --- a/openapi/platform-api.json +++ b/openapi/platform-api.json @@ -8,7 +8,7 @@ "url": "https://steadybit.com", "email": "support@steadybit.com" }, - "version": "1.0.0-SNAPSHOT" + "version": "2.8.10" }, "externalDocs": { "description": "Steadybit Docs - Integrate via API", @@ -16,7 +16,7 @@ }, "servers": [ { - "url": "https://platform.dev.steadybit.com", + "url": "https://platform.steadybit.com", "description": "Generated server url" } ], @@ -3120,7 +3120,7 @@ "Experiment Executions" ], "summary": "Execute an experiment", - "description": " Trigger execution of a single experiment specified by `key`. The body is optional and allows to specify overrides and custom properties for the experiment execution.\n\n Examples:\n - Override environment from the experiment for a single run:\n ```\n {\n \"environment\": \"Shop Stage\"\n }\n ```\n - Override the variables for a single execution:\n ```\n {\n \"variables\": {\n \"httpEndpoint\": \"http://dev.shop.products.internal\"\n }\n }\n ```\n", + "description": " Trigger execution of a single experiment specified by `key`. The body is optional and allows to specify overrides and custom properties for the experiment execution.\n\n Examples:\n - Override environment from the experiment for a single run:\n ```\n {\n \"environment\": \"Shop Stage\"\n }\n ```\n - Override the variables for a single execution:\n ```\n {\n \"variables\": {\n \"httpEndpoint\": \"http://dev.shop.products.internal\"\n }\n }\n ```\n\n While another experiment is running, depending on the platform's configuration, the execution is refused unless `allowParallel` is `true`.\n\n While this experiment is already running, the run in progress is returned instead of starting another one. To start another run of the same experiment, pass `allowRunningAgain=true` together with an `Idempotency-Key` header (any unique value, e.g. a UUID, up to 255 characters):\n ```\n curl -X POST \"https:///api/experiments/ADM-2/execute?allowRunningAgain=true\" \\\n -H \"Authorization: Bearer \" \\\n -H \"Idempotency-Key: $(uuidgen)\"\n ```\n `allowRunningAgain` only concerns runs of this experiment. While another experiment is running, the request is still refused unless `allowParallel` is `true` as well.\n\n The optional `Idempotency-Key` makes any execute request safe to retry: while the run started with that key is in progress, repeating the request with the same key returns that run instead of starting another one. Once the run has ended, the same key starts a new run. Use a new key for every run you want to start, and the same key only for retries of that request.\n\n Both a new run and a run returned instead answer with `201`. To tell them apart, compare the `executionId` of the response with the runs you already know of.\n", "operationId": "executeExperiment", "parameters": [ { @@ -3136,12 +3136,31 @@ { "name": "allowParallel", "in": "query", - "description": "By default an experiment is only executed when no other experiment is running. This can be overriden by starting the new experiment execution although another one is currently running", + "description": "By default an experiment is only executed when no other experiment is running. Set to `true` to start the execution anyway. Does not apply to runs of this experiment itself - see `allowRunningAgain`.", "required": false, "schema": { "type": "boolean" }, - "example": true + "example": false + }, + { + "name": "allowRunningAgain", + "in": "query", + "description": "By default, while this experiment is already running, the run in progress is returned. Set to `true` to start another run of it. Requires an `Idempotency-Key` header, so a retried request cannot start yet another run. Other experiments running are still governed by `allowParallel`.", + "required": false, + "schema": { + "type": "boolean" + }, + "example": false + }, + { + "name": "Idempotency-Key", + "in": "header", + "description": "Optional key making the request safe to retry: while the run started with this key is in progress, the same request returns that run instead of starting another one. Any unique value, e.g. a UUID, up to 255 characters. Required with `allowRunningAgain=true`.", + "required": false, + "schema": { + "type": "string" + } }, { "name": "forcePersist", @@ -3165,7 +3184,22 @@ }, "responses": { "201": { - "description": "Experiment execution was started. The newly created object can be accessed via the HTTP header `location`", + "description": "Experiment execution was started, or a run of this experiment in progress was returned (see above). The object can be accessed via the HTTP header `location`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteExperimentResponseAO" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/ExecuteExperimentResponseAO" + } + } + } + }, + "400": { + "description": "The `Idempotency-Key` is longer than 255 characters, or missing while `allowRunningAgain` is `true`.", "content": { "application/json": { "schema": { @@ -3193,6 +3227,21 @@ } } } + }, + "422": { + "description": "Experiment execution was refused, e.g. because another experiment is running and `allowParallel` was not set - also when `allowRunningAgain` is set.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteExperimentResponseAO" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/ExecuteExperimentResponseAO" + } + } + } } } } @@ -3420,7 +3469,7 @@ "Experiment Templates" ], "summary": "Create an experiment based on an experiment template and run experiment", - "description": "Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted.", + "description": "Use the given experiment template id and the placeholder values to create or update and immediately run the experiment. The `externalId` will be used to identify whether the experiment exists already and should be updated or newly inserted. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request.", "operationId": "saveAndRunFromTemplate", "parameters": [ { @@ -3448,13 +3497,32 @@ { "name": "allowParallel", "in": "query", - "description": "Should this experiment also be executed when there is already another experiment running?", + "description": "Should this experiment also be executed when there is already another experiment running? Does not apply to runs of this experiment itself - see `allowRunningAgain`.", + "required": false, + "schema": { + "type": "boolean" + }, + "example": false + }, + { + "name": "allowRunningAgain", + "in": "query", + "description": "By default, while this experiment is already running, the run in progress is returned. Set to `true` to start another run of it. Requires an `Idempotency-Key` header, so a retried request cannot start yet another run. Other experiments running are still governed by `allowParallel`.", "required": false, "schema": { "type": "boolean" }, "example": false }, + { + "name": "Idempotency-Key", + "in": "header", + "description": "Optional key making the request safe to retry: while the run started with this key is in progress, the same request returns that run instead of starting another one. Any unique value, e.g. a UUID, up to 255 characters. Required with `allowRunningAgain=true`.", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "forcePersist", "in": "query", @@ -3478,7 +3546,22 @@ }, "responses": { "200": { - "description": "Experiment started", + "description": "Experiment started, or a run of this experiment in progress was returned (see above)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteExperimentResponseAO" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/ExecuteExperimentResponseAO" + } + } + } + }, + "400": { + "description": "The `Idempotency-Key` is longer than 255 characters, or missing while `allowRunningAgain` is `true`.", "content": { "application/json": { "schema": { @@ -3972,19 +4055,38 @@ "Experiment Designs" ], "summary": "Save and run experiment", - "description": "Save the given experiment and immediately run it.", + "description": "Save the given experiment and immediately run it. While the experiment is already running, the run in progress is returned instead of starting another one, unless `allowRunningAgain` is `true`. The `Idempotency-Key` covers the run only: the experiment is saved on every request.", "operationId": "saveAndRun", "parameters": [ { "name": "allowParallel", "in": "query", - "description": "Should this experiment also be executed when there is already at least one experiment running?", + "description": "Should this experiment also be executed when there is already at least one other experiment running? Does not apply to runs of this experiment itself - see `allowRunningAgain`.", + "required": false, + "schema": { + "type": "boolean" + }, + "example": false + }, + { + "name": "allowRunningAgain", + "in": "query", + "description": "By default, while this experiment is already running, the run in progress is returned. Set to `true` to start another run of it. Requires an `Idempotency-Key` header, so a retried request cannot start yet another run. Other experiments running are still governed by `allowParallel`.", "required": false, "schema": { "type": "boolean" }, "example": false }, + { + "name": "Idempotency-Key", + "in": "header", + "description": "Optional key making the request safe to retry: while the run started with this key is in progress, the same request returns that run instead of starting another one. Any unique value, e.g. a UUID, up to 255 characters. Required with `allowRunningAgain=true`.", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "forcePersist", "in": "query", @@ -4008,7 +4110,22 @@ }, "responses": { "200": { - "description": "Experiment started", + "description": "Experiment started, or a run of this experiment in progress was returned (see above)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteExperimentResponseAO" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/ExecuteExperimentResponseAO" + } + } + } + }, + "400": { + "description": "The `Idempotency-Key` is longer than 255 characters, or missing while `allowRunningAgain` is `true`.", "content": { "application/json": { "schema": { @@ -7187,16 +7304,16 @@ } }, "VariableExpressionAO": { - "description": "A variable value: a constant string (≤5000 chars), an array of constant strings, or a select expression object.", + "description": "A variable value: a constant string (≤10000 chars), an array of constant strings, or a select expression object.", "oneOf": [ { - "maxLength": 5000, + "maxLength": 10000, "type": "string" }, { "type": "array", "items": { - "maxLength": 5000, + "maxLength": 10000, "type": "string" } }, @@ -8792,7 +8909,7 @@ "x-go-type": "interface{}" } }, - "description": "List of template placeholder values" + "description": "List of template placeholder values. The `SERVICE` placeholder defaults to the id of the service and only needs to be passed to override it." }, "UpsertProvidedExperimentRequestAO": { "required": [ @@ -8814,7 +8931,7 @@ }, "placeholders": { "type": "array", - "description": "List of template placeholder values", + "description": "List of template placeholder values. The `SERVICE` placeholder defaults to the id of the service and only needs to be passed to override it.", "nullable": true, "items": { "$ref": "#/components/schemas/ExperimentTemplatePlaceholderValueAO" @@ -12148,9 +12265,14 @@ }, "allowParallel": { "type": "boolean", - "description": "Should the experiment run if another experiment is running? Default is true.", + "description": "Should the experiment run if another experiment is running? Does not apply to runs of this experiment itself - see `allowRunningAgain`. Default is true.", "example": true }, + "allowRunningAgain": { + "type": "boolean", + "description": "Should the experiment run while a run of this same experiment is still in progress? `allowParallel` only covers other experiments. Default is false.", + "example": false + }, "timezone": { "type": "string", "description": "Optional timezone for a experiment schedule. Can only be used with `cron`.", @@ -12214,9 +12336,14 @@ }, "allowParallel": { "type": "boolean", - "description": "Should the experiment run if another experiment is running? Default is true.", + "description": "Should the experiment run if another experiment is running? Does not apply to runs of this experiment itself - see `allowRunningAgain`. Default is true.", "example": true }, + "allowRunningAgain": { + "type": "boolean", + "description": "Should the experiment run while a run of this same experiment is still in progress? `allowParallel` only covers other experiments. Default is false.", + "example": false + }, "timezone": { "type": "string", "description": "Optional timezone for a experiment schedule. Can only be used with `cron`.", @@ -12256,6 +12383,7 @@ "cron": "30 * * * * ? *", "enabled": true, "allowParallel": true, + "allowRunningAgain": false, "timezone": "Europe/Berlin", "variables": {}, "id": "01951394-727f-76a0-8675-c7519ebd0ff5", @@ -13279,10 +13407,16 @@ }, "allowParallel": { "type": "boolean", - "description": "Should the experiment run if another experiment is running?", + "description": "Should the experiment run if another experiment is running? Does not apply to runs of this experiment itself - see `allowRunningAgain`.", "nullable": true, "example": true }, + "allowRunningAgain": { + "type": "boolean", + "description": "Should the experiment run while a run of this same experiment is still in progress?", + "nullable": true, + "example": false + }, "timezone": { "type": "string", "description": "Optional timezone for a experiment schedule. Can only be used with `cron`.",