From 07964864f2f3d64fb63382e83d352cb6ecd935c6 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 19 Jun 2024 13:28:13 +0200 Subject: [PATCH 001/215] refactor: move client to separate package --- buildingblock.go | 90 +++++++++++++++ client.go | 216 ++++++++++++++++++++++++++++++++++++ project.go | 236 ++++++++++++++++++++++++++++++++++++++++ project_user_binding.go | 120 ++++++++++++++++++++ tenant.go | 138 +++++++++++++++++++++++ 5 files changed, 800 insertions(+) create mode 100644 buildingblock.go create mode 100644 client.go create mode 100644 project.go create mode 100644 project_user_binding.go create mode 100644 tenant.go diff --git a/buildingblock.go b/buildingblock.go new file mode 100644 index 00000000..5580e7b8 --- /dev/null +++ b/buildingblock.go @@ -0,0 +1,90 @@ +package client + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +type MeshBuildingBlock struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshBuildingBlockMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` + Status MeshBuildingBlockStatus `json:"status" tfsdk:"status"` +} + +type MeshBuildingBlockMetadata struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` + DefinitionVersion int64 `json:"definitionVersion" tfsdk:"definition_version"` + TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + MarkedForDeletionOn *string `json:"markedForDeletionOn" tfsdk:"marked_for_deletion_on"` + MarkedForDeletionBy *string `json:"markedForDeletionBy" tfsdk:"marked_for_deletion_by"` +} + +type MeshBuildingBlockSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Inputs []MeshBuildingBlockIO `json:"inputs" tfsdk:"inputs"` + ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` +} + +type MeshBuildingBlockIO struct { + Key string `json:"key" tfsdk:"key"` + Value interface{} `json:"value" tfsdk:"value"` + ValueType string `json:"valueType" tfsdk:"value_type"` +} + +type MeshBuildingBlockParent struct { + BuildingBlockUuid string `json:"buildingBlockUuid" tfsdk:"buildingblock_uuid"` + DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` +} + +type MeshBuildingBlockStatus struct { + Status string `json:"status" tfsdk:"status"` + Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` +} + +func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingBlock, error) { + if c.ensureValidToken() != nil { + return nil, errors.New(ERROR_AUTHENTICATION_FAILURE) + } + + targetUrl := c.endpoints.BuildingBlocks.JoinPath(uuid) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode == 404 { + return nil, nil + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var bb MeshBuildingBlock + err = json.Unmarshal(data, &bb) + if err != nil { + return nil, err + } + + return &bb, nil +} diff --git a/client.go b/client.go new file mode 100644 index 00000000..9158f69e --- /dev/null +++ b/client.go @@ -0,0 +1,216 @@ +package client + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + apiMeshObjectsRoot = "/api/meshobjects" + loginEndpoint = "/api/login" + + ERROR_GENERIC_CLIENT_ERROR = "client error" + ERROR_GENERIC_API_ERROR = "api error" + ERROR_AUTHENTICATION_FAILURE = "Not authorized. Check api key and secret." + ERROR_ENDPOINT_LOOKUP = "Could not fetch endpoints for meshStack." + + CONTENT_TYPE_PROJECT = "application/vnd.meshcloud.api.meshproject.v2.hal+json" + CONTENT_TYPE_TENANT = "application/vnd.meshcloud.api.meshtenant.v3.hal+json" + CONTENT_TYPE_PROJECT_USER_BINDINGS = "application/vnd.meshcloud.api.meshprojectuserbinding.v1.hal+json" + CONTENT_TYPE_PROJECT_USER_BINDING = "application/vnd.meshcloud.api.meshprojectuserbinding.v3.hal+json" +) + +type MeshStackProviderClient struct { + url *url.URL + httpClient *http.Client + apiKey string + apiSecret string + token string + tokenExpiry time.Time + endpoints endpoints +} + +type endpoints struct { + BuildingBlocks *url.URL `json:"meshbuildingblocks"` + Projects *url.URL `json:"meshprojects"` + ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` + Tenants *url.URL `json:"meshtenants"` +} + +type loginResponse struct { + Token string `json:"access_token"` + ExpireSec int `json:"expires_in"` +} + +func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackProviderClient, error) { + client := &MeshStackProviderClient{ + url: rootUrl, + httpClient: &http.Client{ + Timeout: time.Minute * 5, + }, + apiKey: apiKey, + apiSecret: apiSecret, + token: "", + } + + // TODO: lookup endpoints + client.endpoints = endpoints{ + BuildingBlocks: rootUrl.JoinPath(apiMeshObjectsRoot, "meshbuildingblocks"), + Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), + ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), + Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), + } + + return client, nil +} + +func (c *MeshStackProviderClient) login() error { + loginPath, err := url.JoinPath(c.url.String(), loginEndpoint) + if err != nil { + return err + } + + formData := url.Values{} + formData.Set("client_id", c.apiKey) + formData.Set("client_secret", c.apiSecret) + formData.Set("grant_type", "client_credentials") + + req, _ := http.NewRequest(http.MethodPost, loginPath, strings.NewReader(formData.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := c.httpClient.Do(req) + + if err != nil || res.StatusCode != 200 { + return errors.New(ERROR_AUTHENTICATION_FAILURE) + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + var loginResult loginResponse + err = json.Unmarshal(data, &loginResult) + if err != nil { + return err + } + + c.token = fmt.Sprintf("Bearer %s", loginResult.Token) + c.tokenExpiry = time.Now().Add(time.Second * time.Duration(loginResult.ExpireSec)) + + return nil +} + +func (c *MeshStackProviderClient) ensureValidToken() error { + if c.token == "" || time.Now().Add(time.Second*30).After(c.tokenExpiry) { + return c.login() + } + return nil +} + +// nolint: unused +func (c *MeshStackProviderClient) lookUpEndpoints() error { + if c.ensureValidToken() != nil { + return errors.New(ERROR_AUTHENTICATION_FAILURE) + } + + meshObjectsPath, err := url.JoinPath(c.url.String(), apiMeshObjectsRoot) + if err != nil { + return err + } + meshObjects, _ := url.Parse(meshObjectsPath) + + res, err := c.httpClient.Do( + &http.Request{ + URL: meshObjects, + Method: "GET", + Header: http.Header{ + "Authorization": {c.token}, + }, + }, + ) + + if err != nil { + return errors.New(ERROR_GENERIC_CLIENT_ERROR) + } + + defer res.Body.Close() + + if res.StatusCode != 200 { + return errors.New(ERROR_AUTHENTICATION_FAILURE) + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + var endpoints endpoints + err = json.Unmarshal(data, &endpoints) + if err != nil { + return err + } + + c.endpoints = endpoints + return nil +} + +func (c *MeshStackProviderClient) doAuthenticatedRequest(req *http.Request) (*http.Response, error) { + // ensure that headeres are initialized + if req.Header == nil { + req.Header = map[string][]string{} + } + req.Header.Set("User-Agent", "meshStack Terraform Provider") + + // log request before adding auth + log.Println(req) + + // add authentication + if c.ensureValidToken() != nil { + return nil, errors.New(ERROR_AUTHENTICATION_FAILURE) + } + req.Header.Set("Authorization", c.token) + + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + log.Println(res) + + return res, nil +} + +func (c *MeshStackProviderClient) deleteMeshObject(targetUrl url.URL, expectedStatus int) error { + req, err := http.NewRequest("DELETE", targetUrl.String(), nil) + if err != nil { + return err + } + + res, err := c.doAuthenticatedRequest(req) + + if err != nil { + return errors.New(ERROR_GENERIC_CLIENT_ERROR) + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + if res.StatusCode != expectedStatus { + return fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + return nil +} diff --git a/project.go b/project.go new file mode 100644 index 00000000..f9d97f85 --- /dev/null +++ b/project.go @@ -0,0 +1,236 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type MeshProject struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshProjectMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshProjectSpec `json:"spec" tfsdk:"spec"` +} + +type MeshProjectMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` +} + +type MeshProjectSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` + PaymentMethodIdentifier *string `json:"paymentMethodIdentifier" tfsdk:"payment_method_identifier"` + SubstitutePaymentMethodIdentifier *string `json:"substitutePaymentMethodIdentifier" tfsdk:"substitute_payment_method_identifier"` +} + +type MeshProjectCreate struct { + Metadata MeshProjectCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshProjectSpec `json:"spec" tfsdk:"spec"` +} + +type MeshProjectCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +func (c *MeshStackProviderClient) urlForProject(workspace string, name string) *url.URL { + identifier := workspace + "." + name + return c.endpoints.Projects.JoinPath(identifier) +} + +func (c *MeshStackProviderClient) ReadProject(workspace string, name string) (*MeshProject, error) { + targetUrl := c.urlForProject(workspace, name) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_PROJECT) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode == 404 { + return nil, nil + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var project MeshProject + err = json.Unmarshal(data, &project) + if err != nil { + return nil, err + } + + return &project, nil +} + +func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, paymentMethodIdentifier *string) (*[]MeshProject, error) { + var allProjects []MeshProject + + pageNumber := 0 + targetUrl := c.endpoints.Projects + query := targetUrl.Query() + query.Set("workspaceIdentifier", workspaceIdentifier) + if paymentMethodIdentifier != nil { + query.Set("paymentIdentifier", *paymentMethodIdentifier) + } + + for { + query.Set("page", fmt.Sprintf("%d", pageNumber)) + + targetUrl.RawQuery = query.Encode() + + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", CONTENT_TYPE_PROJECT) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var response struct { + Embedded struct { + MeshProjects []MeshProject `json:"meshProjects"` + } `json:"_embedded"` + Page struct { + Size int `json:"size"` + TotalElements int `json:"totalElements"` + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` + } + + err = json.Unmarshal(data, &response) + if err != nil { + return nil, err + } + + allProjects = append(allProjects, response.Embedded.MeshProjects...) + + // Check if there are more pages + if response.Page.Number >= response.Page.TotalPages-1 { + break + } + + pageNumber++ + } + + return &allProjects, nil +} + +func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*MeshProject, error) { + payload, err := json.Marshal(project) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.Projects.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT) + req.Header.Set("Accept", CONTENT_TYPE_PROJECT) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode != 201 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdProject MeshProject + err = json.Unmarshal(data, &createdProject) + if err != nil { + return nil, err + } + + return &createdProject, nil +} + +func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*MeshProject, error) { + targetUrl := c.urlForProject(project.Metadata.OwnedByWorkspace, project.Metadata.Name) + + payload, err := json.Marshal(project) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT) + req.Header.Set("Accept", CONTENT_TYPE_PROJECT) + + res, err := c.doAuthenticatedRequest(req) + + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var updatedProject MeshProject + err = json.Unmarshal(data, &updatedProject) + if err != nil { + return nil, err + } + + return &updatedProject, nil +} + +func (c *MeshStackProviderClient) DeleteProject(workspace string, name string) error { + targetUrl := c.urlForProject(workspace, name) + return c.deleteMeshObject(*targetUrl, 202) +} diff --git a/project_user_binding.go b/project_user_binding.go new file mode 100644 index 00000000..0c6f6014 --- /dev/null +++ b/project_user_binding.go @@ -0,0 +1,120 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type MeshProjectUserBinding struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshProjectUserBindingMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshProjectRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshProjectTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshSubject `json:"subject" tfsdk:"subject"` +} + +type MeshProjectUserBindingMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshProjectRoleRef struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshProjectTargetRef struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshSubject struct { + Name string `json:"name" tfsdk:"name"` +} + +func (c *MeshStackProviderClient) urlForPojectUserBinding(name string) *url.URL { + return c.endpoints.ProjectUserBindings.JoinPath(name) +} + +func (c *MeshStackProviderClient) ReadProjectUserBinding(name string) (*MeshProjectUserBinding, error) { + targetUrl := c.urlForPojectUserBinding(name) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_PROJECT_USER_BINDING) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode == 404 { + return nil, nil + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var binding MeshProjectUserBinding + err = json.Unmarshal(data, &binding) + if err != nil { + return nil, err + } + + return &binding, nil +} + +func (c *MeshStackProviderClient) CreateProjectUserBinding(binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { + payload, err := json.Marshal(binding) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.ProjectUserBindings.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT_USER_BINDING) + req.Header.Set("Accept", CONTENT_TYPE_PROJECT_USER_BINDING) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdBinding MeshProjectUserBinding + err = json.Unmarshal(data, &createdBinding) + if err != nil { + return nil, err + } + + return &createdBinding, nil +} + +func (c *MeshStackProviderClient) DeleteProjecUserBinding(name string) error { + targetUrl := c.urlForPojectUserBinding(name) + return c.deleteMeshObject(*targetUrl, 204) +} diff --git a/tenant.go b/tenant.go new file mode 100644 index 00000000..b8026aaa --- /dev/null +++ b/tenant.go @@ -0,0 +1,138 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type MeshTenant struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshTenantMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantSpec `json:"spec" tfsdk:"spec"` +} + +type MeshTenantMetadata struct { + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` + AssignedTags map[string][]string `json:"assignedTags" tfsdk:"assigned_tags"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` +} + +type MeshTenantSpec struct { + LocalId *string `json:"localId" tfsdk:"local_id"` + LandingZoneIdentifier string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` + Quotas []MeshTenantQuota `json:"quotas" tfsdk:"quotas"` +} + +type MeshTenantQuota struct { + Key string `json:"key" tfsdk:"key"` + Value int64 `json:"value" tfsdk:"value"` +} + +type MeshTenantCreate struct { + Metadata MeshTenantCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantCreateSpec `json:"spec" tfsdk:"spec"` +} + +type MeshTenantCreateMetadata struct { + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` +} + +type MeshTenantCreateSpec struct { + LocalId *string `json:"localId" tfsdk:"local_id"` + LandingZoneIdentifier *string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` + Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` +} + +func (c *MeshStackProviderClient) urlForTenant(workspace string, project string, platform string) *url.URL { + identifier := workspace + "." + project + "." + platform + return c.endpoints.Tenants.JoinPath(identifier) +} + +func (c *MeshStackProviderClient) ReadTenant(workspace string, project string, platform string) (*MeshTenant, error) { + targetUrl := c.urlForTenant(workspace, project, platform) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_TENANT) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode == 404 { + return nil, nil + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var tenant MeshTenant + err = json.Unmarshal(data, &tenant) + if err != nil { + return nil, err + } + + return &tenant, nil +} + +func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshTenant, error) { + payload, err := json.Marshal(tenant) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.Tenants.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_TENANT) + req.Header.Set("Accept", CONTENT_TYPE_TENANT) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode != 201 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdTenant MeshTenant + err = json.Unmarshal(data, &createdTenant) + if err != nil { + return nil, err + } + + return &createdTenant, nil +} + +func (c *MeshStackProviderClient) DeleteTenant(workspace string, project string, platform string) error { + targetUrl := c.urlForTenant(workspace, project, platform) + return c.deleteMeshObject(*targetUrl, 202) +} From a0baddf16cbeaa2bf0f1380f6860af0dd71a7fc3 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Fri, 21 Jun 2024 16:10:36 +0200 Subject: [PATCH 002/215] feat: project group bindings --- client.go | 23 +++---- project.go | 2 + project_binding.go | 134 +++++++++++++++++++++++++++++++++++++++ project_group_binding.go | 26 ++++++++ project_user_binding.go | 102 ++--------------------------- tenant.go | 2 + 6 files changed, 178 insertions(+), 111 deletions(-) create mode 100644 project_binding.go create mode 100644 project_group_binding.go diff --git a/client.go b/client.go index 9158f69e..281d0401 100644 --- a/client.go +++ b/client.go @@ -20,11 +20,6 @@ const ( ERROR_GENERIC_API_ERROR = "api error" ERROR_AUTHENTICATION_FAILURE = "Not authorized. Check api key and secret." ERROR_ENDPOINT_LOOKUP = "Could not fetch endpoints for meshStack." - - CONTENT_TYPE_PROJECT = "application/vnd.meshcloud.api.meshproject.v2.hal+json" - CONTENT_TYPE_TENANT = "application/vnd.meshcloud.api.meshtenant.v3.hal+json" - CONTENT_TYPE_PROJECT_USER_BINDINGS = "application/vnd.meshcloud.api.meshprojectuserbinding.v1.hal+json" - CONTENT_TYPE_PROJECT_USER_BINDING = "application/vnd.meshcloud.api.meshprojectuserbinding.v3.hal+json" ) type MeshStackProviderClient struct { @@ -38,10 +33,11 @@ type MeshStackProviderClient struct { } type endpoints struct { - BuildingBlocks *url.URL `json:"meshbuildingblocks"` - Projects *url.URL `json:"meshprojects"` - ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` - Tenants *url.URL `json:"meshtenants"` + BuildingBlocks *url.URL `json:"meshbuildingblocks"` + Projects *url.URL `json:"meshprojects"` + ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` + ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` + Tenants *url.URL `json:"meshtenants"` } type loginResponse struct { @@ -62,10 +58,11 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro // TODO: lookup endpoints client.endpoints = endpoints{ - BuildingBlocks: rootUrl.JoinPath(apiMeshObjectsRoot, "meshbuildingblocks"), - Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), - ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), - Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), + BuildingBlocks: rootUrl.JoinPath(apiMeshObjectsRoot, "meshbuildingblocks"), + Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), + ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), + ProjectGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "groupbindings"), + Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), } return client, nil diff --git a/project.go b/project.go index f9d97f85..eca75ca6 100644 --- a/project.go +++ b/project.go @@ -9,6 +9,8 @@ import ( "net/url" ) +const CONTENT_TYPE_PROJECT = "application/vnd.meshcloud.api.meshproject.v2.hal+json" + type MeshProject struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` diff --git a/project_binding.go b/project_binding.go new file mode 100644 index 00000000..f3b4d419 --- /dev/null +++ b/project_binding.go @@ -0,0 +1,134 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type MeshProjectBinding struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshProjectBindingMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshProjectRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshProjectTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshSubject `json:"subject" tfsdk:"subject"` +} + +type MeshProjectBindingMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshProjectRoleRef struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshProjectTargetRef struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshSubject struct { + Name string `json:"name" tfsdk:"name"` +} + +func (c *MeshStackProviderClient) readProjectBinding(name string, contentType string) (*MeshProjectBinding, error) { + var targetUrl *url.URL + switch contentType { + case CONTENT_TYPE_PROJECT_USER_BINDING: + targetUrl = c.urlForPojectUserBinding(name) + + case CONTENT_TYPE_PROJECT_GROUP_BINDING: + targetUrl = c.urlForPojectGroupBinding(name) + + default: + return nil, fmt.Errorf("Unexpected content type: %s", contentType) + } + + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", contentType) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode == 404 { + return nil, nil + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var binding MeshProjectBinding + err = json.Unmarshal(data, &binding) + if err != nil { + return nil, err + } + + return &binding, nil +} + +func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBinding, contentType string) (*MeshProjectBinding, error) { + var targetUrl *url.URL + switch contentType { + case CONTENT_TYPE_PROJECT_USER_BINDING: + targetUrl = c.endpoints.ProjectUserBindings + + case CONTENT_TYPE_PROJECT_GROUP_BINDING: + targetUrl = c.endpoints.ProjectGroupBindings + + default: + return nil, fmt.Errorf("Unexpected content type: %s", contentType) + } + + payload, err := json.Marshal(binding) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", targetUrl.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT_GROUP_BINDING) + req.Header.Set("Accept", CONTENT_TYPE_PROJECT_GROUP_BINDING) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdBinding MeshProjectBinding + err = json.Unmarshal(data, &createdBinding) + if err != nil { + return nil, err + } + + return &createdBinding, nil +} diff --git a/project_group_binding.go b/project_group_binding.go new file mode 100644 index 00000000..8b66818c --- /dev/null +++ b/project_group_binding.go @@ -0,0 +1,26 @@ +package client + +import ( + "net/url" +) + +const CONTENT_TYPE_PROJECT_GROUP_BINDING = "application/vnd.meshcloud.api.meshprojectgroupbinding.v3.hal+json" + +type MeshProjectGroupBinding = MeshProjectBinding + +func (c *MeshStackProviderClient) urlForPojectGroupBinding(name string) *url.URL { + return c.endpoints.ProjectGroupBindings.JoinPath(name) +} + +func (c *MeshStackProviderClient) ReadProjectGroupBinding(name string) (*MeshProjectGroupBinding, error) { + return c.readProjectBinding(name, CONTENT_TYPE_PROJECT_GROUP_BINDING) +} + +func (c *MeshStackProviderClient) CreateProjectGroupBinding(binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { + return c.createProjectBinding(binding, CONTENT_TYPE_PROJECT_GROUP_BINDING) +} + +func (c *MeshStackProviderClient) DeleteProjecGroupBinding(name string) error { + targetUrl := c.urlForPojectGroupBinding(name) + return c.deleteMeshObject(*targetUrl, 204) +} diff --git a/project_user_binding.go b/project_user_binding.go index 0c6f6014..3de8e225 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -1,117 +1,23 @@ package client import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" "net/url" ) -type MeshProjectUserBinding struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshProjectUserBindingMetadata `json:"metadata" tfsdk:"metadata"` - RoleRef MeshProjectRoleRef `json:"roleRef" tfsdk:"role_ref"` - TargetRef MeshProjectTargetRef `json:"targetRef" tfsdk:"target_ref"` - Subject MeshSubject `json:"subject" tfsdk:"subject"` -} - -type MeshProjectUserBindingMetadata struct { - Name string `json:"name" tfsdk:"name"` -} - -type MeshProjectRoleRef struct { - Name string `json:"name" tfsdk:"name"` -} - -type MeshProjectTargetRef struct { - Name string `json:"name" tfsdk:"name"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` -} +const CONTENT_TYPE_PROJECT_USER_BINDING = "application/vnd.meshcloud.api.meshprojectuserbinding.v3.hal+json" -type MeshSubject struct { - Name string `json:"name" tfsdk:"name"` -} +type MeshProjectUserBinding = MeshProjectBinding func (c *MeshStackProviderClient) urlForPojectUserBinding(name string) *url.URL { return c.endpoints.ProjectUserBindings.JoinPath(name) } func (c *MeshStackProviderClient) ReadProjectUserBinding(name string) (*MeshProjectUserBinding, error) { - targetUrl := c.urlForPojectUserBinding(name) - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_PROJECT_USER_BINDING) - - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - defer res.Body.Close() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if res.StatusCode == 404 { - return nil, nil - } - - if res.StatusCode != 200 { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - - var binding MeshProjectUserBinding - err = json.Unmarshal(data, &binding) - if err != nil { - return nil, err - } - - return &binding, nil + return c.readProjectBinding(name, CONTENT_TYPE_PROJECT_USER_BINDING) } func (c *MeshStackProviderClient) CreateProjectUserBinding(binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { - payload, err := json.Marshal(binding) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.ProjectUserBindings.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT_USER_BINDING) - req.Header.Set("Accept", CONTENT_TYPE_PROJECT_USER_BINDING) - - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - defer res.Body.Close() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if res.StatusCode != 200 { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - - var createdBinding MeshProjectUserBinding - err = json.Unmarshal(data, &createdBinding) - if err != nil { - return nil, err - } - - return &createdBinding, nil + return c.createProjectBinding(binding, CONTENT_TYPE_PROJECT_USER_BINDING) } func (c *MeshStackProviderClient) DeleteProjecUserBinding(name string) error { diff --git a/tenant.go b/tenant.go index b8026aaa..b83c5e60 100644 --- a/tenant.go +++ b/tenant.go @@ -9,6 +9,8 @@ import ( "net/url" ) +const CONTENT_TYPE_TENANT = "application/vnd.meshcloud.api.meshtenant.v3.hal+json" + type MeshTenant struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` From aa3165b031974ac9d17d5109aaa9af9cd6ae7941 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Fri, 28 Jun 2024 15:28:43 +0200 Subject: [PATCH 003/215] feat: building block resource --- buildingblock.go | 80 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index 5580e7b8..8b8a7d66 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -1,11 +1,23 @@ package client import ( + "bytes" "encoding/json" - "errors" "fmt" "io" "net/http" + "net/url" +) + +const ( + MESH_BUILDING_BLOCK_IO_TYPE_STRING = "STRING" + MESH_BUILDING_BLOCK_IO_TYPE_INTEGER = "INTEGER" + MESH_BUILDING_BLOCK_IO_TYPE_BOOLEAN = "BOOLEAN" + MESH_BUILDING_BLOCK_IO_TYPE_SINGLE_SELECT = "SINGLE_SELECT" + MESH_BUILDING_BLOCK_IO_TYPE_FILE = "FILE" + MESH_BUILDING_BLOCK_IO_TYPE_LIST = "LIST" + + CONTENT_TYPE_BUILDING_BLOCK = "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json" ) type MeshBuildingBlock struct { @@ -49,12 +61,25 @@ type MeshBuildingBlockStatus struct { Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` } -func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingBlock, error) { - if c.ensureValidToken() != nil { - return nil, errors.New(ERROR_AUTHENTICATION_FAILURE) - } +type MeshBuildingBlockCreate struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshBuildingBlockCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` +} + +type MeshBuildingBlockCreateMetadata struct { + DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` + DefinitionVersion int64 `json:"definitionVersion" tfsdk:"definition_version"` + TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` +} + +func (c *MeshStackProviderClient) urlForBuildingBlock(uuid string) *url.URL { + return c.endpoints.BuildingBlocks.JoinPath(uuid) +} - targetUrl := c.endpoints.BuildingBlocks.JoinPath(uuid) +func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingBlock, error) { + targetUrl := c.urlForBuildingBlock(uuid) req, err := http.NewRequest("GET", targetUrl.String(), nil) if err != nil { return nil, err @@ -88,3 +113,46 @@ func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingB return &bb, nil } + +func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { + payload, err := json.Marshal(bb) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.BuildingBlocks.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_BUILDING_BLOCK) + req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode != 201 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdBb MeshBuildingBlock + err = json.Unmarshal(data, &createdBb) + if err != nil { + return nil, err + } + + return &createdBb, nil +} + +func (c *MeshStackProviderClient) DeleteBuildingBlock(uuid string) error { + targetUrl := c.urlForBuildingBlock(uuid) + return c.deleteMeshObject(*targetUrl, 202) +} From 0947438b93a0610f9d8a4717ec9f79b3956501fc Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Thu, 4 Jul 2024 11:38:42 +0200 Subject: [PATCH 004/215] fix: updated status codes --- buildingblock.go | 2 +- project.go | 2 +- tenant.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index 8b8a7d66..44d1e020 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -139,7 +139,7 @@ func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreat return nil, err } - if res.StatusCode != 201 { + if res.StatusCode != 200 { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } diff --git a/project.go b/project.go index eca75ca6..9dde4315 100644 --- a/project.go +++ b/project.go @@ -178,7 +178,7 @@ func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*Me return nil, err } - if res.StatusCode != 201 { + if res.StatusCode != 200 { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } diff --git a/tenant.go b/tenant.go index b83c5e60..9a4775a6 100644 --- a/tenant.go +++ b/tenant.go @@ -121,7 +121,7 @@ func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshT return nil, err } - if res.StatusCode != 201 { + if res.StatusCode != 200 { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } From 7cdf4b7eed1d4d52a20d37136c1d184e4f66e740 Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Tue, 9 Jul 2024 14:02:15 +0200 Subject: [PATCH 005/215] fix: contentType for project user binding --- project_binding.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project_binding.go b/project_binding.go index f3b4d419..fdb71e56 100644 --- a/project_binding.go +++ b/project_binding.go @@ -105,8 +105,8 @@ func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBindi if err != nil { return nil, err } - req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT_GROUP_BINDING) - req.Header.Set("Accept", CONTENT_TYPE_PROJECT_GROUP_BINDING) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", contentType) res, err := c.doAuthenticatedRequest(req) if err != nil { From af11b1d40a8f98ec7480c50b89a1d82ffddc1a43 Mon Sep 17 00:00:00 2001 From: Stefan Tomm Date: Fri, 2 Aug 2024 12:30:47 +0200 Subject: [PATCH 006/215] fix: Set Accept header when getting a Building Block meshStack enforces the Accept header soon, so we have to make sure to always provide it --- buildingblock.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/buildingblock.go b/buildingblock.go index 44d1e020..b31c2bfe 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -80,10 +80,12 @@ func (c *MeshStackProviderClient) urlForBuildingBlock(uuid string) *url.URL { func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingBlock, error) { targetUrl := c.urlForBuildingBlock(uuid) + req, err := http.NewRequest("GET", targetUrl.String(), nil) if err != nil { return nil, err } + req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK) res, err := c.doAuthenticatedRequest(req) if err != nil { From 4b23a7e18fe6a50ad16cf552777f9678eeb11b7e Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Thu, 14 Nov 2024 15:03:14 +0100 Subject: [PATCH 007/215] fix: http response code for building block creation is now 201 --- buildingblock.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildingblock.go b/buildingblock.go index b31c2bfe..c9c03580 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -141,7 +141,7 @@ func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreat return nil, err } - if res.StatusCode != 200 { + if res.StatusCode != 201 { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } From f9c7a9ef4e8ad1f26dc83fba354177331297eaf5 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Fri, 15 Nov 2024 09:53:44 +0100 Subject: [PATCH 008/215] fix: response codes for project and tenant creation --- project.go | 2 +- tenant.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/project.go b/project.go index 9dde4315..eca75ca6 100644 --- a/project.go +++ b/project.go @@ -178,7 +178,7 @@ func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*Me return nil, err } - if res.StatusCode != 200 { + if res.StatusCode != 201 { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } diff --git a/tenant.go b/tenant.go index 9a4775a6..b83c5e60 100644 --- a/tenant.go +++ b/tenant.go @@ -121,7 +121,7 @@ func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshT return nil, err } - if res.StatusCode != 200 { + if res.StatusCode != 201 { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } From 4d96b33d237c268338be09fb528de4a3fc9631f1 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Mon, 4 Nov 2024 21:30:16 +0100 Subject: [PATCH 009/215] feat: add basic implementation of tag_definitions data source --- client.go | 2 + tag_definition.go | 165 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 tag_definition.go diff --git a/client.go b/client.go index 281d0401..bf0ee238 100644 --- a/client.go +++ b/client.go @@ -38,6 +38,7 @@ type endpoints struct { ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` Tenants *url.URL `json:"meshtenants"` + TagDefinitions *url.URL `json:"meshtagdefinitions"` } type loginResponse struct { @@ -63,6 +64,7 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), ProjectGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "groupbindings"), Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), + TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), } return client, nil diff --git a/tag_definition.go b/tag_definition.go new file mode 100644 index 00000000..c82f627d --- /dev/null +++ b/tag_definition.go @@ -0,0 +1,165 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const CONTENT_TYPE_TAG_DEFINITION = "application/vnd.meshcloud.api.meshtagdefinition.v1.hal+json" + +type MeshTagDefinition struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshTagDefinitionMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTagDefinitionSpec `json:"spec" tfsdk:"spec"` +} + +type MeshTagDefinitionMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshTagDefinitionSpec struct { + TargetKind string `json:"targetKind" tfsdk:"target_kind"` + Key string `json:"key" tfsdk:"key"` + ValueType MeshTagDefinitionValueType `json:"valueType" tfsdk:"value_type"` + Description string `json:"description" tfsdk:"description"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + SortOrder int64 `json:"sortOrder" tfsdk:"sort_order"` + Mandatory bool `json:"mandatory" tfsdk:"mandatory"` + Immutable bool `json:"immutable" tfsdk:"immutable"` + Restricted bool `json:"restricted" tfsdk:"restricted"` +} + +type MeshTagDefinitionValueType struct { + String *TagValueString `json:"string,omitempty" tfsdk:"string"` + Email *TagValueEmail `json:"email,omitempty" tfsdk:"email"` + Integer *TagValueInteger `json:"integer,omitempty" tfsdk:"integer"` + Number *TagValueNumber `json:"number,omitempty" tfsdk:"number"` + SingleSelect *TagValueSingleSelect `json:"singleSelect,omitempty" tfsdk:"single_select"` + MultiSelect *TagValueMultiSelect `json:"multiSelect,omitempty" tfsdk:"multi_select"` +} + +type TagValueString struct { + DefaultValue string `json:"defaultValue,omitempty" tfsdk:"default_value"` + ValidationRegex string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` +} + +type TagValueEmail struct { + DefaultValue string `json:"defaultValue,omitempty" tfsdk:"default_value"` + ValidationRegex string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` +} + +type TagValueInteger struct { + DefaultValue int64 `json:"defaultValue,omitempty" tfsdk:"default_value"` +} + +type TagValueNumber struct { + DefaultValue float64 `json:"defaultValue,omitempty" tfsdk:"default_value"` +} + +type TagValueSingleSelect struct { + Options []string `json:"options,omitempty" tfsdk:"options"` + DefaultValue string `json:"defaultValue,omitempty" tfsdk:"default_value"` +} + +type TagValueMultiSelect struct { + Options []string `json:"options,omitempty" tfsdk:"options"` + DefaultValue []string `json:"defaultValue,omitempty" tfsdk:"default_value"` +} + +func (c *MeshStackProviderClient) urlForTagDefinition(name string) *url.URL { + return c.endpoints.TagDefinitions.JoinPath(name) +} + +func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, error) { + var all []MeshTagDefinition + + pageNumber := 0 + targetUrl := c.endpoints.TagDefinitions + query := targetUrl.Query() + + for { + query.Set("page", fmt.Sprintf("%d", pageNumber)) + + targetUrl.RawQuery = query.Encode() + + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var response struct { + Embedded struct { + MeshTagDefinitions []MeshTagDefinition `json:"meshTagDefinitions"` + } `json:"_embedded"` + Page struct { + Size int `json:"size"` + TotalElements int `json:"totalElements"` + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` + } + + err = json.Unmarshal(data, &response) + if err != nil { + return nil, err + } + + all = append(all, response.Embedded.MeshTagDefinitions...) + + // Check if there are more pages + if response.Page.Number >= response.Page.TotalPages-1 { + break + } + + pageNumber++ + } + + return &all, nil +} + +func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefinition, error) { + targetUrl := c.urlForTagDefinition(name) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to read tag definition: %s", resp.Status) + } + + var tagDefinition MeshTagDefinition + if err := json.NewDecoder(resp.Body).Decode(&tagDefinition); err != nil { + return nil, err + } + + return &tagDefinition, nil +} From 656d18bb04a635fc8e5fc0628eb1b6c03c7805b5 Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Tue, 5 Nov 2024 16:24:07 +0100 Subject: [PATCH 010/215] feat: meshstack_tag_definition data source (GET) --- tag_definition.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tag_definition.go b/tag_definition.go index c82f627d..b1182d9c 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -146,7 +146,9 @@ func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefini return nil, err } - resp, err := c.httpClient.Do(req) + req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) + + resp, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } From 28c9595802803f96742e3a03464db67fc654f8a8 Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Wed, 6 Nov 2024 09:24:11 +0100 Subject: [PATCH 011/215] feat: meshstack_tag_definition resource --- tag_definition.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tag_definition.go b/tag_definition.go index b1182d9c..b73ac6ac 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -1,6 +1,7 @@ package client import ( + "bytes" "encoding/json" "fmt" "io" @@ -165,3 +166,93 @@ func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefini return &tagDefinition, nil } + +func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { + targetUrl := c.endpoints.TagDefinitions + data, err := json.Marshal(tagDefinition) + if err != nil { + return nil, fmt.Errorf("failed to marshal tag definition: %w", err) + } + + fmt.Printf("JSON Payload: %s\n", string(data)) + + req, err := http.NewRequest("POST", targetUrl.String(), bytes.NewBuffer(data)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", CONTENT_TYPE_TAG_DEFINITION) + req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) + + resp, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to do authenticated request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("failed to create tag definition: %s", resp.Status) + } + + var createdTagDefinition MeshTagDefinition + if err := json.NewDecoder(resp.Body).Decode(&createdTagDefinition); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &createdTagDefinition, nil +} + +func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { + targetUrl := c.urlForTagDefinition(tagDefinition.Metadata.Name) + data, err := json.Marshal(tagDefinition) + if err != nil { + return nil, fmt.Errorf("failed to marshal tag definition: %w", err) + } + + req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(data)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", CONTENT_TYPE_TAG_DEFINITION) + req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) + + resp, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to do authenticated request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to update tag definition: %s", resp.Status) + } + + var updatedTagDefinition MeshTagDefinition + if err := json.NewDecoder(resp.Body).Decode(&updatedTagDefinition); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &updatedTagDefinition, nil +} + +func (c *MeshStackProviderClient) DeleteTagDefinition(name string) error { + targetUrl := c.urlForTagDefinition(name) + req, err := http.NewRequest("DELETE", targetUrl.String(), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) + + resp, err := c.doAuthenticatedRequest(req) + if err != nil { + return fmt.Errorf("failed to do authenticated request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to delete tag definition: %s", resp.Status) + } + + return nil +} From 94e7d03faeb10133fad75948149ac2e8ae669c99 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Tue, 12 Nov 2024 13:53:08 +0100 Subject: [PATCH 012/215] feat: set metadata.name automatically for tag_definition --- tag_definition.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tag_definition.go b/tag_definition.go index b73ac6ac..1855b26f 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -9,6 +9,7 @@ import ( "net/url" ) +const API_VERSION_TAG_DEFINITION = "v1" const CONTENT_TYPE_TAG_DEFINITION = "application/vnd.meshcloud.api.meshtagdefinition.v1.hal+json" type MeshTagDefinition struct { From 76f7c6c67e888829d64fd3499bbcff1b43e0c690 Mon Sep 17 00:00:00 2001 From: Le Date: Tue, 10 Dec 2024 19:43:26 +0100 Subject: [PATCH 013/215] refactor: check success by 2xx range --- buildingblock.go | 4 ++-- project.go | 8 ++++---- project_binding.go | 4 ++-- status_code_checker.go | 13 +++++++++++++ tag_definition.go | 8 ++++---- tenant.go | 4 ++-- 6 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 status_code_checker.go diff --git a/buildingblock.go b/buildingblock.go index c9c03580..41c4252f 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -103,7 +103,7 @@ func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingB return nil, nil } - if res.StatusCode != 200 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } @@ -141,7 +141,7 @@ func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreat return nil, err } - if res.StatusCode != 201 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } diff --git a/project.go b/project.go index eca75ca6..683b2eea 100644 --- a/project.go +++ b/project.go @@ -71,7 +71,7 @@ func (c *MeshStackProviderClient) ReadProject(workspace string, name string) (*M return nil, nil } - if res.StatusCode != 200 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } @@ -119,7 +119,7 @@ func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, payme return nil, fmt.Errorf("failed to read response body: %w", err) } - if res.StatusCode != http.StatusOK { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } @@ -178,7 +178,7 @@ func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*Me return nil, err } - if res.StatusCode != 201 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } @@ -219,7 +219,7 @@ func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*Me return nil, err } - if res.StatusCode != 200 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } diff --git a/project_binding.go b/project_binding.go index fdb71e56..f704efce 100644 --- a/project_binding.go +++ b/project_binding.go @@ -70,7 +70,7 @@ func (c *MeshStackProviderClient) readProjectBinding(name string, contentType st return nil, nil } - if res.StatusCode != 200 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } @@ -120,7 +120,7 @@ func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBindi return nil, err } - if res.StatusCode != 200 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } diff --git a/status_code_checker.go b/status_code_checker.go new file mode 100644 index 00000000..bd6f24b8 --- /dev/null +++ b/status_code_checker.go @@ -0,0 +1,13 @@ +package client + +import ( + "net/http" +) + +func isSuccessHTTPStatus(resp *http.Response) bool { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return false + } + + return true +} diff --git a/tag_definition.go b/tag_definition.go index 1855b26f..67ff98df 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -107,7 +107,7 @@ func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, er return nil, fmt.Errorf("failed to read response body: %w", err) } - if res.StatusCode != http.StatusOK { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } @@ -156,7 +156,7 @@ func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefini } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { + if !isSuccessHTTPStatus(resp) { return nil, fmt.Errorf("failed to read tag definition: %s", resp.Status) } @@ -191,7 +191,7 @@ func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefi } defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { + if !isSuccessHTTPStatus(resp) { return nil, fmt.Errorf("failed to create tag definition: %s", resp.Status) } @@ -224,7 +224,7 @@ func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefi } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { + if !isSuccessHTTPStatus(resp) { return nil, fmt.Errorf("failed to update tag definition: %s", resp.Status) } diff --git a/tenant.go b/tenant.go index b83c5e60..d10556d9 100644 --- a/tenant.go +++ b/tenant.go @@ -83,7 +83,7 @@ func (c *MeshStackProviderClient) ReadTenant(workspace string, project string, p return nil, nil } - if res.StatusCode != 200 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } @@ -121,7 +121,7 @@ func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshT return nil, err } - if res.StatusCode != 201 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } From ba64475975ac78f8111255144f22b0ecd1971386 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Tue, 25 Feb 2025 14:00:41 +0100 Subject: [PATCH 014/215] feature: preview building blocks v2 resources --- buildingblock.go | 6 +- buildingblock_v2.go | 141 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 buildingblock_v2.go diff --git a/buildingblock.go b/buildingblock.go index 41c4252f..7e456cd6 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -46,9 +46,9 @@ type MeshBuildingBlockSpec struct { } type MeshBuildingBlockIO struct { - Key string `json:"key" tfsdk:"key"` - Value interface{} `json:"value" tfsdk:"value"` - ValueType string `json:"valueType" tfsdk:"value_type"` + Key string `json:"key" tfsdk:"key"` + Value any `json:"value" tfsdk:"value"` + ValueType string `json:"valueType" tfsdk:"value_type"` } type MeshBuildingBlockParent struct { diff --git a/buildingblock_v2.go b/buildingblock_v2.go new file mode 100644 index 00000000..371a6727 --- /dev/null +++ b/buildingblock_v2.go @@ -0,0 +1,141 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const ( + CONTENT_TYPE_BUILDING_BLOCK_V2 = "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json" +) + +type MeshBuildingBlockV2 struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshBuildingBlockV2Metadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` + Status MeshBuildingBlockV2Status `json:"status" tfsdk:"status"` +} + +type MeshBuildingBlockV2Metadata struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + MarkedForDeletionOn *string `json:"markedForDeletionOn" tfsdk:"marked_for_deletion_on"` + MarkedForDeletionBy *string `json:"markedForDeletionBy" tfsdk:"marked_for_deletion_by"` +} + +type MeshBuildingBlockV2Spec struct { + BuildingBlockDefinitionVersionRef MeshBuildingBlockV2DefinitionVersionRef `json:"buildingBlockDefinitionVersionRef" tfsdk:"building_block_definition_version_ref"` + TargetRef MeshBuildingBlockV2TargetRef `json:"targetRef" tfsdk:"target_ref"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + + Inputs []MeshBuildingBlockIO `json:"inputs" tfsdk:"inputs"` + ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` +} + +type MeshBuildingBlockV2DefinitionVersionRef struct { + Uuid string `json:"uuid" tfsdk:"uuid"` +} + +type MeshBuildingBlockV2TargetRef struct { + Kind string `json:"kind" tfsdk:"kind"` + Uuid *string `json:"uuid" tfsdk:"uuid"` + Identifier *string `json:"identifier" tfsdk:"identifier"` +} + +type MeshBuildingBlockV2Create struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` +} + +type MeshBuildingBlockV2Status struct { + Status string `json:"status" tfsdk:"status"` + Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` +} + +func (c *MeshStackProviderClient) ReadBuildingBlockV2(uuid string) (*MeshBuildingBlockV2, error) { + targetUrl := c.urlForBuildingBlock(uuid) + + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK_V2) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode == 404 { + return nil, nil + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var bb MeshBuildingBlockV2 + err = json.Unmarshal(data, &bb) + if err != nil { + return nil, err + } + + return &bb, nil +} + +func (c *MeshStackProviderClient) CreateBuildingBlockV2(bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { + payload, err := json.Marshal(bb) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.BuildingBlocks.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_BUILDING_BLOCK_V2) + req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK_V2) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdBb MeshBuildingBlockV2 + err = json.Unmarshal(data, &createdBb) + if err != nil { + return nil, err + } + + return &createdBb, nil +} + +func (c *MeshStackProviderClient) DeleteBuildingBlockV2(uuid string) error { + targetUrl := c.urlForBuildingBlock(uuid) + return c.deleteMeshObject(*targetUrl, 202) +} From e6df98a1cf00e8443223df00eb314b24d1d27416 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Thu, 17 Apr 2025 13:37:51 +0200 Subject: [PATCH 015/215] feature: source provider configuration from environment --- client.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index bf0ee238..c6c629e4 100644 --- a/client.go +++ b/client.go @@ -86,7 +86,9 @@ func (c *MeshStackProviderClient) login() error { res, err := c.httpClient.Do(req) - if err != nil || res.StatusCode != 200 { + if err != nil { + return err + } else if res.StatusCode != 200 { return errors.New(ERROR_AUTHENTICATION_FAILURE) } @@ -174,8 +176,9 @@ func (c *MeshStackProviderClient) doAuthenticatedRequest(req *http.Request) (*ht log.Println(req) // add authentication - if c.ensureValidToken() != nil { - return nil, errors.New(ERROR_AUTHENTICATION_FAILURE) + err := c.ensureValidToken() + if err != nil { + return nil, err } req.Header.Set("Authorization", c.token) From b3b3f9a63ea4544bccfc180700861e54abf1797b Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Thu, 31 Jul 2025 15:14:36 +0200 Subject: [PATCH 016/215] fix: allow code inputs in buildingblock resource --- buildingblock.go | 1 + 1 file changed, 1 insertion(+) diff --git a/buildingblock.go b/buildingblock.go index 7e456cd6..2867be41 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -16,6 +16,7 @@ const ( MESH_BUILDING_BLOCK_IO_TYPE_SINGLE_SELECT = "SINGLE_SELECT" MESH_BUILDING_BLOCK_IO_TYPE_FILE = "FILE" MESH_BUILDING_BLOCK_IO_TYPE_LIST = "LIST" + MESH_BUILDING_BLOCK_IO_TYPE_CODE = "CODE" CONTENT_TYPE_BUILDING_BLOCK = "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json" ) From 2dc60308c3bf1ff52b2365d0d92f57e0901bc644 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 23 Jul 2025 10:09:40 +0200 Subject: [PATCH 017/215] feat: workspace data source --- client.go | 2 ++ project.go | 2 +- workspace.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 workspace.go diff --git a/client.go b/client.go index c6c629e4..7a5e42c6 100644 --- a/client.go +++ b/client.go @@ -37,6 +37,7 @@ type endpoints struct { Projects *url.URL `json:"meshprojects"` ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` + Workspaces *url.URL `json:"meshworkspaces"` Tenants *url.URL `json:"meshtenants"` TagDefinitions *url.URL `json:"meshtagdefinitions"` } @@ -63,6 +64,7 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), ProjectGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "groupbindings"), + Workspaces: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspaces"), Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), } diff --git a/project.go b/project.go index 683b2eea..eca293af 100644 --- a/project.go +++ b/project.go @@ -67,7 +67,7 @@ func (c *MeshStackProviderClient) ReadProject(workspace string, name string) (*M return nil, err } - if res.StatusCode == 404 { + if res.StatusCode == http.StatusNotFound { return nil, nil } diff --git a/workspace.go b/workspace.go new file mode 100644 index 00000000..7effc640 --- /dev/null +++ b/workspace.go @@ -0,0 +1,69 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const CONTENT_TYPE_WORKSPACE = "application/vnd.meshcloud.api.meshworkspace.v1.hal+json" + +type MeshWorkspace struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshWorkspaceMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshWorkspaceSpec `json:"spec" tfsdk:"spec"` +} + +type MeshWorkspaceMetadata struct { + Name string `json:"name" tfsdk:"name"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` +} + +type MeshWorkspaceSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` +} + +func (c *MeshStackProviderClient) urlForWorkspace(name string) *url.URL { + return c.endpoints.Workspaces.JoinPath(name) +} + +func (c *MeshStackProviderClient) ReadWorkspace(name string) (*MeshWorkspace, error) { + targetUrl := c.urlForWorkspace(name) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return nil, nil // Not found is not an error + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var workspace MeshWorkspace + err = json.Unmarshal(data, &workspace) + if err != nil { + return nil, err + } + return &workspace, nil +} From 6118432d676c034f8a70731b0e2724dcda86be24 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 23 Jul 2025 13:47:59 +0200 Subject: [PATCH 018/215] feat: workspace resource --- workspace.go | 103 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 6 deletions(-) diff --git a/workspace.go b/workspace.go index 7effc640..0ade57cd 100644 --- a/workspace.go +++ b/workspace.go @@ -1,6 +1,7 @@ package client import ( + "bytes" "encoding/json" "fmt" "io" @@ -8,7 +9,7 @@ import ( "net/url" ) -const CONTENT_TYPE_WORKSPACE = "application/vnd.meshcloud.api.meshworkspace.v1.hal+json" +const CONTENT_TYPE_WORKSPACE = "application/vnd.meshcloud.api.meshworkspace.v2.hal+json" type MeshWorkspace struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` @@ -18,14 +19,25 @@ type MeshWorkspace struct { } type MeshWorkspaceMetadata struct { - Name string `json:"name" tfsdk:"name"` - CreatedOn string `json:"createdOn" tfsdk:"created_on"` - DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` + Name string `json:"name" tfsdk:"name"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` } type MeshWorkspaceSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Tags map[string][]string `json:"tags" tfsdk:"tags"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + PlatformBuilderAccessEnabled *bool `json:"platformBuilderAccessEnabled,omitempty" tfsdk:"platform_builder_access_enabled"` +} + +type MeshWorkspaceCreate struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Metadata MeshWorkspaceCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshWorkspaceSpec `json:"spec" tfsdk:"spec"` +} +type MeshWorkspaceCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` } func (c *MeshStackProviderClient) urlForWorkspace(name string) *url.URL { @@ -67,3 +79,82 @@ func (c *MeshStackProviderClient) ReadWorkspace(name string) (*MeshWorkspace, er } return &workspace, nil } + +func (c *MeshStackProviderClient) CreateWorkspace(workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { + paylod, err := json.Marshal(workspace) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.Workspaces.String(), bytes.NewBuffer(paylod)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_WORKSPACE) + req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdWorkspace MeshWorkspace + err = json.Unmarshal(data, &createdWorkspace) + if err != nil { + return nil, err + } + return &createdWorkspace, nil +} + +func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { + targetUrl := c.urlForWorkspace(name) + + paylod, err := json.Marshal(workspace) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(paylod)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_WORKSPACE) + req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var updatedWorkspace MeshWorkspace + err = json.Unmarshal(data, &updatedWorkspace) + if err != nil { + return nil, err + } + return &updatedWorkspace, nil +} + +func (c *MeshStackProviderClient) DeleteWorkspace(name string) error { + targetUrl := c.urlForWorkspace(name) + return c.deleteMeshObject(*targetUrl, 204) +} From ef46955a5b3e98aeebebed27cc45259983a365ab Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Thu, 7 Nov 2024 09:48:48 +0100 Subject: [PATCH 019/215] feat: meshstack_tenant_v4 resource --- tenant_v4.go | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tenant_v4.go diff --git a/tenant_v4.go b/tenant_v4.go new file mode 100644 index 00000000..b4974c45 --- /dev/null +++ b/tenant_v4.go @@ -0,0 +1,143 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const CONTENT_TYPE_TENANT_V4 = "application/vnd.meshcloud.api.meshtenant.v4.hal+json" + +type MeshTenantV4 struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshTenantMetadataV4 `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantSpecV4 `json:"spec" tfsdk:"spec"` + Status MeshTenantStatusV4 `json:"status" tfsdk:"status"` +} + +type MeshTenantMetadataV4 struct { + UUID string `json:"uuid" tfsdk:"uuid"` + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` + CreatedOn *string `json:"createdOn" tfsdk:"created_on"` +} + +type MeshTenantSpecV4 struct { + PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` + LocalId *string `json:"localId" tfsdk:"local_id"` + LandingZoneIdentifier string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` + Quotas []MeshTenantQuota `json:"quotas" tfsdk:"quotas"` +} + +type MeshTenantStatusV4 struct { + Tags map[string][]string `json:"tags" tfsdk:"tags"` + LastReplicated *string `json:"lastReplicated" tfsdk:"last_replicated"` + CurrentReplicationStatus string `json:"currentReplicationStatus" tfsdk:"current_replication_status"` +} + +type MeshTenantCreateV4 struct { + Metadata MeshTenantCreateMetadataV4 `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantCreateSpecV4 `json:"spec" tfsdk:"spec"` +} + +type MeshTenantCreateMetadataV4 struct { + UUID string `json:"uuid" tfsdk:"uuid"` + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshTenantCreateSpecV4 struct { + PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` + LocalId *string `json:"localId" tfsdk:"local_id"` + LandingZoneIdentifier string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` + Quotas []MeshTenantQuota `json:"quotas" tfsdk:"quotas"` +} + +func (c *MeshStackProviderClient) urlForTenantV4(uuid string) *url.URL { + return c.endpoints.Tenants.JoinPath(uuid) +} + +func (c *MeshStackProviderClient) ReadTenantV4(uuid string) (*MeshTenantV4, error) { + targetUrl := c.urlForTenantV4(uuid) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_TENANT_V4) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode == 404 { + return nil, nil + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var tenant MeshTenantV4 + err = json.Unmarshal(data, &tenant) + if err != nil { + return nil, err + } + + return &tenant, nil +} + +func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantCreateV4) (*MeshTenantV4, error) { + payload, err := json.Marshal(tenant) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.Tenants.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_TENANT_V4) + req.Header.Set("Accept", CONTENT_TYPE_TENANT_V4) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdTenant MeshTenantV4 + err = json.Unmarshal(data, &createdTenant) + if err != nil { + return nil, err + } + + return &createdTenant, nil +} + +func (c *MeshStackProviderClient) DeleteTenantV4(uuid string) error { + targetUrl := c.urlForTenantV4(uuid) + return c.deleteMeshObject(*targetUrl, 202) +} From d71487c56e0bfc0e0250d473dee42e08478ff6f7 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Mon, 28 Jul 2025 16:10:14 +0200 Subject: [PATCH 020/215] fix: adapt tenant v4 client to actual implementation --- tenant_v4.go | 63 ++++++++++++++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/tenant_v4.go b/tenant_v4.go index b4974c45..4d998409 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -9,53 +9,54 @@ import ( "net/url" ) -const CONTENT_TYPE_TENANT_V4 = "application/vnd.meshcloud.api.meshtenant.v4.hal+json" +const CONTENT_TYPE_TENANT_V4 = "application/vnd.meshcloud.api.meshtenant.v4-preview.hal+json" type MeshTenantV4 struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshTenantMetadataV4 `json:"metadata" tfsdk:"metadata"` - Spec MeshTenantSpecV4 `json:"spec" tfsdk:"spec"` - Status MeshTenantStatusV4 `json:"status" tfsdk:"status"` + Metadata MeshTenantV4Metadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantV4Spec `json:"spec" tfsdk:"spec"` + Status MeshTenantV4Status `json:"status" tfsdk:"status"` } -type MeshTenantMetadataV4 struct { - UUID string `json:"uuid" tfsdk:"uuid"` - OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` - CreatedOn *string `json:"createdOn" tfsdk:"created_on"` +type MeshTenantV4Metadata struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + MarkedForDeletionOn *string `json:"markedForDeletionOn" tfsdk:"marked_for_deletion_on"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` } -type MeshTenantSpecV4 struct { - PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` - LocalId *string `json:"localId" tfsdk:"local_id"` - LandingZoneIdentifier string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` - Quotas []MeshTenantQuota `json:"quotas" tfsdk:"quotas"` +type MeshTenantV4Spec struct { + PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` + PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` + LandingZoneIdentifier *string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` + Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` } -type MeshTenantStatusV4 struct { - Tags map[string][]string `json:"tags" tfsdk:"tags"` - LastReplicated *string `json:"lastReplicated" tfsdk:"last_replicated"` - CurrentReplicationStatus string `json:"currentReplicationStatus" tfsdk:"current_replication_status"` +type MeshTenantV4Status struct { + TenantName string `json:"tenantName" tfsdk:"tenant_name"` + PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` + PlatformWorkspaceIdentifier *string `json:"platformWorkspaceIdentifier" tfsdk:"platform_workspace_identifier"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` } -type MeshTenantCreateV4 struct { - Metadata MeshTenantCreateMetadataV4 `json:"metadata" tfsdk:"metadata"` - Spec MeshTenantCreateSpecV4 `json:"spec" tfsdk:"spec"` +type MeshTenantV4Create struct { + Metadata MeshTenantV4CreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantV4CreateSpec `json:"spec" tfsdk:"spec"` } -type MeshTenantCreateMetadataV4 struct { - UUID string `json:"uuid" tfsdk:"uuid"` +type MeshTenantV4CreateMetadata struct { OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } -type MeshTenantCreateSpecV4 struct { - PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` - LocalId *string `json:"localId" tfsdk:"local_id"` - LandingZoneIdentifier string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` - Quotas []MeshTenantQuota `json:"quotas" tfsdk:"quotas"` +type MeshTenantV4CreateSpec struct { + PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` + LandingZoneIdentifier *string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` + PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` + Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` } func (c *MeshStackProviderClient) urlForTenantV4(uuid string) *url.URL { @@ -99,7 +100,7 @@ func (c *MeshStackProviderClient) ReadTenantV4(uuid string) (*MeshTenantV4, erro return &tenant, nil } -func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantCreateV4) (*MeshTenantV4, error) { +func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantV4Create) (*MeshTenantV4, error) { payload, err := json.Marshal(tenant) if err != nil { return nil, err @@ -124,7 +125,7 @@ func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantCreateV4) (*M return nil, err } - if res.StatusCode != 200 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } From d9197939167db79479b6f9c757ed08a567899401 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Mon, 28 Jul 2025 16:22:46 +0200 Subject: [PATCH 021/215] fix: adapt tenant_v4 resource to actual implementation --- tenant_v4.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_v4.go b/tenant_v4.go index 4d998409..00991b57 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -87,7 +87,7 @@ func (c *MeshStackProviderClient) ReadTenantV4(uuid string) (*MeshTenantV4, erro return nil, nil } - if res.StatusCode != 200 { + if !isSuccessHTTPStatus(res) { return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } From 9ac4d63e8e57c55159fe4805c366184b57d1c59c Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Mon, 11 Aug 2025 19:04:20 +0200 Subject: [PATCH 022/215] feat: buildingblock v2 polling for completion --- buildingblock_v2.go | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 371a6727..fbb81e44 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -2,10 +2,14 @@ package client import ( "bytes" + "context" "encoding/json" "fmt" "io" "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" ) const ( @@ -139,3 +143,72 @@ func (c *MeshStackProviderClient) DeleteBuildingBlockV2(uuid string) error { targetUrl := c.urlForBuildingBlock(uuid) return c.deleteMeshObject(*targetUrl, 202) } + +// PollBuildingBlockV2UntilCompletion polls a building block v2 until it reaches a terminal state (SUCCEEDED or FAILED) +// Returns the final building block state or an error if polling fails or times out +func (c *MeshStackProviderClient) PollBuildingBlockV2UntilCompletion(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { + var result *MeshBuildingBlockV2 + + err := retry.RetryContext(ctx, 30*time.Minute, c.waitForBuildingBlockV2CompletionFunc(ctx, uuid, &result)) + if err != nil { + return nil, err + } + + return result, nil +} + +// waitForBuildingBlockV2CompletionFunc returns a RetryFunc that checks building block completion status +func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(ctx context.Context, uuid string, result **MeshBuildingBlockV2) retry.RetryFunc { + return func() *retry.RetryError { + current, err := c.ReadBuildingBlockV2(uuid) + if err != nil { + return retry.NonRetryableError(fmt.Errorf("could not read building block status while waiting for completion: %w", err)) + } + + if current == nil { + return retry.NonRetryableError(fmt.Errorf("building block was not found while waiting for completion")) + } + + // Check if we've reached a terminal state + status := current.Status.Status + switch status { + case "SUCCEEDED": + *result = current + return nil // Success, stop retrying + case "FAILED": + return retry.NonRetryableError(fmt.Errorf("building block %s reached FAILED state", uuid)) + } + + // Not done yet, continue polling + return retry.RetryableError(fmt.Errorf("waiting for building block %s to complete: currently in %s state", uuid, status)) + } +} + +// PollBuildingBlockV2UntilDeletion polls a building block v2 until it is deleted (not found) +// Returns nil on successful deletion or an error if polling fails or times out +func (c *MeshStackProviderClient) PollBuildingBlockV2UntilDeletion(ctx context.Context, uuid string) error { + return retry.RetryContext(ctx, 30*time.Minute, c.waitForBuildingBlockV2DeletionFunc(uuid)) +} + +// waitForBuildingBlockV2DeletionFunc returns a RetryFunc that checks building block deletion status +func (c *MeshStackProviderClient) waitForBuildingBlockV2DeletionFunc(uuid string) retry.RetryFunc { + return func() *retry.RetryError { + current, err := c.ReadBuildingBlockV2(uuid) + if err != nil { + return retry.NonRetryableError(fmt.Errorf("could not read building block status while waiting for deletion: %w", err)) + } + + // If building block is not found, deletion is complete + if current == nil { + return nil // Success, stop retrying + } + + // If building block is in FAILED state during deletion, consider it a terminal state + if current.Status.Status == "FAILED" { + return retry.NonRetryableError(fmt.Errorf("building block %s reached FAILED state during deletion", uuid)) + } + + // Not done yet, continue polling + return retry.RetryableError(fmt.Errorf("waiting for building block %s to be deleted: currently in %s state", uuid, current.Status.Status)) + } +} From 0802352270990f51dac0cd72b11f648bf1469da5 Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Wed, 13 Aug 2025 13:02:26 +0200 Subject: [PATCH 023/215] feat: tenant v4 polling for completion --- buildingblock_v2.go | 8 +++--- tenant_v4.go | 64 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index fbb81e44..5bc6ffb5 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -144,12 +144,12 @@ func (c *MeshStackProviderClient) DeleteBuildingBlockV2(uuid string) error { return c.deleteMeshObject(*targetUrl, 202) } -// PollBuildingBlockV2UntilCompletion polls a building block v2 until it reaches a terminal state (SUCCEEDED or FAILED) +// PollBuildingBlockV2UntilCompletion polls a building block until it reaches a terminal state (SUCCEEDED or FAILED) // Returns the final building block state or an error if polling fails or times out func (c *MeshStackProviderClient) PollBuildingBlockV2UntilCompletion(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { var result *MeshBuildingBlockV2 - err := retry.RetryContext(ctx, 30*time.Minute, c.waitForBuildingBlockV2CompletionFunc(ctx, uuid, &result)) + err := retry.RetryContext(ctx, 30*time.Minute, c.waitForBuildingBlockV2CompletionFunc(uuid, &result)) if err != nil { return nil, err } @@ -158,7 +158,7 @@ func (c *MeshStackProviderClient) PollBuildingBlockV2UntilCompletion(ctx context } // waitForBuildingBlockV2CompletionFunc returns a RetryFunc that checks building block completion status -func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(ctx context.Context, uuid string, result **MeshBuildingBlockV2) retry.RetryFunc { +func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(uuid string, result **MeshBuildingBlockV2) retry.RetryFunc { return func() *retry.RetryError { current, err := c.ReadBuildingBlockV2(uuid) if err != nil { @@ -184,7 +184,7 @@ func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(ctx conte } } -// PollBuildingBlockV2UntilDeletion polls a building block v2 until it is deleted (not found) +// PollBuildingBlockV2UntilDeletion polls a building block until it is deleted (not found) // Returns nil on successful deletion or an error if polling fails or times out func (c *MeshStackProviderClient) PollBuildingBlockV2UntilDeletion(ctx context.Context, uuid string) error { return retry.RetryContext(ctx, 30*time.Minute, c.waitForBuildingBlockV2DeletionFunc(uuid)) diff --git a/tenant_v4.go b/tenant_v4.go index 00991b57..4768c69d 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -2,11 +2,15 @@ package client import ( "bytes" + "context" "encoding/json" "fmt" "io" "net/http" "net/url" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" ) const CONTENT_TYPE_TENANT_V4 = "application/vnd.meshcloud.api.meshtenant.v4-preview.hal+json" @@ -142,3 +146,63 @@ func (c *MeshStackProviderClient) DeleteTenantV4(uuid string) error { targetUrl := c.urlForTenantV4(uuid) return c.deleteMeshObject(*targetUrl, 202) } + +// PollTenantV4UntilCreation polls a tenant until creation completes (platformTenantId is set) +// Returns the final tenant state or an error if polling fails or times out +func (c *MeshStackProviderClient) PollTenantV4UntilCreation(ctx context.Context, uuid string) (*MeshTenantV4, error) { + var result *MeshTenantV4 + + err := retry.RetryContext(ctx, 30*time.Minute, c.waitForTenantV4CreationFunc(uuid, &result)) + if err != nil { + return nil, err + } + + return result, nil +} + +// waitForTenantV4CreationFunc returns a RetryFunc that checks tenant creation status +func (c *MeshStackProviderClient) waitForTenantV4CreationFunc(uuid string, result **MeshTenantV4) retry.RetryFunc { + return func() *retry.RetryError { + current, err := c.ReadTenantV4(uuid) + if err != nil { + return retry.NonRetryableError(fmt.Errorf("could not read tenant status while waiting for creation: %w", err)) + } + + if current == nil { + return retry.NonRetryableError(fmt.Errorf("tenant was not found while waiting for creation")) + } + + // Check if creation is complete (platformTenantId is set) + if current.Spec.PlatformTenantId != nil && *current.Spec.PlatformTenantId != "" { + *result = current + return nil // Success, stop retrying + } + + // Not done yet, continue polling + return retry.RetryableError(fmt.Errorf("waiting for tenant %s creation to complete: platformTenantId not yet set", uuid)) + } +} + +// PollTenantV4UntilDeletion polls a tenant until it is deleted (not found) +// Returns nil on successful deletion or an error if polling fails or times out +func (c *MeshStackProviderClient) PollTenantV4UntilDeletion(ctx context.Context, uuid string) error { + return retry.RetryContext(ctx, 30*time.Minute, c.waitForTenantV4DeletionFunc(uuid)) +} + +// waitForTenantV4DeletionFunc returns a RetryFunc that checks tenant deletion status +func (c *MeshStackProviderClient) waitForTenantV4DeletionFunc(uuid string) retry.RetryFunc { + return func() *retry.RetryError { + current, err := c.ReadTenantV4(uuid) + if err != nil { + return retry.NonRetryableError(fmt.Errorf("could not read tenant status while waiting for deletion: %w", err)) + } + + // If tenant is not found, deletion is complete + if current == nil { + return nil // Success, stop retrying + } + + // Not done yet, continue polling + return retry.RetryableError(fmt.Errorf("waiting for tenant %s to be deleted: still present", uuid)) + } +} From c67b1c9b08a471638539fd9e5f94368e3435a9c5 Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Thu, 14 Aug 2025 10:54:59 +0200 Subject: [PATCH 024/215] chore: example for building_block_v2 includes adaptations from PR remarks --- buildingblock_v2.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 5bc6ffb5..be23299b 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -205,7 +205,7 @@ func (c *MeshStackProviderClient) waitForBuildingBlockV2DeletionFunc(uuid string // If building block is in FAILED state during deletion, consider it a terminal state if current.Status.Status == "FAILED" { - return retry.NonRetryableError(fmt.Errorf("building block %s reached FAILED state during deletion", uuid)) + return retry.NonRetryableError(fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", uuid)) } // Not done yet, continue polling From 3d2ffc92ae35bb7a587c5087851a3bae6670cdde Mon Sep 17 00:00:00 2001 From: OliverEsoterik Date: Mon, 18 Aug 2025 18:25:11 +0200 Subject: [PATCH 025/215] add initial workspace bindings --- client.go | 3 + workspace_binding.go | 133 +++++++++++++++++++++++++++++++++++++ workspace_group_binding.go | 26 ++++++++ workspace_user_binding.go | 26 ++++++++ 4 files changed, 188 insertions(+) create mode 100644 workspace_binding.go create mode 100644 workspace_group_binding.go create mode 100644 workspace_user_binding.go diff --git a/client.go b/client.go index 7a5e42c6..d6ad7b92 100644 --- a/client.go +++ b/client.go @@ -38,6 +38,7 @@ type endpoints struct { ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` Workspaces *url.URL `json:"meshworkspaces"` + WorkspaceGroupBindings *url.URL `json:"meshworkspacegroupbindings"` Tenants *url.URL `json:"meshtenants"` TagDefinitions *url.URL `json:"meshtagdefinitions"` } @@ -65,6 +66,8 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), ProjectGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "groupbindings"), Workspaces: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspaces"), + WorkspaceUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "userbindings"), + WorkspaceGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "groupbindings"), Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), } diff --git a/workspace_binding.go b/workspace_binding.go new file mode 100644 index 00000000..d4dc9557 --- /dev/null +++ b/workspace_binding.go @@ -0,0 +1,133 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type MeshWorkspaceBinding struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshProjectBindingMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshProjectRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshProjectTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshSubject `json:"subject" tfsdk:"subject"` +} + +type MeshWorkspaceBindingMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshWorkspaceRoleRef struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshWorkspaceTargetRef struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshSubject struct { + Name string `json:"name" tfsdk:"name"` +} + +func (c *MeshStackProviderClient) readWorkspaceBinding(name string, contentType string) (*MeshWorkspaceBinding, error) { + var targetUrl *url.URL + switch contentType { + case CONTENT_TYPE_WORKSPACE_USER_BINDING: + targetUrl = c.urlForWorkspaceUserBinding(name) + + case CONTENT_TYPE_WORKSPACE_GROUP_BINDING: + targetUrl = c.urlForWorkspaceGroupBinding(name) + + default: + return nil, fmt.Errorf("Unexpected content type: %s", contentType) + } + + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", contentType) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode == 404 { + return nil, nil + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var binding MeshWorkspaceBinding + err = json.Unmarshal(data, &binding) + if err != nil { + return nil, err + } + + return &binding, nil +} + +func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceBinding, contentType string) (*MeshWorkspaceBinding, error) { + var targetUrl *url.URL + switch contentType { + case CONTENT_TYPE_WORKSPACE_USER_BINDING: + targetUrl = c.endpoints.WorkspaceUserBindings + + case CONTENT_TYPE_WORKSPACE_GROUP_BINDING: + targetUrl = c.endpoints.WorkspaceGroupBindings + + default: + return nil, fmt.Errorf("Unexpected content type: %s", contentType) + } + + payload, err := json.Marshal(binding) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", targetUrl.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", contentType) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdBinding MeshProjectBinding + err = json.Unmarshal(data, &createdBinding) + if err != nil { + return nil, err + } + + return &createdBinding, nil +} diff --git a/workspace_group_binding.go b/workspace_group_binding.go new file mode 100644 index 00000000..24887c8f --- /dev/null +++ b/workspace_group_binding.go @@ -0,0 +1,26 @@ +package client + +import ( + "net/url" +) + +const CONTENT_TYPE_WORKSPACE_GROUP_BINDING = "application/vnd.meshcloud.api.meshworkspacegroupbinding.v2.hal+json" + +type MeshWorkspaceGroupBinding = MeshWorkspaceBinding + +func (c *MeshStackProviderClient) urlForWorkspaceGroupBinding(name string) *url.URL { + return c.endpoints.WorkspaceGroupBindings.JoinPath(name) +} + +func (c *MeshStackProviderClient) ReadPWorkspaceGroupBinding(name string) (*MeshWorkspaceGroupBinding, error) { + return c.readWorkspaceBinding(name, CONTENT_TYPE_WORKSPACE_GROUP_BINDING) +} + +func (c *MeshStackProviderClient) CreateWorkspaceGroupBinding(binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { + return c.createWorkspaceBinding(binding, CONTENT_TYPE_WORKSPACE_GROUP_BINDING) +} + +func (c *MeshStackProviderClient) DeleteWorkspaceGroupBinding(name string) error { + targetUrl := c.urlForWorkspaceGroupBinding(name) + return c.deleteMeshObject(*targetUrl, 204) +} diff --git a/workspace_user_binding.go b/workspace_user_binding.go new file mode 100644 index 00000000..e50db18c --- /dev/null +++ b/workspace_user_binding.go @@ -0,0 +1,26 @@ +package client + +import ( + "net/url" +) + +const CONTENT_TYPE_PROJECT_USER_BINDING = "application/vnd.meshcloud.api.meshworkspaceuserbinding.v2.hal+json" + +type MeshProjectUserBinding = MeshProjectBinding + +func (c *MeshStackProviderClient) urlForPojectUserBinding(name string) *url.URL { + return c.endpoints.ProjectUserBindings.JoinPath(name) +} + +func (c *MeshStackProviderClient) ReadProjectUserBinding(name string) (*MeshProjectUserBinding, error) { + return c.readProjectBinding(name, CONTENT_TYPE_PROJECT_USER_BINDING) +} + +func (c *MeshStackProviderClient) CreateProjectUserBinding(binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { + return c.createProjectBinding(binding, CONTENT_TYPE_PROJECT_USER_BINDING) +} + +func (c *MeshStackProviderClient) DeleteProjecUserBinding(name string) error { + targetUrl := c.urlForPojectUserBinding(name) + return c.deleteMeshObject(*targetUrl, 204) +} From 36d7b9115141bcd9b0822009d22a28ca012bf28e Mon Sep 17 00:00:00 2001 From: OliverEsoterik Date: Mon, 18 Aug 2025 18:36:48 +0200 Subject: [PATCH 026/215] additional changes from project to workspace, remove duplicates --- workspace_binding.go | 10 +++++----- workspace_user_binding.go | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/workspace_binding.go b/workspace_binding.go index d4dc9557..f9073f8f 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -12,10 +12,10 @@ import ( type MeshWorkspaceBinding struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshProjectBindingMetadata `json:"metadata" tfsdk:"metadata"` - RoleRef MeshProjectRoleRef `json:"roleRef" tfsdk:"role_ref"` - TargetRef MeshProjectTargetRef `json:"targetRef" tfsdk:"target_ref"` - Subject MeshSubject `json:"subject" tfsdk:"subject"` + Metadata MeshWorkspaceBindingMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshWorkspaceRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshWorkspaceTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshWorkspaceSubject `json:"subject" tfsdk:"subject"` } type MeshWorkspaceBindingMetadata struct { @@ -30,7 +30,7 @@ type MeshWorkspaceTargetRef struct { Name string `json:"name" tfsdk:"name"` } -type MeshSubject struct { +type MeshWorkspaceSubject struct { Name string `json:"name" tfsdk:"name"` } diff --git a/workspace_user_binding.go b/workspace_user_binding.go index e50db18c..dbd7d773 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -4,23 +4,23 @@ import ( "net/url" ) -const CONTENT_TYPE_PROJECT_USER_BINDING = "application/vnd.meshcloud.api.meshworkspaceuserbinding.v2.hal+json" +const CONTENT_TYPE_WORKSPACE_USER_BINDING = "application/vnd.meshcloud.api.meshworkspaceuserbinding.v2.hal+json" -type MeshProjectUserBinding = MeshProjectBinding +type MeshWorkspaceUserBinding = MeshProjectBinding -func (c *MeshStackProviderClient) urlForPojectUserBinding(name string) *url.URL { - return c.endpoints.ProjectUserBindings.JoinPath(name) +func (c *MeshStackProviderClient) urlForWorkspaceUserBinding(name string) *url.URL { + return c.endpoints.WorkspaceUserBindings.JoinPath(name) } -func (c *MeshStackProviderClient) ReadProjectUserBinding(name string) (*MeshProjectUserBinding, error) { - return c.readProjectBinding(name, CONTENT_TYPE_PROJECT_USER_BINDING) +func (c *MeshStackProviderClient) ReadWorkspaceUserBinding(name string) (*MeshWorkspaceUserBinding, error) { + return c.readWorkspaceBinding(name, CONTENT_TYPE_WORKSPACE_USER_BINDING) } -func (c *MeshStackProviderClient) CreateProjectUserBinding(binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { - return c.createProjectBinding(binding, CONTENT_TYPE_PROJECT_USER_BINDING) +func (c *MeshStackProviderClient) CreateWorkspaceUserBinding(binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { + return c.createWorkspaceBinding(binding, CONTENT_TYPE_WORKSPACE_USER_BINDING) } -func (c *MeshStackProviderClient) DeleteProjecUserBinding(name string) error { - targetUrl := c.urlForPojectUserBinding(name) +func (c *MeshStackProviderClient) DeleteWorkspaceUserBinding(name string) error { + targetUrl := c.urlForWorkspaceUserBinding(name) return c.deleteMeshObject(*targetUrl, 204) } From 0c6813b29ea954c9d79b0dcfd61b2a95e12079df Mon Sep 17 00:00:00 2001 From: OliverEsoterik Date: Mon, 18 Aug 2025 18:44:09 +0200 Subject: [PATCH 027/215] duplicates and rrs resolved --- client.go | 1 + workspace_binding.go | 2 +- workspace_user_binding.go | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client.go b/client.go index d6ad7b92..be64f4ce 100644 --- a/client.go +++ b/client.go @@ -38,6 +38,7 @@ type endpoints struct { ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` Workspaces *url.URL `json:"meshworkspaces"` + WorkspaceUserBindings *url.URL `json:"meshworkspaceuserbindings"` WorkspaceGroupBindings *url.URL `json:"meshworkspacegroupbindings"` Tenants *url.URL `json:"meshtenants"` TagDefinitions *url.URL `json:"meshtagdefinitions"` diff --git a/workspace_binding.go b/workspace_binding.go index f9073f8f..eb4718f0 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -123,7 +123,7 @@ func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceB return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } - var createdBinding MeshProjectBinding + var createdBinding MeshWorkspaceBinding err = json.Unmarshal(data, &createdBinding) if err != nil { return nil, err diff --git a/workspace_user_binding.go b/workspace_user_binding.go index dbd7d773..3019df81 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -6,7 +6,7 @@ import ( const CONTENT_TYPE_WORKSPACE_USER_BINDING = "application/vnd.meshcloud.api.meshworkspaceuserbinding.v2.hal+json" -type MeshWorkspaceUserBinding = MeshProjectBinding +type MeshWorkspaceUserBinding = MeshWorkspaceBinding func (c *MeshStackProviderClient) urlForWorkspaceUserBinding(name string) *url.URL { return c.endpoints.WorkspaceUserBindings.JoinPath(name) From 5eb5713cd03fa495ff26a19767149e4e513771fb Mon Sep 17 00:00:00 2001 From: OliverEsoterik Date: Mon, 18 Aug 2025 18:53:00 +0200 Subject: [PATCH 028/215] remove typo in workspace group bindings --- workspace_group_binding.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspace_group_binding.go b/workspace_group_binding.go index 24887c8f..0f3e7d69 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -12,7 +12,7 @@ func (c *MeshStackProviderClient) urlForWorkspaceGroupBinding(name string) *url. return c.endpoints.WorkspaceGroupBindings.JoinPath(name) } -func (c *MeshStackProviderClient) ReadPWorkspaceGroupBinding(name string) (*MeshWorkspaceGroupBinding, error) { +func (c *MeshStackProviderClient) ReadWorkspaceGroupBinding(name string) (*MeshWorkspaceGroupBinding, error) { return c.readWorkspaceBinding(name, CONTENT_TYPE_WORKSPACE_GROUP_BINDING) } From 7f0a4f8230fa50a96a0f318f1242504062dfe561 Mon Sep 17 00:00:00 2001 From: OliverEsoterik Date: Mon, 18 Aug 2025 18:56:55 +0200 Subject: [PATCH 029/215] fmt --- client.go | 36 ++++++++++++++++++------------------ workspace_binding.go | 6 +++--- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/client.go b/client.go index be64f4ce..1529c4ce 100644 --- a/client.go +++ b/client.go @@ -33,15 +33,15 @@ type MeshStackProviderClient struct { } type endpoints struct { - BuildingBlocks *url.URL `json:"meshbuildingblocks"` - Projects *url.URL `json:"meshprojects"` - ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` - ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` - Workspaces *url.URL `json:"meshworkspaces"` - WorkspaceUserBindings *url.URL `json:"meshworkspaceuserbindings"` - WorkspaceGroupBindings *url.URL `json:"meshworkspacegroupbindings"` - Tenants *url.URL `json:"meshtenants"` - TagDefinitions *url.URL `json:"meshtagdefinitions"` + BuildingBlocks *url.URL `json:"meshbuildingblocks"` + Projects *url.URL `json:"meshprojects"` + ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` + ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` + Workspaces *url.URL `json:"meshworkspaces"` + WorkspaceUserBindings *url.URL `json:"meshworkspaceuserbindings"` + WorkspaceGroupBindings *url.URL `json:"meshworkspacegroupbindings"` + Tenants *url.URL `json:"meshtenants"` + TagDefinitions *url.URL `json:"meshtagdefinitions"` } type loginResponse struct { @@ -62,15 +62,15 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro // TODO: lookup endpoints client.endpoints = endpoints{ - BuildingBlocks: rootUrl.JoinPath(apiMeshObjectsRoot, "meshbuildingblocks"), - Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), - ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), - ProjectGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "groupbindings"), - Workspaces: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspaces"), - WorkspaceUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "userbindings"), - WorkspaceGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "groupbindings"), - Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), - TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), + BuildingBlocks: rootUrl.JoinPath(apiMeshObjectsRoot, "meshbuildingblocks"), + Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), + ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), + ProjectGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "groupbindings"), + Workspaces: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspaces"), + WorkspaceUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "userbindings"), + WorkspaceGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "groupbindings"), + Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), + TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), } return client, nil diff --git a/workspace_binding.go b/workspace_binding.go index eb4718f0..ffef8951 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -10,12 +10,12 @@ import ( ) type MeshWorkspaceBinding struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` Metadata MeshWorkspaceBindingMetadata `json:"metadata" tfsdk:"metadata"` RoleRef MeshWorkspaceRoleRef `json:"roleRef" tfsdk:"role_ref"` TargetRef MeshWorkspaceTargetRef `json:"targetRef" tfsdk:"target_ref"` - Subject MeshWorkspaceSubject `json:"subject" tfsdk:"subject"` + Subject MeshWorkspaceSubject `json:"subject" tfsdk:"subject"` } type MeshWorkspaceBindingMetadata struct { From e36aba571a530437a6cfc5f978b6244cf416065d Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Mon, 18 Aug 2025 22:41:56 +0200 Subject: [PATCH 030/215] refactor: buildingblock status constants --- buildingblock_v2.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index be23299b..bf2bc55c 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -14,6 +14,14 @@ import ( const ( CONTENT_TYPE_BUILDING_BLOCK_V2 = "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json" + + // Building Block Status Constants + BUILDING_BLOCK_STATUS_WAITING_FOR_DEPENDENT_INPUT = "WAITING_FOR_DEPENDENT_INPUT" + BUILDING_BLOCK_STATUS_WAITING_FOR_OPERATOR_INPUT = "WAITING_FOR_OPERATOR_INPUT" + BUILDING_BLOCK_STATUS_PENDING = "PENDING" + BUILDING_BLOCK_STATUS_IN_PROGRESS = "IN_PROGRESS" + BUILDING_BLOCK_STATUS_SUCCEEDED = "SUCCEEDED" + BUILDING_BLOCK_STATUS_FAILED = "FAILED" ) type MeshBuildingBlockV2 struct { @@ -172,10 +180,10 @@ func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(uuid stri // Check if we've reached a terminal state status := current.Status.Status switch status { - case "SUCCEEDED": + case BUILDING_BLOCK_STATUS_SUCCEEDED: *result = current return nil // Success, stop retrying - case "FAILED": + case BUILDING_BLOCK_STATUS_FAILED: return retry.NonRetryableError(fmt.Errorf("building block %s reached FAILED state", uuid)) } @@ -204,7 +212,7 @@ func (c *MeshStackProviderClient) waitForBuildingBlockV2DeletionFunc(uuid string } // If building block is in FAILED state during deletion, consider it a terminal state - if current.Status.Status == "FAILED" { + if current.Status.Status == BUILDING_BLOCK_STATUS_FAILED { return retry.NonRetryableError(fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", uuid)) } From d95061152c6ac8c78b12dea12025beeca2285b2f Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Fri, 29 Aug 2025 13:44:38 +0200 Subject: [PATCH 031/215] refactor: move type out of loop --- tag_definition.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/tag_definition.go b/tag_definition.go index 67ff98df..b8d40eec 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -83,6 +83,18 @@ func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, er targetUrl := c.endpoints.TagDefinitions query := targetUrl.Query() + type tagsResponse struct { + Embedded struct { + MeshTagDefinitions []MeshTagDefinition `json:"meshTagDefinitions"` + } `json:"_embedded"` + Page struct { + Size int `json:"size"` + TotalElements int `json:"totalElements"` + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` + } + for { query.Set("page", fmt.Sprintf("%d", pageNumber)) @@ -111,18 +123,7 @@ func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, er return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) } - var response struct { - Embedded struct { - MeshTagDefinitions []MeshTagDefinition `json:"meshTagDefinitions"` - } `json:"_embedded"` - Page struct { - Size int `json:"size"` - TotalElements int `json:"totalElements"` - TotalPages int `json:"totalPages"` - Number int `json:"number"` - } `json:"page"` - } - + var response tagsResponse err = json.Unmarshal(data, &response) if err != nil { return nil, err From a46ce61ff61898765d2c943ea7831f3a2f4c3b13 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Fri, 29 Aug 2025 15:25:30 +0200 Subject: [PATCH 032/215] fix: use pointers for optional tag value fields This ensures we can differentiate between not setting a default and setting a default of the empty value, e.g. 0. gh-37 --- tag_definition.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tag_definition.go b/tag_definition.go index b8d40eec..dd36b221 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -45,31 +45,31 @@ type MeshTagDefinitionValueType struct { } type TagValueString struct { - DefaultValue string `json:"defaultValue,omitempty" tfsdk:"default_value"` - ValidationRegex string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` + DefaultValue *string `json:"defaultValue,omitempty" tfsdk:"default_value"` + ValidationRegex *string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` } type TagValueEmail struct { - DefaultValue string `json:"defaultValue,omitempty" tfsdk:"default_value"` - ValidationRegex string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` + DefaultValue *string `json:"defaultValue,omitempty" tfsdk:"default_value"` + ValidationRegex *string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` } type TagValueInteger struct { - DefaultValue int64 `json:"defaultValue,omitempty" tfsdk:"default_value"` + DefaultValue *int64 `json:"defaultValue,omitempty" tfsdk:"default_value"` } type TagValueNumber struct { - DefaultValue float64 `json:"defaultValue,omitempty" tfsdk:"default_value"` + DefaultValue *float64 `json:"defaultValue,omitempty" tfsdk:"default_value"` } type TagValueSingleSelect struct { Options []string `json:"options,omitempty" tfsdk:"options"` - DefaultValue string `json:"defaultValue,omitempty" tfsdk:"default_value"` + DefaultValue *string `json:"defaultValue,omitempty" tfsdk:"default_value"` } type TagValueMultiSelect struct { - Options []string `json:"options,omitempty" tfsdk:"options"` - DefaultValue []string `json:"defaultValue,omitempty" tfsdk:"default_value"` + Options []string `json:"options,omitempty" tfsdk:"options"` + DefaultValue *[]string `json:"defaultValue,omitempty" tfsdk:"default_value"` } func (c *MeshStackProviderClient) urlForTagDefinition(name string) *url.URL { From 15dcd28862fda1132d2ce58cff49f1009c889461 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Fri, 29 Aug 2025 16:22:20 +0200 Subject: [PATCH 033/215] fix: add missing replicationKey to tag definition gh-30 --- tag_definition.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tag_definition.go b/tag_definition.go index dd36b221..7916b9cb 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -24,15 +24,16 @@ type MeshTagDefinitionMetadata struct { } type MeshTagDefinitionSpec struct { - TargetKind string `json:"targetKind" tfsdk:"target_kind"` - Key string `json:"key" tfsdk:"key"` - ValueType MeshTagDefinitionValueType `json:"valueType" tfsdk:"value_type"` - Description string `json:"description" tfsdk:"description"` - DisplayName string `json:"displayName" tfsdk:"display_name"` - SortOrder int64 `json:"sortOrder" tfsdk:"sort_order"` - Mandatory bool `json:"mandatory" tfsdk:"mandatory"` - Immutable bool `json:"immutable" tfsdk:"immutable"` - Restricted bool `json:"restricted" tfsdk:"restricted"` + TargetKind string `json:"targetKind" tfsdk:"target_kind"` + Key string `json:"key" tfsdk:"key"` + ValueType MeshTagDefinitionValueType `json:"valueType" tfsdk:"value_type"` + Description string `json:"description" tfsdk:"description"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + SortOrder int64 `json:"sortOrder" tfsdk:"sort_order"` + Mandatory bool `json:"mandatory" tfsdk:"mandatory"` + Immutable bool `json:"immutable" tfsdk:"immutable"` + Restricted bool `json:"restricted" tfsdk:"restricted"` + ReplicationKey *string `json:"replicationKey,omitempty" tfsdk:"replication_key"` } type MeshTagDefinitionValueType struct { From ac3b80b280621226d85677e35ac6cec11aec64ca Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Thu, 18 Sep 2025 11:28:24 +0200 Subject: [PATCH 034/215] chore: format all code according to go-fmt --- client.go | 36 +++++++++++++------------- workspace_binding.go | 2 +- workspace_group_binding.go | 52 +++++++++++++++++++------------------- 3 files changed, 45 insertions(+), 45 deletions(-) diff --git a/client.go b/client.go index 1529c4ce..7c505fb1 100644 --- a/client.go +++ b/client.go @@ -33,15 +33,15 @@ type MeshStackProviderClient struct { } type endpoints struct { - BuildingBlocks *url.URL `json:"meshbuildingblocks"` - Projects *url.URL `json:"meshprojects"` - ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` - ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` - Workspaces *url.URL `json:"meshworkspaces"` - WorkspaceUserBindings *url.URL `json:"meshworkspaceuserbindings"` - WorkspaceGroupBindings *url.URL `json:"meshworkspacegroupbindings"` - Tenants *url.URL `json:"meshtenants"` - TagDefinitions *url.URL `json:"meshtagdefinitions"` + BuildingBlocks *url.URL `json:"meshbuildingblocks"` + Projects *url.URL `json:"meshprojects"` + ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` + ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` + Workspaces *url.URL `json:"meshworkspaces"` + WorkspaceUserBindings *url.URL `json:"meshworkspaceuserbindings"` + WorkspaceGroupBindings *url.URL `json:"meshworkspacegroupbindings"` + Tenants *url.URL `json:"meshtenants"` + TagDefinitions *url.URL `json:"meshtagdefinitions"` } type loginResponse struct { @@ -62,15 +62,15 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro // TODO: lookup endpoints client.endpoints = endpoints{ - BuildingBlocks: rootUrl.JoinPath(apiMeshObjectsRoot, "meshbuildingblocks"), - Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), - ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), - ProjectGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "groupbindings"), - Workspaces: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspaces"), - WorkspaceUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "userbindings"), - WorkspaceGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "groupbindings"), - Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), - TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), + BuildingBlocks: rootUrl.JoinPath(apiMeshObjectsRoot, "meshbuildingblocks"), + Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), + ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), + ProjectGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "groupbindings"), + Workspaces: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspaces"), + WorkspaceUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "userbindings"), + WorkspaceGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "groupbindings"), + Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), + TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), } return client, nil diff --git a/workspace_binding.go b/workspace_binding.go index ffef8951..a03b5867 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -27,7 +27,7 @@ type MeshWorkspaceRoleRef struct { } type MeshWorkspaceTargetRef struct { - Name string `json:"name" tfsdk:"name"` + Name string `json:"name" tfsdk:"name"` } type MeshWorkspaceSubject struct { diff --git a/workspace_group_binding.go b/workspace_group_binding.go index 0f3e7d69..a32a5827 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -1,26 +1,26 @@ -package client - -import ( - "net/url" -) - -const CONTENT_TYPE_WORKSPACE_GROUP_BINDING = "application/vnd.meshcloud.api.meshworkspacegroupbinding.v2.hal+json" - -type MeshWorkspaceGroupBinding = MeshWorkspaceBinding - -func (c *MeshStackProviderClient) urlForWorkspaceGroupBinding(name string) *url.URL { - return c.endpoints.WorkspaceGroupBindings.JoinPath(name) -} - -func (c *MeshStackProviderClient) ReadWorkspaceGroupBinding(name string) (*MeshWorkspaceGroupBinding, error) { - return c.readWorkspaceBinding(name, CONTENT_TYPE_WORKSPACE_GROUP_BINDING) -} - -func (c *MeshStackProviderClient) CreateWorkspaceGroupBinding(binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { - return c.createWorkspaceBinding(binding, CONTENT_TYPE_WORKSPACE_GROUP_BINDING) -} - -func (c *MeshStackProviderClient) DeleteWorkspaceGroupBinding(name string) error { - targetUrl := c.urlForWorkspaceGroupBinding(name) - return c.deleteMeshObject(*targetUrl, 204) -} +package client + +import ( + "net/url" +) + +const CONTENT_TYPE_WORKSPACE_GROUP_BINDING = "application/vnd.meshcloud.api.meshworkspacegroupbinding.v2.hal+json" + +type MeshWorkspaceGroupBinding = MeshWorkspaceBinding + +func (c *MeshStackProviderClient) urlForWorkspaceGroupBinding(name string) *url.URL { + return c.endpoints.WorkspaceGroupBindings.JoinPath(name) +} + +func (c *MeshStackProviderClient) ReadWorkspaceGroupBinding(name string) (*MeshWorkspaceGroupBinding, error) { + return c.readWorkspaceBinding(name, CONTENT_TYPE_WORKSPACE_GROUP_BINDING) +} + +func (c *MeshStackProviderClient) CreateWorkspaceGroupBinding(binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { + return c.createWorkspaceBinding(binding, CONTENT_TYPE_WORKSPACE_GROUP_BINDING) +} + +func (c *MeshStackProviderClient) DeleteWorkspaceGroupBinding(name string) error { + targetUrl := c.urlForWorkspaceGroupBinding(name) + return c.deleteMeshObject(*targetUrl, 204) +} From f8e67de96633848854f6b55867bddbee3b44b2e4 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Thu, 18 Sep 2025 12:39:30 +0200 Subject: [PATCH 035/215] chore: fix typo in workspace.go (paylod) --- workspace.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/workspace.go b/workspace.go index 0ade57cd..bdf878f3 100644 --- a/workspace.go +++ b/workspace.go @@ -81,12 +81,12 @@ func (c *MeshStackProviderClient) ReadWorkspace(name string) (*MeshWorkspace, er } func (c *MeshStackProviderClient) CreateWorkspace(workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { - paylod, err := json.Marshal(workspace) + payload, err := json.Marshal(workspace) if err != nil { return nil, err } - req, err := http.NewRequest("POST", c.endpoints.Workspaces.String(), bytes.NewBuffer(paylod)) + req, err := http.NewRequest("POST", c.endpoints.Workspaces.String(), bytes.NewBuffer(payload)) if err != nil { return nil, err } @@ -119,12 +119,12 @@ func (c *MeshStackProviderClient) CreateWorkspace(workspace *MeshWorkspaceCreate func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { targetUrl := c.urlForWorkspace(name) - paylod, err := json.Marshal(workspace) + payload, err := json.Marshal(workspace) if err != nil { return nil, err } - req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(paylod)) + req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) if err != nil { return nil, err } From f4482d080eb07d2c92b75213829732788a1c0942 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Wed, 10 Sep 2025 10:54:40 +0200 Subject: [PATCH 036/215] feat: support meshLandingZones CU-86c57xgjy --- client.go | 2 + landingzone.go | 179 ++++++++++++++++++++++++++++++ platform_properties_aks.go | 10 ++ platform_properties_aws.go | 14 +++ platform_properties_azure.go | 17 +++ platform_properties_azurerg.go | 18 +++ platform_properties_gcp.go | 12 ++ platform_properties_kubernetes.go | 5 + platform_properties_openshift.go | 5 + project_binding.go | 7 ++ 10 files changed, 269 insertions(+) create mode 100644 landingzone.go create mode 100644 platform_properties_aks.go create mode 100644 platform_properties_aws.go create mode 100644 platform_properties_azure.go create mode 100644 platform_properties_azurerg.go create mode 100644 platform_properties_gcp.go create mode 100644 platform_properties_kubernetes.go create mode 100644 platform_properties_openshift.go diff --git a/client.go b/client.go index 7c505fb1..6d06efa0 100644 --- a/client.go +++ b/client.go @@ -42,6 +42,7 @@ type endpoints struct { WorkspaceGroupBindings *url.URL `json:"meshworkspacegroupbindings"` Tenants *url.URL `json:"meshtenants"` TagDefinitions *url.URL `json:"meshtagdefinitions"` + LandingZones *url.URL `json:"meshlandingzones"` } type loginResponse struct { @@ -71,6 +72,7 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro WorkspaceGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "groupbindings"), Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), + LandingZones: rootUrl.JoinPath(apiMeshObjectsRoot, "meshlandingzones"), } return client, nil diff --git a/landingzone.go b/landingzone.go new file mode 100644 index 00000000..ec4d097a --- /dev/null +++ b/landingzone.go @@ -0,0 +1,179 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const CONTENT_TYPE_LANDINGZONE = "application/vnd.meshcloud.api.meshlandingzone.v1-preview.hal+json" + +type MeshLandingZone struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` +} + +type MeshLandingZoneMetadata struct { + Name string `json:"name" tfsdk:"name"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` +} + +type MeshLandingZoneSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` + AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` + InfoLink string `json:"infoLink" tfsdk:"info_link"` + PlatformRef PlatformRef `json:"platformRef" tfsdk:"platform_ref"` + PlatformProperties *PlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` +} + +type PlatformRef struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + Kind string `json:"kind" tfsdk:"kind"` +} + +type PlatformProperties struct { + Type string `json:"type" tfsdk:"type"` + AWS *AwsPlatformProperties `json:"aws" tfsdk:"aws"` + AKS *AksPlatformProperties `json:"aks" tfsdk:"aks"` + Azure *AzurePlatformProperties `json:"azure" tfsdk:"azure"` + AzureRG *AzureRgPlatformProperties `json:"azurerg" tfsdk:"azurerg"` + GCP *GcpPlatformProperties `json:"gcp" tfsdk:"gcp"` + Kubernetes *KubernetesPlatformProperties `json:"kubernetes" tfsdk:"kubernetes"` + OpenShift *OpenShiftPlatformProperties `json:"openshift" tfsdk:"openshift"` +} + +type MeshLandingZoneCreate struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Metadata MeshLandingZoneCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` +} +type MeshLandingZoneCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` +} + +func (c *MeshStackProviderClient) urlForLandingZone(name string) *url.URL { + return c.endpoints.LandingZones.JoinPath(name) +} + +func (c *MeshStackProviderClient) ReadLandingZone(name string) (*MeshLandingZone, error) { + targetUrl := c.urlForLandingZone(name) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return nil, nil // Not found is not an error + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var landingZone MeshLandingZone + err = json.Unmarshal(data, &landingZone) + if err != nil { + return nil, err + } + return &landingZone, nil +} + +func (c *MeshStackProviderClient) CreateLandingZone(landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { + payload, err := json.Marshal(landingZone) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.LandingZones.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_LANDINGZONE) + req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdLandingZone MeshLandingZone + err = json.Unmarshal(data, &createdLandingZone) + if err != nil { + return nil, err + } + return &createdLandingZone, nil +} + +func (c *MeshStackProviderClient) UpdateLandingZone(name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { + targetUrl := c.urlForLandingZone(name) + + payload, err := json.Marshal(landingZone) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_LANDINGZONE) + req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var updatedLandingZone MeshLandingZone + err = json.Unmarshal(data, &updatedLandingZone) + if err != nil { + return nil, err + } + return &updatedLandingZone, nil +} + +func (c *MeshStackProviderClient) DeleteLandingZone(name string) error { + targetUrl := c.urlForLandingZone(name) + return c.deleteMeshObject(*targetUrl, 204) +} diff --git a/platform_properties_aks.go b/platform_properties_aks.go new file mode 100644 index 00000000..4599a5fe --- /dev/null +++ b/platform_properties_aks.go @@ -0,0 +1,10 @@ +package client + +type AksPlatformProperties struct { + KubernetesRoleMappings []KubernetesRoleMapping `json:"kubernetesRoleMappings" tfsdk:"kubernetes_role_mappings"` +} + +type KubernetesRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + PlatformRoles []string `json:"platformRoles" tfsdk:"platform_roles"` +} diff --git a/platform_properties_aws.go b/platform_properties_aws.go new file mode 100644 index 00000000..bbd8480d --- /dev/null +++ b/platform_properties_aws.go @@ -0,0 +1,14 @@ +package client + +type AwsPlatformProperties struct { + AwsTargetOrgUnitId string `json:"awsTargetOrgUnitId" tfsdk:"aws_target_org_unit_id"` + AwsEnrollAccount bool `json:"awsEnrollAccount" tfsdk:"aws_enroll_account"` + AwsLambdaArn *string `json:"awsLambdaArn" tfsdk:"aws_lambda_arn"` + AwsRoleMappings []AwsRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` +} + +type AwsRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + PlatformRole string `json:"platformRole" tfsdk:"platform_role"` + Policies []string `json:"policies" tfsdk:"policies"` +} diff --git a/platform_properties_azure.go b/platform_properties_azure.go new file mode 100644 index 00000000..a06c0101 --- /dev/null +++ b/platform_properties_azure.go @@ -0,0 +1,17 @@ +package client + +type AzurePlatformProperties struct { + AzureRoleMappings []AzureRoleMapping `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` + AzureManagementGroupId string `json:"azureManagementGroupId" tfsdk:"azure_management_group_id"` +} + +type AzureRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureGroupSuffix string `json:"azureGroupSuffix" tfsdk:"azure_group_suffix"` + AzureRoleDefinitions []AzureRoleDefinition `json:"azureRoleDefinitions" tfsdk:"azure_role_definitions"` +} + +type AzureRoleDefinition struct { + AzureRoleDefinitionId string `json:"azureRoleDefinitionId" tfsdk:"azure_role_definition_id"` + AbacCondition *string `json:"abacCondition" tfsdk:"abac_condition"` +} diff --git a/platform_properties_azurerg.go b/platform_properties_azurerg.go new file mode 100644 index 00000000..dc97e8fe --- /dev/null +++ b/platform_properties_azurerg.go @@ -0,0 +1,18 @@ +package client + +type AzureRgPlatformProperties struct { + AzureRgLocation string `json:"azureRgLocation" tfsdk:"azure_rg_location"` + AzureRgRoleMappings []AzureRgRoleMapping `json:"azureRgRoleMappings" tfsdk:"azure_rg_role_mappings"` + AzureFunction *AzureFunction `json:"azureFunction,omitempty" tfsdk:"azure_function"` +} + +type AzureRgRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureGroupSuffix string `json:"azureGroupSuffix" tfsdk:"azure_group_suffix"` + AzureRoleDefinitionIds []string `json:"azureRoleDefinitionIds" tfsdk:"azure_role_definition_ids"` +} + +type AzureFunction struct { + AzureFunctionUrl string `json:"azureFunctionUrl" tfsdk:"azure_function_url"` + AzureFunctionScope string `json:"azureFunctionScope" tfsdk:"azure_function_scope"` +} diff --git a/platform_properties_gcp.go b/platform_properties_gcp.go new file mode 100644 index 00000000..c8bb02b0 --- /dev/null +++ b/platform_properties_gcp.go @@ -0,0 +1,12 @@ +package client + +type GcpPlatformProperties struct { + GcpCloudFunctionUrl *string `json:"gcpCloudFunctionUrl,omitempty" tfsdk:"gcp_cloud_function_url"` + GcpFolderId *string `json:"gcpFolderId,omitempty" tfsdk:"gcp_folder_id"` + GcpRoleMappings []GcpRoleMapping `json:"gcpRoleMappings" tfsdk:"gcp_role_mappings"` +} + +type GcpRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + PlatformRoles []string `json:"platformRoles" tfsdk:"platform_roles"` +} diff --git a/platform_properties_kubernetes.go b/platform_properties_kubernetes.go new file mode 100644 index 00000000..b48c338a --- /dev/null +++ b/platform_properties_kubernetes.go @@ -0,0 +1,5 @@ +package client + +type KubernetesPlatformProperties struct { + KubernetesRoleMappings []KubernetesRoleMapping `json:"kubernetesRoleMappings" tfsdk:"kubernetes_role_mappings"` +} diff --git a/platform_properties_openshift.go b/platform_properties_openshift.go new file mode 100644 index 00000000..15d67521 --- /dev/null +++ b/platform_properties_openshift.go @@ -0,0 +1,5 @@ +package client + +type OpenShiftPlatformProperties struct { + OpenShiftTemplate *string `json:"openShiftTemplate,omitempty" tfsdk:"openshift_template"` +} diff --git a/project_binding.go b/project_binding.go index f704efce..9e7138e9 100644 --- a/project_binding.go +++ b/project_binding.go @@ -22,10 +22,17 @@ type MeshProjectBindingMetadata struct { Name string `json:"name" tfsdk:"name"` } +// Deprecated: Use MeshProjectRoleRefV2 if possible. The convention is to also provide the `kind`, +// so this struct should only be used for meshobjects that violate our API conventions. type MeshProjectRoleRef struct { Name string `json:"name" tfsdk:"name"` } +type MeshProjectRoleRefV2 struct { + Name string `json:"name" tfsdk:"name"` + Kind string `json:"kind" tfsdk:"kind"` +} + type MeshProjectTargetRef struct { Name string `json:"name" tfsdk:"name"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` From 4782eb04cd0ecca5b12d509c0e55a3da43613cf8 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Fri, 19 Sep 2025 13:49:52 +0200 Subject: [PATCH 037/215] chore: fix capitalization: AWS -> Aws etc. --- landingzone.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/landingzone.go b/landingzone.go index ec4d097a..f926510f 100644 --- a/landingzone.go +++ b/landingzone.go @@ -40,11 +40,11 @@ type PlatformRef struct { type PlatformProperties struct { Type string `json:"type" tfsdk:"type"` - AWS *AwsPlatformProperties `json:"aws" tfsdk:"aws"` - AKS *AksPlatformProperties `json:"aks" tfsdk:"aks"` + Aws *AwsPlatformProperties `json:"aws" tfsdk:"aws"` + Aks *AksPlatformProperties `json:"aks" tfsdk:"aks"` Azure *AzurePlatformProperties `json:"azure" tfsdk:"azure"` - AzureRG *AzureRgPlatformProperties `json:"azurerg" tfsdk:"azurerg"` - GCP *GcpPlatformProperties `json:"gcp" tfsdk:"gcp"` + AzureRg *AzureRgPlatformProperties `json:"azurerg" tfsdk:"azurerg"` + Gcp *GcpPlatformProperties `json:"gcp" tfsdk:"gcp"` Kubernetes *KubernetesPlatformProperties `json:"kubernetes" tfsdk:"kubernetes"` OpenShift *OpenShiftPlatformProperties `json:"openshift" tfsdk:"openshift"` } From 409ebff63b0db286fc1bd0b92c5e6319aac9c884 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Fri, 19 Sep 2025 13:56:34 +0200 Subject: [PATCH 038/215] fix: add missing landing zone status --- landingzone.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/landingzone.go b/landingzone.go index f926510f..5d19dd24 100644 --- a/landingzone.go +++ b/landingzone.go @@ -16,6 +16,7 @@ type MeshLandingZone struct { Kind string `json:"kind" tfsdk:"kind"` Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` + Status MeshLandingZoneStatus `json:"status" tfsdk:"status"` } type MeshLandingZoneMetadata struct { @@ -33,6 +34,11 @@ type MeshLandingZoneSpec struct { PlatformProperties *PlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` } +type MeshLandingZoneStatus struct { + Disabled string `json:"disabled" tfsdk:"disabled"` + Restricted string `json:"restricted" tfsdk:"restricted"` +} + type PlatformRef struct { Uuid string `json:"uuid" tfsdk:"uuid"` Kind string `json:"kind" tfsdk:"kind"` From b6c1a50da38e982308a20ef48faf05113b0000c6 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Fri, 19 Sep 2025 13:57:55 +0200 Subject: [PATCH 039/215] chore: remove redundant MeshLandingZoneCreateMetadata --- landingzone.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/landingzone.go b/landingzone.go index 5d19dd24..a0e4b6ff 100644 --- a/landingzone.go +++ b/landingzone.go @@ -56,13 +56,9 @@ type PlatformProperties struct { } type MeshLandingZoneCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Metadata MeshLandingZoneCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` -} -type MeshLandingZoneCreateMetadata struct { - Name string `json:"name" tfsdk:"name"` - Tags map[string][]string `json:"tags" tfsdk:"tags"` + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` } func (c *MeshStackProviderClient) urlForLandingZone(name string) *url.URL { From e918df69b900a8c85fb0f44b7047c486cfc93385 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Fri, 19 Sep 2025 14:38:44 +0200 Subject: [PATCH 040/215] fix: fix landing zone status --- landingzone.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/landingzone.go b/landingzone.go index a0e4b6ff..6f60d0b3 100644 --- a/landingzone.go +++ b/landingzone.go @@ -35,8 +35,8 @@ type MeshLandingZoneSpec struct { } type MeshLandingZoneStatus struct { - Disabled string `json:"disabled" tfsdk:"disabled"` - Restricted string `json:"restricted" tfsdk:"restricted"` + Disabled bool `json:"disabled" tfsdk:"disabled"` + Restricted bool `json:"restricted" tfsdk:"restricted"` } type PlatformRef struct { From 0244cf4630ffe63a908667d0a3e16bbcd44efd7e Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Tue, 23 Sep 2025 11:13:34 +0200 Subject: [PATCH 041/215] feat: implement support for meshPlatforms CU-86c55h4xp --- client.go | 2 + platform.go | 503 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 platform.go diff --git a/client.go b/client.go index 6d06efa0..f9223fa9 100644 --- a/client.go +++ b/client.go @@ -43,6 +43,7 @@ type endpoints struct { Tenants *url.URL `json:"meshtenants"` TagDefinitions *url.URL `json:"meshtagdefinitions"` LandingZones *url.URL `json:"meshlandingzones"` + Platforms *url.URL `json:"meshplatforms"` } type loginResponse struct { @@ -73,6 +74,7 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), LandingZones: rootUrl.JoinPath(apiMeshObjectsRoot, "meshlandingzones"), + Platforms: rootUrl.JoinPath(apiMeshObjectsRoot, "meshplatforms"), } return client, nil diff --git a/platform.go b/platform.go new file mode 100644 index 00000000..57d5de7f --- /dev/null +++ b/platform.go @@ -0,0 +1,503 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const CONTENT_TYPE_PLATFORM = "application/vnd.meshcloud.api.meshplatform.v2-preview.hal+json" + +type MeshPlatform struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshPlatformMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPlatformMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Uuid string `json:"uuid" tfsdk:"uuid"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` +} + +type MeshPlatformSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + Endpoint string `json:"endpoint" tfsdk:"endpoint"` + SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` + DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + LocationRef LocationRef `json:"locationRef" tfsdk:"location_ref"` + ContributingWorkspaces []string `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` + Availability PlatformAvailability `json:"availability" tfsdk:"availability"` + Config PlatformConfig `json:"config" tfsdk:"config"` +} + +type LocationRef struct { + Kind string `json:"kind" tfsdk:"kind"` + Name string `json:"name" tfsdk:"name"` +} + +type PlatformAvailability struct { + Restriction string `json:"restriction" tfsdk:"restriction"` + PublicationState string `json:"publicationState" tfsdk:"publication_state"` + RestrictedToWorkspaces []string `json:"restrictedToWorkspaces,omitempty" tfsdk:"restricted_to_workspaces"` +} + +type PlatformConfig struct { + Type string `json:"type" tfsdk:"type"` + Aws *AwsPlatformConfig `json:"aws,omitempty" tfsdk:"aws"` + Aks *AksPlatformConfig `json:"aks,omitempty" tfsdk:"aks"` + Azure *AzurePlatformConfig `json:"azure,omitempty" tfsdk:"azure"` + AzureRg *AzureRgPlatformConfig `json:"azurerg,omitempty" tfsdk:"azurerg"` + Gcp *GcpPlatformConfig `json:"gcp,omitempty" tfsdk:"gcp"` + Kubernetes *KubernetesPlatformConfig `json:"kubernetes,omitempty" tfsdk:"kubernetes"` + OpenShift *OpenShiftPlatformConfig `json:"openshift,omitempty" tfsdk:"openshift"` +} + +type AwsPlatformConfig struct { + Region *string `json:"region,omitempty" tfsdk:"region"` + Replication *AwsReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` +} + +type AksPlatformConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` + Replication *AksReplicationConfig `json:"replication" tfsdk:"replication"` +} + +type AzurePlatformConfig struct { + EntraTenant *string `json:"entraTenant,omitempty" tfsdk:"entra_tenant"` + Replication *AzureReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` +} + +type AzureRgPlatformConfig struct { + EntraTenant *string `json:"entraTenant,omitempty" tfsdk:"entra_tenant"` + Replication *AzureRgReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` +} + +type GcpPlatformConfig struct { + Replication *GcpReplicationConfig `json:"replication" tfsdk:"replication"` +} + +type KubernetesPlatformConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` + Replication *KubernetesReplicationConfig `json:"replication" tfsdk:"replication"` +} + +type OpenShiftPlatformConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` + Replication *OpenShiftReplicationConfig `json:"replication" tfsdk:"replication"` +} + +type AzureRgReplicationConfig struct { + ServicePrincipal *AzureServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` + Subscription *string `json:"subscription,omitempty" tfsdk:"subscription"` + ResourceGroupNamePattern *string `json:"resourceGroupNamePattern,omitempty" tfsdk:"resource_group_name_pattern"` + UserGroupNamePattern *string `json:"userGroupNamePattern,omitempty" tfsdk:"user_group_name_pattern"` + B2bUserInvitation *AzureB2bUserInvitation `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` + TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AllowHierarchicalManagementGroupAssignment *bool `json:"allowHierarchicalManagementGroupAssignment,omitempty" tfsdk:"allow_hierarchical_management_group_assignment"` +} + +type GcpReplicationConfig struct { + ServiceAccountConfig *GcpServiceAccountConfig `json:"serviceAccountConfig,omitempty" tfsdk:"service_account_config"` + Domain *string `json:"domain,omitempty" tfsdk:"domain"` + CustomerId *string `json:"customerId,omitempty" tfsdk:"customer_id"` + GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` + ProjectNamePattern *string `json:"projectNamePattern,omitempty" tfsdk:"project_name_pattern"` + ProjectIdPattern *string `json:"projectIdPattern,omitempty" tfsdk:"project_id_pattern"` + BillingAccountId *string `json:"billingAccountId,omitempty" tfsdk:"billing_account_id"` + UserLookupStrategy *string `json:"userLookupStrategy,omitempty" tfsdk:"user_lookup_strategy"` + GcpRoleMappings []GcpPlatformRoleMapping `json:"gcpRoleMappings,omitempty" tfsdk:"gcp_role_mappings"` + AllowHierarchicalFolderAssignment *bool `json:"allowHierarchicalFolderAssignment,omitempty" tfsdk:"allow_hierarchical_folder_assignment"` + TenantTags *GcpTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` +} + +type GcpServiceAccountConfig struct { + ServiceAccountCredentialsConfig *GcpServiceAccountCredentialsConfig `json:"serviceAccountCredentialsConfig,omitempty" tfsdk:"service_account_credentials_config"` + ServiceAccountWorkloadIdentityConfig *GcpServiceAccountWorkloadIdentityConfig `json:"serviceAccountWorkloadIdentityConfig,omitempty" tfsdk:"service_account_workload_identity_config"` +} + +type GcpServiceAccountCredentialsConfig struct { + ServiceAccountCredentialsB64 *string `json:"serviceAccountCredentialsB64,omitempty" tfsdk:"service_account_credentials_b64"` +} + +type GcpServiceAccountWorkloadIdentityConfig struct { + Audience *string `json:"audience,omitempty" tfsdk:"audience"` + ServiceAccountEmail *string `json:"serviceAccountEmail,omitempty" tfsdk:"service_account_email"` +} + +type GcpTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers []GcpTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type GcpTagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} + +type KubernetesReplicationConfig struct { + ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + NamespaceNamePattern *string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` +} + +type KubernetesClientConfig struct { + AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` +} + +type OpenShiftReplicationConfig struct { + ClientConfig *OpenShiftClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` + ProjectNamePattern *string `json:"projectNamePattern,omitempty" tfsdk:"project_name_pattern"` + EnableTemplateInstantiation *bool `json:"enableTemplateInstantiation,omitempty" tfsdk:"enable_template_instantiation"` + OpenShiftRoleMappings []OpenShiftPlatformRoleMapping `json:"openshiftRoleMappings,omitempty" tfsdk:"openshift_role_mappings"` + IdentityProviderName *string `json:"identityProviderName,omitempty" tfsdk:"identity_provider_name"` + TenantTags *OpenShiftTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` +} + +type OpenShiftClientConfig struct { + AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` +} + +type OpenShiftTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers []OpenShiftTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type OpenShiftTagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} + +type AksReplicationConfig struct { + AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` + NamespaceNamePattern *string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` + GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` + ServicePrincipal *ServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` + AksSubscriptionId *string `json:"aksSubscriptionId,omitempty" tfsdk:"aks_subscription_id"` + AksClusterName *string `json:"aksClusterName,omitempty" tfsdk:"aks_cluster_name"` + AksResourceGroup *string `json:"aksResourceGroup,omitempty" tfsdk:"aks_resource_group"` + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` + SendAzureInvitationMail *bool `json:"sendAzureInvitationMail,omitempty" tfsdk:"send_azure_invitation_mail"` + UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` +} + +type ServicePrincipalConfig struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + AuthType string `json:"authType" tfsdk:"auth_type"` + CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` + EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` + ObjectId string `json:"objectId" tfsdk:"object_id"` +} + +// Azure-specific service principal configurations +type AzureServicePrincipalConfig struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + AuthType string `json:"authType" tfsdk:"auth_type"` + CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` + ObjectId string `json:"objectId" tfsdk:"object_id"` +} + +type AzureSourceServicePrincipalConfig struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + AuthType string `json:"authType" tfsdk:"auth_type"` + CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` +} + +// AWS-specific replication configuration structures +type AwsReplicationConfig struct { + AccessConfig *AwsAccessConfig `json:"accessConfig,omitempty" tfsdk:"access_config"` + WaitForExternalAvm *bool `json:"waitForExternalAvm,omitempty" tfsdk:"wait_for_external_avm"` + AutomationAccountRole *string `json:"automationAccountRole,omitempty" tfsdk:"automation_account_role"` + AutomationAccountExternalId *string `json:"automationAccountExternalId,omitempty" tfsdk:"automation_account_external_id"` + AccountAccessRole *string `json:"accountAccessRole,omitempty" tfsdk:"account_access_role"` + AccountAliasPattern *string `json:"accountAliasPattern,omitempty" tfsdk:"account_alias_pattern"` + EnforceAccountAlias *bool `json:"enforceAccountAlias,omitempty" tfsdk:"enforce_account_alias"` + AccountEmailPattern *string `json:"accountEmailPattern,omitempty" tfsdk:"account_email_pattern"` + TenantTags *AwsTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + AwsSso *AwsSsoConfig `json:"awsSso,omitempty" tfsdk:"aws_sso"` + EnrollmentConfiguration *AwsEnrollmentConfiguration `json:"enrollmentConfiguration,omitempty" tfsdk:"enrollment_configuration"` + SelfDowngradeAccessRole *bool `json:"selfDowngradeAccessRole,omitempty" tfsdk:"self_downgrade_access_role"` + SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` + AllowHierarchicalOrganizationalUnitAssignment *bool `json:"allowHierarchicalOrganizationalUnitAssignment,omitempty" tfsdk:"allow_hierarchical_organizational_unit_assignment"` +} + +type AwsAccessConfig struct { + OrganizationRootAccountRole string `json:"organizationRootAccountRole" tfsdk:"organization_root_account_role"` + OrganizationRootAccountExternalId *string `json:"organizationRootAccountExternalId,omitempty" tfsdk:"organization_root_account_external_id"` + ServiceUserConfig *AwsServiceUserConfig `json:"serviceUserConfig,omitempty" tfsdk:"service_user_config"` + WorkloadIdentityConfig *AwsWorkloadIdentityConfig `json:"workloadIdentityConfig,omitempty" tfsdk:"workload_identity_config"` +} + +type AwsServiceUserConfig struct { + AccessKey string `json:"accessKey" tfsdk:"access_key"` + SecretKey *string `json:"secretKey,omitempty" tfsdk:"secret_key"` +} + +type AwsWorkloadIdentityConfig struct { + RoleArn string `json:"roleArn" tfsdk:"role_arn"` +} + +type AwsTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers []AwsTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type AwsTagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} + +type AwsSsoConfig struct { + ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` + Arn string `json:"arn" tfsdk:"arn"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + SsoAccessToken *string `json:"ssoAccessToken,omitempty" tfsdk:"sso_access_token"` + AwsRoleMappings []AwsSsoRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` + SignInUrl *string `json:"signInUrl,omitempty" tfsdk:"sign_in_url"` +} + +type AwsSsoRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + AwsRole string `json:"awsRole" tfsdk:"aws_role"` + PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` +} + +type GcpPlatformRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + GcpRole string `json:"gcpRole" tfsdk:"gcp_role"` +} + +type OpenShiftPlatformRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + OpenShiftRole string `json:"openshiftRole" tfsdk:"openshift_role"` +} + +type AwsEnrollmentConfiguration struct { + ManagementAccountId string `json:"managementAccountId" tfsdk:"management_account_id"` + AccountFactoryProductId string `json:"accountFactoryProductId" tfsdk:"account_factory_product_id"` +} + +// Azure-specific replication configuration structures +type AzureReplicationConfig struct { + ServicePrincipal *AzureServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` + Provisioning *AzureProvisioning `json:"provisioning,omitempty" tfsdk:"provisioning"` + B2bUserInvitation *AzureB2bUserInvitation `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + SubscriptionNamePattern *string `json:"subscriptionNamePattern,omitempty" tfsdk:"subscription_name_pattern"` + GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` + BlueprintServicePrincipal *string `json:"blueprintServicePrincipal,omitempty" tfsdk:"blueprint_service_principal"` + BlueprintLocation *string `json:"blueprintLocation,omitempty" tfsdk:"blueprint_location"` + AzureRoleMappings []AzurePlatformRoleMapping `json:"azureRoleMappings,omitempty" tfsdk:"azure_role_mappings"` + TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` + SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AllowHierarchicalManagementGroupAssignment *bool `json:"allowHierarchicalManagementGroupAssignment,omitempty" tfsdk:"allow_hierarchical_management_group_assignment"` +} + +type AzureProvisioning struct { + SubscriptionOwnerObjectIds []string `json:"subscriptionOwnerObjectIds,omitempty" tfsdk:"subscription_owner_object_ids"` + EnterpriseEnrollment *AzureEnterpriseEnrollment `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` + CustomerAgreement *AzureCustomerAgreement `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` + PreProvisioned *AzurePreProvisioned `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` +} + +type AzureEnterpriseEnrollment struct { + EnrollmentAccountId string `json:"enrollmentAccountId" tfsdk:"enrollment_account_id"` + SubscriptionOfferType string `json:"subscriptionOfferType" tfsdk:"subscription_offer_type"` + UseLegacySubscriptionEnrollment *bool `json:"useLegacySubscriptionEnrollment,omitempty" tfsdk:"use_legacy_subscription_enrollment"` + SubscriptionCreationErrorCooldownSec *int `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` +} + +type AzureCustomerAgreement struct { + SourceServicePrincipal *AzureSourceServicePrincipalConfig `json:"sourceServicePrincipal,omitempty" tfsdk:"source_service_principal"` + DestinationEntraId string `json:"destinationEntraId" tfsdk:"destination_entra_id"` + SourceEntraTenant string `json:"sourceEntraTenant" tfsdk:"source_entra_tenant"` + BillingScope string `json:"billingScope" tfsdk:"billing_scope"` + SubscriptionCreationErrorCooldownSec *int `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` +} + +type AzurePreProvisioned struct { + UnusedSubscriptionNamePrefix string `json:"unusedSubscriptionNamePrefix" tfsdk:"unused_subscription_name_prefix"` +} + +type AzureB2bUserInvitation struct { + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` + SendAzureInvitationMail *bool `json:"sendAzureInvitationMail,omitempty" tfsdk:"send_azure_invitation_mail"` +} + +type AzurePlatformRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureRole AzurePlatformRoleDefinition `json:"azureRole" tfsdk:"azure_role"` +} + +type AzurePlatformRoleDefinition struct { + Alias string `json:"alias" tfsdk:"alias"` + Id string `json:"id" tfsdk:"id"` +} + +type AzureTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers []AzureTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type AzureTagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} + +type MeshPlatformCreate struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Metadata MeshPlatformCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPlatformCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshPlatformUpdate struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Metadata MeshPlatformUpdateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPlatformUpdateMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Uuid string `json:"uuid" tfsdk:"uuid"` +} + +func (c *MeshStackProviderClient) urlForPlatform(uuid string) *url.URL { + return c.endpoints.Platforms.JoinPath(uuid) +} + +func (c *MeshStackProviderClient) ReadPlatform(uuid string) (*MeshPlatform, error) { + targetUrl := c.urlForPlatform(uuid) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return nil, nil // Not found is not an error + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var platform MeshPlatform + err = json.Unmarshal(data, &platform) + if err != nil { + return nil, err + } + return &platform, nil +} + +func (c *MeshStackProviderClient) CreatePlatform(platform *MeshPlatformCreate) (*MeshPlatform, error) { + payload, err := json.Marshal(platform) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.Platforms.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_PLATFORM) + req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdPlatform MeshPlatform + err = json.Unmarshal(data, &createdPlatform) + if err != nil { + return nil, err + } + return &createdPlatform, nil +} + +func (c *MeshStackProviderClient) DeletePlatform(uuid string) error { + targetUrl := c.urlForPlatform(uuid) + return c.deleteMeshObject(*targetUrl, 204) +} + +func (c *MeshStackProviderClient) UpdatePlatform(uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { + targetUrl := c.urlForPlatform(uuid) + + payload, err := json.Marshal(platform) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_PLATFORM) + req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var updatedPlatform MeshPlatform + err = json.Unmarshal(data, &updatedPlatform) + if err != nil { + return nil, err + } + return &updatedPlatform, nil +} From d3fa716abaecf30a55dc46f84fea80a574138cfd Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Mon, 20 Oct 2025 15:03:41 +0200 Subject: [PATCH 042/215] fix: make landingzone info_link optional Fixes #60 --- landingzone.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landingzone.go b/landingzone.go index 6f60d0b3..20cca500 100644 --- a/landingzone.go +++ b/landingzone.go @@ -29,7 +29,7 @@ type MeshLandingZoneSpec struct { Description string `json:"description" tfsdk:"description"` AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` - InfoLink string `json:"infoLink" tfsdk:"info_link"` + InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` PlatformRef PlatformRef `json:"platformRef" tfsdk:"platform_ref"` PlatformProperties *PlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` } From 746f2c33683210e0517f06ee7e6da501153017c8 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Mon, 3 Nov 2025 12:13:36 +0100 Subject: [PATCH 043/215] chore: use new API endpoint for login and improve error message in case of login error --- client.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/client.go b/client.go index f9223fa9..608b7780 100644 --- a/client.go +++ b/client.go @@ -1,6 +1,7 @@ package client import ( + "bytes" "encoding/json" "errors" "fmt" @@ -8,7 +9,6 @@ import ( "log" "net/http" "net/url" - "strings" "time" ) @@ -46,6 +46,11 @@ type endpoints struct { Platforms *url.URL `json:"meshplatforms"` } +type loginRequest struct { + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + type loginResponse struct { Token string `json:"access_token"` ExpireSec int `json:"expires_in"` @@ -86,20 +91,25 @@ func (c *MeshStackProviderClient) login() error { return err } - formData := url.Values{} - formData.Set("client_id", c.apiKey) - formData.Set("client_secret", c.apiSecret) - formData.Set("grant_type", "client_credentials") + loginRequest := loginRequest{ + ClientId: c.apiKey, + ClientSecret: c.apiSecret, + } + + payload, err := json.Marshal(loginRequest) + if err != nil { + return err + } - req, _ := http.NewRequest(http.MethodPost, loginPath, strings.NewReader(formData.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req, _ := http.NewRequest(http.MethodPost, loginPath, bytes.NewBuffer(payload)) + req.Header.Add("Content-Type", "application/json") res, err := c.httpClient.Do(req) if err != nil { return err } else if res.StatusCode != 200 { - return errors.New(ERROR_AUTHENTICATION_FAILURE) + return errors.New(fmt.Sprintf("Status %d: %s", res.StatusCode, ERROR_AUTHENTICATION_FAILURE)) } defer res.Body.Close() From 1ee35ca16f4afcd3b98b9eda20290c788da9e9c8 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Thu, 6 Nov 2025 11:38:27 +0100 Subject: [PATCH 044/215] feat: add quota definitions to meshPlatforms --- platform.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/platform.go b/platform.go index 57d5de7f..5afdd3d8 100644 --- a/platform.go +++ b/platform.go @@ -36,6 +36,17 @@ type MeshPlatformSpec struct { ContributingWorkspaces []string `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` Availability PlatformAvailability `json:"availability" tfsdk:"availability"` Config PlatformConfig `json:"config" tfsdk:"config"` + QuotaDefinitions []QuotaDefinition `json:"quotaDefinitions" tfsdk:"quota_definitions"` +} + +type QuotaDefinition struct { + QuotaKey string `json:"quotaKey" tfsdk:"quota_key"` + MinValue int `json:"minValue" tfsdk:"min_value"` + MaxValue int `json:"maxValue" tfsdk:"max_value"` + Unit string `json:"unit" tfsdk:"unit"` + AutoApprovalThreshold int `json:"autoApprovalThreshold" tfsdk:"auto_approval_threshold"` + Description string `json:"description" tfsdk:"description"` + Label string `json:"label" tfsdk:"label"` } type LocationRef struct { From 5460edfd01ff2d22e28b8a8ce27b8a1129fdda3c Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 12 Nov 2025 12:05:08 +0100 Subject: [PATCH 045/215] feat: add metering config to platform --- platform.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/platform.go b/platform.go index 5afdd3d8..8251a76d 100644 --- a/platform.go +++ b/platform.go @@ -100,12 +100,14 @@ type KubernetesPlatformConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` Replication *KubernetesReplicationConfig `json:"replication" tfsdk:"replication"` + Metering *KubernetesMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` } type OpenShiftPlatformConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` Replication *OpenShiftReplicationConfig `json:"replication" tfsdk:"replication"` + Metering *OpenShiftMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` } type AzureRgReplicationConfig struct { @@ -169,6 +171,11 @@ type KubernetesClientConfig struct { AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` } +type KubernetesMeteringConfig struct { + ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` +} + type OpenShiftReplicationConfig struct { ClientConfig *OpenShiftClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` @@ -183,6 +190,11 @@ type OpenShiftClientConfig struct { AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` } +type OpenShiftMeteringConfig struct { + ClientConfig *OpenShiftClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` +} + type OpenShiftTenantTags struct { NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` TagMappers []OpenShiftTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` @@ -394,6 +406,11 @@ type MeshPlatformUpdateMetadata struct { Uuid string `json:"uuid" tfsdk:"uuid"` } +type MeshPlatformMeteringProcessingConfig struct { + CompactTimelinesAfterDays int64 `json:"compactTimelinesAfterDays" tfsdk:"compact_timelines_after_days"` + DeleteRawDataAfterDays int64 `json:"deleteRawDataAfterDays" tfsdk:"delete_raw_data_after_days"` +} + func (c *MeshStackProviderClient) urlForPlatform(uuid string) *url.URL { return c.endpoints.Platforms.JoinPath(uuid) } From 8945872c2d181b30600bb7d551ea4b6dbe78e707 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Thu, 13 Nov 2025 09:10:46 +0100 Subject: [PATCH 046/215] feature: metering config for aks platforms --- platform.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/platform.go b/platform.go index 8251a76d..49e046af 100644 --- a/platform.go +++ b/platform.go @@ -80,6 +80,7 @@ type AksPlatformConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` Replication *AksReplicationConfig `json:"replication" tfsdk:"replication"` + Metering *AksMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` } type AzurePlatformConfig struct { @@ -227,6 +228,11 @@ type ServicePrincipalConfig struct { ObjectId string `json:"objectId" tfsdk:"object_id"` } +type AksMeteringConfig struct { + ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` +} + // Azure-specific service principal configurations type AzureServicePrincipalConfig struct { ClientId string `json:"clientId" tfsdk:"client_id"` From 1bf89447515d4b9a2fbd809f24db35e866a4d38a Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Thu, 13 Nov 2025 09:16:02 +0100 Subject: [PATCH 047/215] refactor: separate platform configs by platform --- platform.go | 318 ---------------------------------- platform_config_aks.go | 35 ++++ platform_config_aws.go | 69 ++++++++ platform_config_azure.go | 86 +++++++++ platform_config_azurerg.go | 19 ++ platform_config_gcp.go | 49 ++++++ platform_config_kubernetes.go | 22 +++ platform_config_openshift.go | 42 +++++ 8 files changed, 322 insertions(+), 318 deletions(-) create mode 100644 platform_config_aks.go create mode 100644 platform_config_aws.go create mode 100644 platform_config_azure.go create mode 100644 platform_config_azurerg.go create mode 100644 platform_config_gcp.go create mode 100644 platform_config_kubernetes.go create mode 100644 platform_config_openshift.go diff --git a/platform.go b/platform.go index 49e046af..bc40515b 100644 --- a/platform.go +++ b/platform.go @@ -71,324 +71,6 @@ type PlatformConfig struct { OpenShift *OpenShiftPlatformConfig `json:"openshift,omitempty" tfsdk:"openshift"` } -type AwsPlatformConfig struct { - Region *string `json:"region,omitempty" tfsdk:"region"` - Replication *AwsReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` -} - -type AksPlatformConfig struct { - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` - Replication *AksReplicationConfig `json:"replication" tfsdk:"replication"` - Metering *AksMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` -} - -type AzurePlatformConfig struct { - EntraTenant *string `json:"entraTenant,omitempty" tfsdk:"entra_tenant"` - Replication *AzureReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` -} - -type AzureRgPlatformConfig struct { - EntraTenant *string `json:"entraTenant,omitempty" tfsdk:"entra_tenant"` - Replication *AzureRgReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` -} - -type GcpPlatformConfig struct { - Replication *GcpReplicationConfig `json:"replication" tfsdk:"replication"` -} - -type KubernetesPlatformConfig struct { - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` - Replication *KubernetesReplicationConfig `json:"replication" tfsdk:"replication"` - Metering *KubernetesMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` -} - -type OpenShiftPlatformConfig struct { - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` - Replication *OpenShiftReplicationConfig `json:"replication" tfsdk:"replication"` - Metering *OpenShiftMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` -} - -type AzureRgReplicationConfig struct { - ServicePrincipal *AzureServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` - Subscription *string `json:"subscription,omitempty" tfsdk:"subscription"` - ResourceGroupNamePattern *string `json:"resourceGroupNamePattern,omitempty" tfsdk:"resource_group_name_pattern"` - UserGroupNamePattern *string `json:"userGroupNamePattern,omitempty" tfsdk:"user_group_name_pattern"` - B2bUserInvitation *AzureB2bUserInvitation `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` - UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` - TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` - AllowHierarchicalManagementGroupAssignment *bool `json:"allowHierarchicalManagementGroupAssignment,omitempty" tfsdk:"allow_hierarchical_management_group_assignment"` -} - -type GcpReplicationConfig struct { - ServiceAccountConfig *GcpServiceAccountConfig `json:"serviceAccountConfig,omitempty" tfsdk:"service_account_config"` - Domain *string `json:"domain,omitempty" tfsdk:"domain"` - CustomerId *string `json:"customerId,omitempty" tfsdk:"customer_id"` - GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` - ProjectNamePattern *string `json:"projectNamePattern,omitempty" tfsdk:"project_name_pattern"` - ProjectIdPattern *string `json:"projectIdPattern,omitempty" tfsdk:"project_id_pattern"` - BillingAccountId *string `json:"billingAccountId,omitempty" tfsdk:"billing_account_id"` - UserLookupStrategy *string `json:"userLookupStrategy,omitempty" tfsdk:"user_lookup_strategy"` - GcpRoleMappings []GcpPlatformRoleMapping `json:"gcpRoleMappings,omitempty" tfsdk:"gcp_role_mappings"` - AllowHierarchicalFolderAssignment *bool `json:"allowHierarchicalFolderAssignment,omitempty" tfsdk:"allow_hierarchical_folder_assignment"` - TenantTags *GcpTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` -} - -type GcpServiceAccountConfig struct { - ServiceAccountCredentialsConfig *GcpServiceAccountCredentialsConfig `json:"serviceAccountCredentialsConfig,omitempty" tfsdk:"service_account_credentials_config"` - ServiceAccountWorkloadIdentityConfig *GcpServiceAccountWorkloadIdentityConfig `json:"serviceAccountWorkloadIdentityConfig,omitempty" tfsdk:"service_account_workload_identity_config"` -} - -type GcpServiceAccountCredentialsConfig struct { - ServiceAccountCredentialsB64 *string `json:"serviceAccountCredentialsB64,omitempty" tfsdk:"service_account_credentials_b64"` -} - -type GcpServiceAccountWorkloadIdentityConfig struct { - Audience *string `json:"audience,omitempty" tfsdk:"audience"` - ServiceAccountEmail *string `json:"serviceAccountEmail,omitempty" tfsdk:"service_account_email"` -} - -type GcpTenantTags struct { - NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` - TagMappers []GcpTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` -} - -type GcpTagMapper struct { - Key string `json:"key" tfsdk:"key"` - ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` -} - -type KubernetesReplicationConfig struct { - ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` - NamespaceNamePattern *string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` -} - -type KubernetesClientConfig struct { - AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` -} - -type KubernetesMeteringConfig struct { - ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` - Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` -} - -type OpenShiftReplicationConfig struct { - ClientConfig *OpenShiftClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` - WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` - ProjectNamePattern *string `json:"projectNamePattern,omitempty" tfsdk:"project_name_pattern"` - EnableTemplateInstantiation *bool `json:"enableTemplateInstantiation,omitempty" tfsdk:"enable_template_instantiation"` - OpenShiftRoleMappings []OpenShiftPlatformRoleMapping `json:"openshiftRoleMappings,omitempty" tfsdk:"openshift_role_mappings"` - IdentityProviderName *string `json:"identityProviderName,omitempty" tfsdk:"identity_provider_name"` - TenantTags *OpenShiftTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` -} - -type OpenShiftClientConfig struct { - AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` -} - -type OpenShiftMeteringConfig struct { - ClientConfig *OpenShiftClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` - Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` -} - -type OpenShiftTenantTags struct { - NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` - TagMappers []OpenShiftTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` -} - -type OpenShiftTagMapper struct { - Key string `json:"key" tfsdk:"key"` - ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` -} - -type AksReplicationConfig struct { - AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` - NamespaceNamePattern *string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` - GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` - ServicePrincipal *ServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` - AksSubscriptionId *string `json:"aksSubscriptionId,omitempty" tfsdk:"aks_subscription_id"` - AksClusterName *string `json:"aksClusterName,omitempty" tfsdk:"aks_cluster_name"` - AksResourceGroup *string `json:"aksResourceGroup,omitempty" tfsdk:"aks_resource_group"` - RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` - SendAzureInvitationMail *bool `json:"sendAzureInvitationMail,omitempty" tfsdk:"send_azure_invitation_mail"` - UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` -} - -type ServicePrincipalConfig struct { - ClientId string `json:"clientId" tfsdk:"client_id"` - AuthType string `json:"authType" tfsdk:"auth_type"` - CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` - EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` - ObjectId string `json:"objectId" tfsdk:"object_id"` -} - -type AksMeteringConfig struct { - ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` - Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` -} - -// Azure-specific service principal configurations -type AzureServicePrincipalConfig struct { - ClientId string `json:"clientId" tfsdk:"client_id"` - AuthType string `json:"authType" tfsdk:"auth_type"` - CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` - ObjectId string `json:"objectId" tfsdk:"object_id"` -} - -type AzureSourceServicePrincipalConfig struct { - ClientId string `json:"clientId" tfsdk:"client_id"` - AuthType string `json:"authType" tfsdk:"auth_type"` - CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` -} - -// AWS-specific replication configuration structures -type AwsReplicationConfig struct { - AccessConfig *AwsAccessConfig `json:"accessConfig,omitempty" tfsdk:"access_config"` - WaitForExternalAvm *bool `json:"waitForExternalAvm,omitempty" tfsdk:"wait_for_external_avm"` - AutomationAccountRole *string `json:"automationAccountRole,omitempty" tfsdk:"automation_account_role"` - AutomationAccountExternalId *string `json:"automationAccountExternalId,omitempty" tfsdk:"automation_account_external_id"` - AccountAccessRole *string `json:"accountAccessRole,omitempty" tfsdk:"account_access_role"` - AccountAliasPattern *string `json:"accountAliasPattern,omitempty" tfsdk:"account_alias_pattern"` - EnforceAccountAlias *bool `json:"enforceAccountAlias,omitempty" tfsdk:"enforce_account_alias"` - AccountEmailPattern *string `json:"accountEmailPattern,omitempty" tfsdk:"account_email_pattern"` - TenantTags *AwsTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - AwsSso *AwsSsoConfig `json:"awsSso,omitempty" tfsdk:"aws_sso"` - EnrollmentConfiguration *AwsEnrollmentConfiguration `json:"enrollmentConfiguration,omitempty" tfsdk:"enrollment_configuration"` - SelfDowngradeAccessRole *bool `json:"selfDowngradeAccessRole,omitempty" tfsdk:"self_downgrade_access_role"` - SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` - AllowHierarchicalOrganizationalUnitAssignment *bool `json:"allowHierarchicalOrganizationalUnitAssignment,omitempty" tfsdk:"allow_hierarchical_organizational_unit_assignment"` -} - -type AwsAccessConfig struct { - OrganizationRootAccountRole string `json:"organizationRootAccountRole" tfsdk:"organization_root_account_role"` - OrganizationRootAccountExternalId *string `json:"organizationRootAccountExternalId,omitempty" tfsdk:"organization_root_account_external_id"` - ServiceUserConfig *AwsServiceUserConfig `json:"serviceUserConfig,omitempty" tfsdk:"service_user_config"` - WorkloadIdentityConfig *AwsWorkloadIdentityConfig `json:"workloadIdentityConfig,omitempty" tfsdk:"workload_identity_config"` -} - -type AwsServiceUserConfig struct { - AccessKey string `json:"accessKey" tfsdk:"access_key"` - SecretKey *string `json:"secretKey,omitempty" tfsdk:"secret_key"` -} - -type AwsWorkloadIdentityConfig struct { - RoleArn string `json:"roleArn" tfsdk:"role_arn"` -} - -type AwsTenantTags struct { - NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` - TagMappers []AwsTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` -} - -type AwsTagMapper struct { - Key string `json:"key" tfsdk:"key"` - ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` -} - -type AwsSsoConfig struct { - ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` - Arn string `json:"arn" tfsdk:"arn"` - GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` - SsoAccessToken *string `json:"ssoAccessToken,omitempty" tfsdk:"sso_access_token"` - AwsRoleMappings []AwsSsoRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` - SignInUrl *string `json:"signInUrl,omitempty" tfsdk:"sign_in_url"` -} - -type AwsSsoRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - AwsRole string `json:"awsRole" tfsdk:"aws_role"` - PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` -} - -type GcpPlatformRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - GcpRole string `json:"gcpRole" tfsdk:"gcp_role"` -} - -type OpenShiftPlatformRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - OpenShiftRole string `json:"openshiftRole" tfsdk:"openshift_role"` -} - -type AwsEnrollmentConfiguration struct { - ManagementAccountId string `json:"managementAccountId" tfsdk:"management_account_id"` - AccountFactoryProductId string `json:"accountFactoryProductId" tfsdk:"account_factory_product_id"` -} - -// Azure-specific replication configuration structures -type AzureReplicationConfig struct { - ServicePrincipal *AzureServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` - Provisioning *AzureProvisioning `json:"provisioning,omitempty" tfsdk:"provisioning"` - B2bUserInvitation *AzureB2bUserInvitation `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` - SubscriptionNamePattern *string `json:"subscriptionNamePattern,omitempty" tfsdk:"subscription_name_pattern"` - GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` - BlueprintServicePrincipal *string `json:"blueprintServicePrincipal,omitempty" tfsdk:"blueprint_service_principal"` - BlueprintLocation *string `json:"blueprintLocation,omitempty" tfsdk:"blueprint_location"` - AzureRoleMappings []AzurePlatformRoleMapping `json:"azureRoleMappings,omitempty" tfsdk:"azure_role_mappings"` - TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` - SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` - AllowHierarchicalManagementGroupAssignment *bool `json:"allowHierarchicalManagementGroupAssignment,omitempty" tfsdk:"allow_hierarchical_management_group_assignment"` -} - -type AzureProvisioning struct { - SubscriptionOwnerObjectIds []string `json:"subscriptionOwnerObjectIds,omitempty" tfsdk:"subscription_owner_object_ids"` - EnterpriseEnrollment *AzureEnterpriseEnrollment `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` - CustomerAgreement *AzureCustomerAgreement `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` - PreProvisioned *AzurePreProvisioned `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` -} - -type AzureEnterpriseEnrollment struct { - EnrollmentAccountId string `json:"enrollmentAccountId" tfsdk:"enrollment_account_id"` - SubscriptionOfferType string `json:"subscriptionOfferType" tfsdk:"subscription_offer_type"` - UseLegacySubscriptionEnrollment *bool `json:"useLegacySubscriptionEnrollment,omitempty" tfsdk:"use_legacy_subscription_enrollment"` - SubscriptionCreationErrorCooldownSec *int `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` -} - -type AzureCustomerAgreement struct { - SourceServicePrincipal *AzureSourceServicePrincipalConfig `json:"sourceServicePrincipal,omitempty" tfsdk:"source_service_principal"` - DestinationEntraId string `json:"destinationEntraId" tfsdk:"destination_entra_id"` - SourceEntraTenant string `json:"sourceEntraTenant" tfsdk:"source_entra_tenant"` - BillingScope string `json:"billingScope" tfsdk:"billing_scope"` - SubscriptionCreationErrorCooldownSec *int `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` -} - -type AzurePreProvisioned struct { - UnusedSubscriptionNamePrefix string `json:"unusedSubscriptionNamePrefix" tfsdk:"unused_subscription_name_prefix"` -} - -type AzureB2bUserInvitation struct { - RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` - SendAzureInvitationMail *bool `json:"sendAzureInvitationMail,omitempty" tfsdk:"send_azure_invitation_mail"` -} - -type AzurePlatformRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - AzureRole AzurePlatformRoleDefinition `json:"azureRole" tfsdk:"azure_role"` -} - -type AzurePlatformRoleDefinition struct { - Alias string `json:"alias" tfsdk:"alias"` - Id string `json:"id" tfsdk:"id"` -} - -type AzureTenantTags struct { - NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` - TagMappers []AzureTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` -} - -type AzureTagMapper struct { - Key string `json:"key" tfsdk:"key"` - ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` -} - type MeshPlatformCreate struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Metadata MeshPlatformCreateMetadata `json:"metadata" tfsdk:"metadata"` diff --git a/platform_config_aks.go b/platform_config_aks.go new file mode 100644 index 00000000..c02162b7 --- /dev/null +++ b/platform_config_aks.go @@ -0,0 +1,35 @@ +package client + +type AksPlatformConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` + Replication *AksReplicationConfig `json:"replication" tfsdk:"replication"` + Metering *AksMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type AksReplicationConfig struct { + AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` + NamespaceNamePattern *string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` + GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` + ServicePrincipal *ServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` + AksSubscriptionId *string `json:"aksSubscriptionId,omitempty" tfsdk:"aks_subscription_id"` + AksClusterName *string `json:"aksClusterName,omitempty" tfsdk:"aks_cluster_name"` + AksResourceGroup *string `json:"aksResourceGroup,omitempty" tfsdk:"aks_resource_group"` + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` + SendAzureInvitationMail *bool `json:"sendAzureInvitationMail,omitempty" tfsdk:"send_azure_invitation_mail"` + UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` +} + +type ServicePrincipalConfig struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + AuthType string `json:"authType" tfsdk:"auth_type"` + CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` + EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` + ObjectId string `json:"objectId" tfsdk:"object_id"` +} + +type AksMeteringConfig struct { + ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` +} diff --git a/platform_config_aws.go b/platform_config_aws.go new file mode 100644 index 00000000..b109a4c4 --- /dev/null +++ b/platform_config_aws.go @@ -0,0 +1,69 @@ +package client + +type AwsPlatformConfig struct { + Region *string `json:"region,omitempty" tfsdk:"region"` + Replication *AwsReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` +} + +type AwsReplicationConfig struct { + AccessConfig *AwsAccessConfig `json:"accessConfig,omitempty" tfsdk:"access_config"` + WaitForExternalAvm *bool `json:"waitForExternalAvm,omitempty" tfsdk:"wait_for_external_avm"` + AutomationAccountRole *string `json:"automationAccountRole,omitempty" tfsdk:"automation_account_role"` + AutomationAccountExternalId *string `json:"automationAccountExternalId,omitempty" tfsdk:"automation_account_external_id"` + AccountAccessRole *string `json:"accountAccessRole,omitempty" tfsdk:"account_access_role"` + AccountAliasPattern *string `json:"accountAliasPattern,omitempty" tfsdk:"account_alias_pattern"` + EnforceAccountAlias *bool `json:"enforceAccountAlias,omitempty" tfsdk:"enforce_account_alias"` + AccountEmailPattern *string `json:"accountEmailPattern,omitempty" tfsdk:"account_email_pattern"` + TenantTags *AwsTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + AwsSso *AwsSsoConfig `json:"awsSso,omitempty" tfsdk:"aws_sso"` + EnrollmentConfiguration *AwsEnrollmentConfiguration `json:"enrollmentConfiguration,omitempty" tfsdk:"enrollment_configuration"` + SelfDowngradeAccessRole *bool `json:"selfDowngradeAccessRole,omitempty" tfsdk:"self_downgrade_access_role"` + SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` + AllowHierarchicalOrganizationalUnitAssignment *bool `json:"allowHierarchicalOrganizationalUnitAssignment,omitempty" tfsdk:"allow_hierarchical_organizational_unit_assignment"` +} + +type AwsAccessConfig struct { + OrganizationRootAccountRole string `json:"organizationRootAccountRole" tfsdk:"organization_root_account_role"` + OrganizationRootAccountExternalId *string `json:"organizationRootAccountExternalId,omitempty" tfsdk:"organization_root_account_external_id"` + ServiceUserConfig *AwsServiceUserConfig `json:"serviceUserConfig,omitempty" tfsdk:"service_user_config"` + WorkloadIdentityConfig *AwsWorkloadIdentityConfig `json:"workloadIdentityConfig,omitempty" tfsdk:"workload_identity_config"` +} + +type AwsServiceUserConfig struct { + AccessKey string `json:"accessKey" tfsdk:"access_key"` + SecretKey *string `json:"secretKey,omitempty" tfsdk:"secret_key"` +} + +type AwsWorkloadIdentityConfig struct { + RoleArn string `json:"roleArn" tfsdk:"role_arn"` +} + +type AwsTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers []AwsTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type AwsTagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} + +type AwsSsoConfig struct { + ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` + Arn string `json:"arn" tfsdk:"arn"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + SsoAccessToken *string `json:"ssoAccessToken,omitempty" tfsdk:"sso_access_token"` + AwsRoleMappings []AwsSsoRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` + SignInUrl *string `json:"signInUrl,omitempty" tfsdk:"sign_in_url"` +} + +type AwsSsoRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + AwsRole string `json:"awsRole" tfsdk:"aws_role"` + PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` +} + +type AwsEnrollmentConfiguration struct { + ManagementAccountId string `json:"managementAccountId" tfsdk:"management_account_id"` + AccountFactoryProductId string `json:"accountFactoryProductId" tfsdk:"account_factory_product_id"` +} diff --git a/platform_config_azure.go b/platform_config_azure.go new file mode 100644 index 00000000..05493262 --- /dev/null +++ b/platform_config_azure.go @@ -0,0 +1,86 @@ +package client + +type AzurePlatformConfig struct { + EntraTenant *string `json:"entraTenant,omitempty" tfsdk:"entra_tenant"` + Replication *AzureReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` +} + +type AzureReplicationConfig struct { + ServicePrincipal *AzureServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` + Provisioning *AzureProvisioning `json:"provisioning,omitempty" tfsdk:"provisioning"` + B2bUserInvitation *AzureB2bUserInvitation `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + SubscriptionNamePattern *string `json:"subscriptionNamePattern,omitempty" tfsdk:"subscription_name_pattern"` + GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` + BlueprintServicePrincipal *string `json:"blueprintServicePrincipal,omitempty" tfsdk:"blueprint_service_principal"` + BlueprintLocation *string `json:"blueprintLocation,omitempty" tfsdk:"blueprint_location"` + AzureRoleMappings []AzurePlatformRoleMapping `json:"azureRoleMappings,omitempty" tfsdk:"azure_role_mappings"` + TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` + SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AllowHierarchicalManagementGroupAssignment *bool `json:"allowHierarchicalManagementGroupAssignment,omitempty" tfsdk:"allow_hierarchical_management_group_assignment"` +} + +type AzureServicePrincipalConfig struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + AuthType string `json:"authType" tfsdk:"auth_type"` + CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` + ObjectId string `json:"objectId" tfsdk:"object_id"` +} + +type AzureSourceServicePrincipalConfig struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + AuthType string `json:"authType" tfsdk:"auth_type"` + CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` +} + +type AzureProvisioning struct { + SubscriptionOwnerObjectIds []string `json:"subscriptionOwnerObjectIds,omitempty" tfsdk:"subscription_owner_object_ids"` + EnterpriseEnrollment *AzureEnterpriseEnrollment `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` + CustomerAgreement *AzureCustomerAgreement `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` + PreProvisioned *AzurePreProvisioned `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` +} + +type AzureEnterpriseEnrollment struct { + EnrollmentAccountId string `json:"enrollmentAccountId" tfsdk:"enrollment_account_id"` + SubscriptionOfferType string `json:"subscriptionOfferType" tfsdk:"subscription_offer_type"` + UseLegacySubscriptionEnrollment *bool `json:"useLegacySubscriptionEnrollment,omitempty" tfsdk:"use_legacy_subscription_enrollment"` + SubscriptionCreationErrorCooldownSec *int `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` +} + +type AzureCustomerAgreement struct { + SourceServicePrincipal *AzureSourceServicePrincipalConfig `json:"sourceServicePrincipal,omitempty" tfsdk:"source_service_principal"` + DestinationEntraId string `json:"destinationEntraId" tfsdk:"destination_entra_id"` + SourceEntraTenant string `json:"sourceEntraTenant" tfsdk:"source_entra_tenant"` + BillingScope string `json:"billingScope" tfsdk:"billing_scope"` + SubscriptionCreationErrorCooldownSec *int `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` +} + +type AzurePreProvisioned struct { + UnusedSubscriptionNamePrefix string `json:"unusedSubscriptionNamePrefix" tfsdk:"unused_subscription_name_prefix"` +} + +type AzureB2bUserInvitation struct { + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` + SendAzureInvitationMail *bool `json:"sendAzureInvitationMail,omitempty" tfsdk:"send_azure_invitation_mail"` +} + +type AzurePlatformRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureRole AzurePlatformRoleDefinition `json:"azureRole" tfsdk:"azure_role"` +} + +type AzurePlatformRoleDefinition struct { + Alias string `json:"alias" tfsdk:"alias"` + Id string `json:"id" tfsdk:"id"` +} + +type AzureTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers []AzureTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type AzureTagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} diff --git a/platform_config_azurerg.go b/platform_config_azurerg.go new file mode 100644 index 00000000..47f585a8 --- /dev/null +++ b/platform_config_azurerg.go @@ -0,0 +1,19 @@ +package client + +type AzureRgPlatformConfig struct { + EntraTenant *string `json:"entraTenant,omitempty" tfsdk:"entra_tenant"` + Replication *AzureRgReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` +} + +type AzureRgReplicationConfig struct { + ServicePrincipal *AzureServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` + Subscription *string `json:"subscription,omitempty" tfsdk:"subscription"` + ResourceGroupNamePattern *string `json:"resourceGroupNamePattern,omitempty" tfsdk:"resource_group_name_pattern"` + UserGroupNamePattern *string `json:"userGroupNamePattern,omitempty" tfsdk:"user_group_name_pattern"` + B2bUserInvitation *AzureB2bUserInvitation `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` + TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AllowHierarchicalManagementGroupAssignment *bool `json:"allowHierarchicalManagementGroupAssignment,omitempty" tfsdk:"allow_hierarchical_management_group_assignment"` +} diff --git a/platform_config_gcp.go b/platform_config_gcp.go new file mode 100644 index 00000000..0ee137b7 --- /dev/null +++ b/platform_config_gcp.go @@ -0,0 +1,49 @@ +package client + +type GcpPlatformConfig struct { + Replication *GcpReplicationConfig `json:"replication" tfsdk:"replication"` +} + +type GcpReplicationConfig struct { + ServiceAccountConfig *GcpServiceAccountConfig `json:"serviceAccountConfig,omitempty" tfsdk:"service_account_config"` + Domain *string `json:"domain,omitempty" tfsdk:"domain"` + CustomerId *string `json:"customerId,omitempty" tfsdk:"customer_id"` + GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` + ProjectNamePattern *string `json:"projectNamePattern,omitempty" tfsdk:"project_name_pattern"` + ProjectIdPattern *string `json:"projectIdPattern,omitempty" tfsdk:"project_id_pattern"` + BillingAccountId *string `json:"billingAccountId,omitempty" tfsdk:"billing_account_id"` + UserLookupStrategy *string `json:"userLookupStrategy,omitempty" tfsdk:"user_lookup_strategy"` + GcpRoleMappings []GcpPlatformRoleMapping `json:"gcpRoleMappings,omitempty" tfsdk:"gcp_role_mappings"` + AllowHierarchicalFolderAssignment *bool `json:"allowHierarchicalFolderAssignment,omitempty" tfsdk:"allow_hierarchical_folder_assignment"` + TenantTags *GcpTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` +} + +type GcpServiceAccountConfig struct { + ServiceAccountCredentialsConfig *GcpServiceAccountCredentialsConfig `json:"serviceAccountCredentialsConfig,omitempty" tfsdk:"service_account_credentials_config"` + ServiceAccountWorkloadIdentityConfig *GcpServiceAccountWorkloadIdentityConfig `json:"serviceAccountWorkloadIdentityConfig,omitempty" tfsdk:"service_account_workload_identity_config"` +} + +type GcpServiceAccountCredentialsConfig struct { + ServiceAccountCredentialsB64 *string `json:"serviceAccountCredentialsB64,omitempty" tfsdk:"service_account_credentials_b64"` +} + +type GcpServiceAccountWorkloadIdentityConfig struct { + Audience *string `json:"audience,omitempty" tfsdk:"audience"` + ServiceAccountEmail *string `json:"serviceAccountEmail,omitempty" tfsdk:"service_account_email"` +} + +type GcpTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers []GcpTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type GcpTagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} + +type GcpPlatformRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + GcpRole string `json:"gcpRole" tfsdk:"gcp_role"` +} diff --git a/platform_config_kubernetes.go b/platform_config_kubernetes.go new file mode 100644 index 00000000..8ea5768f --- /dev/null +++ b/platform_config_kubernetes.go @@ -0,0 +1,22 @@ +package client + +type KubernetesPlatformConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` + Replication *KubernetesReplicationConfig `json:"replication" tfsdk:"replication"` + Metering *KubernetesMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type KubernetesReplicationConfig struct { + ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + NamespaceNamePattern *string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` +} + +type KubernetesClientConfig struct { + AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` +} + +type KubernetesMeteringConfig struct { + ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` +} diff --git a/platform_config_openshift.go b/platform_config_openshift.go new file mode 100644 index 00000000..80f5ca63 --- /dev/null +++ b/platform_config_openshift.go @@ -0,0 +1,42 @@ +package client + +type OpenShiftPlatformConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` + Replication *OpenShiftReplicationConfig `json:"replication" tfsdk:"replication"` + Metering *OpenShiftMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type OpenShiftReplicationConfig struct { + ClientConfig *OpenShiftClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` + ProjectNamePattern *string `json:"projectNamePattern,omitempty" tfsdk:"project_name_pattern"` + EnableTemplateInstantiation *bool `json:"enableTemplateInstantiation,omitempty" tfsdk:"enable_template_instantiation"` + OpenShiftRoleMappings []OpenShiftPlatformRoleMapping `json:"openshiftRoleMappings,omitempty" tfsdk:"openshift_role_mappings"` + IdentityProviderName *string `json:"identityProviderName,omitempty" tfsdk:"identity_provider_name"` + TenantTags *OpenShiftTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` +} + +type OpenShiftClientConfig struct { + AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` +} + +type OpenShiftMeteringConfig struct { + ClientConfig *OpenShiftClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` +} + +type OpenShiftTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers []OpenShiftTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type OpenShiftTagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} + +type OpenShiftPlatformRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + OpenShiftRole string `json:"openshiftRole" tfsdk:"openshift_role"` +} From 470415ddbd80fe122f6b1572951338d5351b9c09 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Thu, 13 Nov 2025 10:05:38 +0100 Subject: [PATCH 048/215] fix: correctly model nullable platform config fields --- platform_config_aks.go | 26 ++++++------ platform_config_aws.go | 9 ++++ platform_config_azure.go | 80 +++++++++++++++++++---------------- platform_config_azurerg.go | 18 ++++---- platform_config_gcp.go | 42 +++++++++++------- platform_config_kubernetes.go | 10 ++--- platform_properties_azure.go | 6 +-- 7 files changed, 109 insertions(+), 82 deletions(-) diff --git a/platform_config_aks.go b/platform_config_aks.go index c02162b7..66f251fc 100644 --- a/platform_config_aks.go +++ b/platform_config_aks.go @@ -8,17 +8,17 @@ type AksPlatformConfig struct { } type AksReplicationConfig struct { - AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` - NamespaceNamePattern *string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` - GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` - ServicePrincipal *ServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` - AksSubscriptionId *string `json:"aksSubscriptionId,omitempty" tfsdk:"aks_subscription_id"` - AksClusterName *string `json:"aksClusterName,omitempty" tfsdk:"aks_cluster_name"` - AksResourceGroup *string `json:"aksResourceGroup,omitempty" tfsdk:"aks_resource_group"` - RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` - SendAzureInvitationMail *bool `json:"sendAzureInvitationMail,omitempty" tfsdk:"send_azure_invitation_mail"` - UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AccessToken string `json:"accessToken" tfsdk:"access_token"` + NamespaceNamePattern string `json:"namespaceNamePattern" tfsdk:"namespace_name_pattern"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + ServicePrincipal ServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + AksSubscriptionId string `json:"aksSubscriptionId" tfsdk:"aks_subscription_id"` + AksClusterName string `json:"aksClusterName" tfsdk:"aks_cluster_name"` + AksResourceGroup string `json:"aksResourceGroup" tfsdk:"aks_resource_group"` + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` + SendAzureInvitationMail bool `json:"sendAzureInvitationMail" tfsdk:"send_azure_invitation_mail"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` } type ServicePrincipalConfig struct { @@ -30,6 +30,6 @@ type ServicePrincipalConfig struct { } type AksMeteringConfig struct { - ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` - Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` } diff --git a/platform_config_aws.go b/platform_config_aws.go index b109a4c4..a17a4a28 100644 --- a/platform_config_aws.go +++ b/platform_config_aws.go @@ -3,6 +3,7 @@ package client type AwsPlatformConfig struct { Region *string `json:"region,omitempty" tfsdk:"region"` Replication *AwsReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` + Metering *AwsMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` } type AwsReplicationConfig struct { @@ -67,3 +68,11 @@ type AwsEnrollmentConfiguration struct { ManagementAccountId string `json:"managementAccountId" tfsdk:"management_account_id"` AccountFactoryProductId string `json:"accountFactoryProductId" tfsdk:"account_factory_product_id"` } + +type AwsMeteringConfig struct { + AccessConfig AwsAccessConfig `json:"accessConfig" tfsdk:"access_config"` + Filter string `json:"filter" tfsdk:"filter"` + ReservedInstanceFairChargeback bool `json:"reservedInstanceFairChargeback" tfsdk:"reserved_instance_fair_chargeback"` + SavingsPlanFairChargeback bool `json:"savingsPlanFairChargeback" tfsdk:"savings_plan_fair_chargeback"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` +} diff --git a/platform_config_azure.go b/platform_config_azure.go index 05493262..33516d01 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -1,24 +1,25 @@ package client type AzurePlatformConfig struct { - EntraTenant *string `json:"entraTenant,omitempty" tfsdk:"entra_tenant"` + EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` Replication *AzureReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` + Metering *AzureMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` } type AzureReplicationConfig struct { - ServicePrincipal *AzureServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` - Provisioning *AzureProvisioning `json:"provisioning,omitempty" tfsdk:"provisioning"` - B2bUserInvitation *AzureB2bUserInvitation `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` - SubscriptionNamePattern *string `json:"subscriptionNamePattern,omitempty" tfsdk:"subscription_name_pattern"` - GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` - BlueprintServicePrincipal *string `json:"blueprintServicePrincipal,omitempty" tfsdk:"blueprint_service_principal"` - BlueprintLocation *string `json:"blueprintLocation,omitempty" tfsdk:"blueprint_location"` - AzureRoleMappings []AzurePlatformRoleMapping `json:"azureRoleMappings,omitempty" tfsdk:"azure_role_mappings"` - TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` - SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` - AllowHierarchicalManagementGroupAssignment *bool `json:"allowHierarchicalManagementGroupAssignment,omitempty" tfsdk:"allow_hierarchical_management_group_assignment"` + ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + Provisioning *AzureSubscriptionProvisioningConfig `json:"provisioning,omitempty" tfsdk:"provisioning"` + B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + SubscriptionNamePattern string `json:"subscriptionNamePattern" tfsdk:"subscription_name_pattern"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + BlueprintServicePrincipal string `json:"blueprintServicePrincipal" tfsdk:"blueprint_service_principal"` + BlueprintLocation string `json:"blueprintLocation" tfsdk:"blueprint_location"` + AzureRoleMappings []AzureRoleMapping `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` + TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AllowHierarchicalManagementGroupAssignment bool `json:"allowHierarchicalManagementGroupAssignment" tfsdk:"allow_hierarchical_management_group_assignment"` } type AzureServicePrincipalConfig struct { @@ -28,49 +29,49 @@ type AzureServicePrincipalConfig struct { ObjectId string `json:"objectId" tfsdk:"object_id"` } -type AzureSourceServicePrincipalConfig struct { +type AzureGraphApiCredentials struct { ClientId string `json:"clientId" tfsdk:"client_id"` AuthType string `json:"authType" tfsdk:"auth_type"` CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` } -type AzureProvisioning struct { - SubscriptionOwnerObjectIds []string `json:"subscriptionOwnerObjectIds,omitempty" tfsdk:"subscription_owner_object_ids"` - EnterpriseEnrollment *AzureEnterpriseEnrollment `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` - CustomerAgreement *AzureCustomerAgreement `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` - PreProvisioned *AzurePreProvisioned `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` +type AzureSubscriptionProvisioningConfig struct { + SubscriptionOwnerObjectIds []string `json:"subscriptionOwnerObjectIds" tfsdk:"subscription_owner_object_ids"` + EnterpriseEnrollment *AzureEnterpriseEnrollmentConfig `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` + CustomerAgreement *AzureCustomerAgreementConfig `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` + PreProvisioned *AzurePreProvisionedSubscriptionConfig `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` } -type AzureEnterpriseEnrollment struct { +type AzureEnterpriseEnrollmentConfig struct { EnrollmentAccountId string `json:"enrollmentAccountId" tfsdk:"enrollment_account_id"` SubscriptionOfferType string `json:"subscriptionOfferType" tfsdk:"subscription_offer_type"` - UseLegacySubscriptionEnrollment *bool `json:"useLegacySubscriptionEnrollment,omitempty" tfsdk:"use_legacy_subscription_enrollment"` - SubscriptionCreationErrorCooldownSec *int `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` + UseLegacySubscriptionEnrollment bool `json:"useLegacySubscriptionEnrollment" tfsdk:"use_legacy_subscription_enrollment"` + SubscriptionCreationErrorCooldownSec int64 `json:"subscriptionCreationErrorCooldownSec" tfsdk:"subscription_creation_error_cooldown_sec"` } -type AzureCustomerAgreement struct { - SourceServicePrincipal *AzureSourceServicePrincipalConfig `json:"sourceServicePrincipal,omitempty" tfsdk:"source_service_principal"` - DestinationEntraId string `json:"destinationEntraId" tfsdk:"destination_entra_id"` - SourceEntraTenant string `json:"sourceEntraTenant" tfsdk:"source_entra_tenant"` - BillingScope string `json:"billingScope" tfsdk:"billing_scope"` - SubscriptionCreationErrorCooldownSec *int `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` +type AzureCustomerAgreementConfig struct { + SourceServicePrincipal AzureGraphApiCredentials `json:"sourceServicePrincipal" tfsdk:"source_service_principal"` + DestinationEntraId string `json:"destinationEntraId" tfsdk:"destination_entra_id"` + SourceEntraTenant string `json:"sourceEntraTenant" tfsdk:"source_entra_tenant"` + BillingScope string `json:"billingScope" tfsdk:"billing_scope"` + SubscriptionCreationErrorCooldownSec int64 `json:"subscriptionCreationErrorCooldownSec" tfsdk:"subscription_creation_error_cooldown_sec"` } -type AzurePreProvisioned struct { +type AzurePreProvisionedSubscriptionConfig struct { UnusedSubscriptionNamePrefix string `json:"unusedSubscriptionNamePrefix" tfsdk:"unused_subscription_name_prefix"` } -type AzureB2bUserInvitation struct { - RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` - SendAzureInvitationMail *bool `json:"sendAzureInvitationMail,omitempty" tfsdk:"send_azure_invitation_mail"` +type AzureInviteB2BUserConfig struct { + RedirectUrl string `json:"redirectUrl" tfsdk:"redirect_url"` + SendAzureInvitationMail bool `json:"sendAzureInvitationMail" tfsdk:"send_azure_invitation_mail"` } -type AzurePlatformRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - AzureRole AzurePlatformRoleDefinition `json:"azureRole" tfsdk:"azure_role"` +type AzureRoleMapping struct { + MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureRole AzureRole `json:"azureRole" tfsdk:"azure_role"` } -type AzurePlatformRoleDefinition struct { +type AzureRole struct { Alias string `json:"alias" tfsdk:"alias"` Id string `json:"id" tfsdk:"id"` } @@ -84,3 +85,8 @@ type AzureTagMapper struct { Key string `json:"key" tfsdk:"key"` ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` } + +type AzureMeteringConfig struct { + ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` +} diff --git a/platform_config_azurerg.go b/platform_config_azurerg.go index 47f585a8..82b5e868 100644 --- a/platform_config_azurerg.go +++ b/platform_config_azurerg.go @@ -1,19 +1,19 @@ package client type AzureRgPlatformConfig struct { - EntraTenant *string `json:"entraTenant,omitempty" tfsdk:"entra_tenant"` + EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` Replication *AzureRgReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` } type AzureRgReplicationConfig struct { - ServicePrincipal *AzureServicePrincipalConfig `json:"servicePrincipal,omitempty" tfsdk:"service_principal"` - Subscription *string `json:"subscription,omitempty" tfsdk:"subscription"` - ResourceGroupNamePattern *string `json:"resourceGroupNamePattern,omitempty" tfsdk:"resource_group_name_pattern"` - UserGroupNamePattern *string `json:"userGroupNamePattern,omitempty" tfsdk:"user_group_name_pattern"` - B2bUserInvitation *AzureB2bUserInvitation `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` - UserLookUpStrategy *string `json:"userLookUpStrategy,omitempty" tfsdk:"user_look_up_strategy"` + ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + Subscription string `json:"subscription" tfsdk:"subscription"` + ResourceGroupNamePattern string `json:"resourceGroupNamePattern" tfsdk:"resource_group_name_pattern"` + UserGroupNamePattern string `json:"userGroupNamePattern" tfsdk:"user_group_name_pattern"` + B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` - AllowHierarchicalManagementGroupAssignment *bool `json:"allowHierarchicalManagementGroupAssignment,omitempty" tfsdk:"allow_hierarchical_management_group_assignment"` + AllowHierarchicalManagementGroupAssignment bool `json:"allowHierarchicalManagementGroupAssignment" tfsdk:"allow_hierarchical_management_group_assignment"` } diff --git a/platform_config_gcp.go b/platform_config_gcp.go index 0ee137b7..e6563b9a 100644 --- a/platform_config_gcp.go +++ b/platform_config_gcp.go @@ -1,22 +1,24 @@ package client type GcpPlatformConfig struct { - Replication *GcpReplicationConfig `json:"replication" tfsdk:"replication"` + Replication *GcpReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` + Metering *GcpMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` } type GcpReplicationConfig struct { - ServiceAccountConfig *GcpServiceAccountConfig `json:"serviceAccountConfig,omitempty" tfsdk:"service_account_config"` - Domain *string `json:"domain,omitempty" tfsdk:"domain"` - CustomerId *string `json:"customerId,omitempty" tfsdk:"customer_id"` - GroupNamePattern *string `json:"groupNamePattern,omitempty" tfsdk:"group_name_pattern"` - ProjectNamePattern *string `json:"projectNamePattern,omitempty" tfsdk:"project_name_pattern"` - ProjectIdPattern *string `json:"projectIdPattern,omitempty" tfsdk:"project_id_pattern"` - BillingAccountId *string `json:"billingAccountId,omitempty" tfsdk:"billing_account_id"` - UserLookupStrategy *string `json:"userLookupStrategy,omitempty" tfsdk:"user_lookup_strategy"` - GcpRoleMappings []GcpPlatformRoleMapping `json:"gcpRoleMappings,omitempty" tfsdk:"gcp_role_mappings"` - AllowHierarchicalFolderAssignment *bool `json:"allowHierarchicalFolderAssignment,omitempty" tfsdk:"allow_hierarchical_folder_assignment"` + ServiceAccountConfig GcpServiceAccountConfig `json:"serviceAccountConfig" tfsdk:"service_account_config"` + Domain string `json:"domain" tfsdk:"domain"` + CustomerId string `json:"customerId" tfsdk:"customer_id"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` + ProjectIdPattern string `json:"projectIdPattern" tfsdk:"project_id_pattern"` + BillingAccountId string `json:"billingAccountId" tfsdk:"billing_account_id"` + UserLookupStrategy string `json:"userLookupStrategy" tfsdk:"user_lookup_strategy"` + UsedExternalIdType *string `json:"usedExternalIdType,omitempty" tfsdk:"used_external_id_type"` + GcpRoleMappings []GcpPlatformRoleMapping `json:"gcpRoleMappings" tfsdk:"gcp_role_mappings"` + AllowHierarchicalFolderAssignment bool `json:"allowHierarchicalFolderAssignment" tfsdk:"allow_hierarchical_folder_assignment"` TenantTags *GcpTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` } type GcpServiceAccountConfig struct { @@ -25,12 +27,12 @@ type GcpServiceAccountConfig struct { } type GcpServiceAccountCredentialsConfig struct { - ServiceAccountCredentialsB64 *string `json:"serviceAccountCredentialsB64,omitempty" tfsdk:"service_account_credentials_b64"` + ServiceAccountCredentialsB64 string `json:"serviceAccountCredentialsB64" tfsdk:"service_account_credentials_b64"` } type GcpServiceAccountWorkloadIdentityConfig struct { - Audience *string `json:"audience,omitempty" tfsdk:"audience"` - ServiceAccountEmail *string `json:"serviceAccountEmail,omitempty" tfsdk:"service_account_email"` + Audience string `json:"audience" tfsdk:"audience"` + ServiceAccountEmail string `json:"serviceAccountEmail" tfsdk:"service_account_email"` } type GcpTenantTags struct { @@ -47,3 +49,13 @@ type GcpPlatformRoleMapping struct { MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` GcpRole string `json:"gcpRole" tfsdk:"gcp_role"` } + +type GcpMeteringConfig struct { + ServiceAccountConfig GcpServiceAccountConfig `json:"serviceAccountConfig" tfsdk:"service_account_config"` + BigqueryTable string `json:"bigqueryTable" tfsdk:"bigquery_table"` + BigqueryTableForCarbonFootprint *string `json:"bigqueryTableForCarbonFootprint,omitempty" tfsdk:"bigquery_table_for_carbon_footprint"` + CarbonFootprintDataCollectionStartMonth *string `json:"carbonFootprintDataCollectionStartMonth,omitempty" tfsdk:"carbon_footprint_data_collection_start_month"` + PartitionTimeColumn string `json:"partitionTimeColumn" tfsdk:"partition_time_column"` + AdditionalFilter *string `json:"additionalFilter,omitempty" tfsdk:"additional_filter"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` +} diff --git a/platform_config_kubernetes.go b/platform_config_kubernetes.go index 8ea5768f..d817861d 100644 --- a/platform_config_kubernetes.go +++ b/platform_config_kubernetes.go @@ -8,15 +8,15 @@ type KubernetesPlatformConfig struct { } type KubernetesReplicationConfig struct { - ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` - NamespaceNamePattern *string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + NamespaceNamePattern string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` } type KubernetesClientConfig struct { - AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` + AccessToken string `json:"accessToken" tfsdk:"access_token"` } type KubernetesMeteringConfig struct { - ClientConfig *KubernetesClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` - Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` } diff --git a/platform_properties_azure.go b/platform_properties_azure.go index a06c0101..6979403e 100644 --- a/platform_properties_azure.go +++ b/platform_properties_azure.go @@ -1,11 +1,11 @@ package client type AzurePlatformProperties struct { - AzureRoleMappings []AzureRoleMapping `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` - AzureManagementGroupId string `json:"azureManagementGroupId" tfsdk:"azure_management_group_id"` + AzureRoleMappings []AzureRoleMappingProperty `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` + AzureManagementGroupId string `json:"azureManagementGroupId" tfsdk:"azure_management_group_id"` } -type AzureRoleMapping struct { +type AzureRoleMappingProperty struct { MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` AzureGroupSuffix string `json:"azureGroupSuffix" tfsdk:"azure_group_suffix"` AzureRoleDefinitions []AzureRoleDefinition `json:"azureRoleDefinitions" tfsdk:"azure_role_definitions"` From 0b7d8f828b378605481fb0f1eb40e09858fa1ee8 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Thu, 13 Nov 2025 10:08:41 +0100 Subject: [PATCH 049/215] refactor: common data structure for tenant tags --- platform.go | 10 ++++++++++ platform_config_aws.go | 12 +----------- platform_config_azure.go | 12 +----------- platform_config_azurerg.go | 2 +- platform_config_gcp.go | 12 +----------- platform_config_openshift.go | 11 +---------- 6 files changed, 15 insertions(+), 44 deletions(-) diff --git a/platform.go b/platform.go index bc40515b..01500b02 100644 --- a/platform.go +++ b/platform.go @@ -99,6 +99,16 @@ type MeshPlatformMeteringProcessingConfig struct { DeleteRawDataAfterDays int64 `json:"deleteRawDataAfterDays" tfsdk:"delete_raw_data_after_days"` } +type MeshTenantTags struct { + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers []TagMapper `json:"tagMappers" tfsdk:"tag_mappers"` +} + +type TagMapper struct { + Key string `json:"key" tfsdk:"key"` + ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` +} + func (c *MeshStackProviderClient) urlForPlatform(uuid string) *url.URL { return c.endpoints.Platforms.JoinPath(uuid) } diff --git a/platform_config_aws.go b/platform_config_aws.go index a17a4a28..6bd924c8 100644 --- a/platform_config_aws.go +++ b/platform_config_aws.go @@ -15,7 +15,7 @@ type AwsReplicationConfig struct { AccountAliasPattern *string `json:"accountAliasPattern,omitempty" tfsdk:"account_alias_pattern"` EnforceAccountAlias *bool `json:"enforceAccountAlias,omitempty" tfsdk:"enforce_account_alias"` AccountEmailPattern *string `json:"accountEmailPattern,omitempty" tfsdk:"account_email_pattern"` - TenantTags *AwsTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` AwsSso *AwsSsoConfig `json:"awsSso,omitempty" tfsdk:"aws_sso"` EnrollmentConfiguration *AwsEnrollmentConfiguration `json:"enrollmentConfiguration,omitempty" tfsdk:"enrollment_configuration"` SelfDowngradeAccessRole *bool `json:"selfDowngradeAccessRole,omitempty" tfsdk:"self_downgrade_access_role"` @@ -39,16 +39,6 @@ type AwsWorkloadIdentityConfig struct { RoleArn string `json:"roleArn" tfsdk:"role_arn"` } -type AwsTenantTags struct { - NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` - TagMappers []AwsTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` -} - -type AwsTagMapper struct { - Key string `json:"key" tfsdk:"key"` - ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` -} - type AwsSsoConfig struct { ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` Arn string `json:"arn" tfsdk:"arn"` diff --git a/platform_config_azure.go b/platform_config_azure.go index 33516d01..6541a420 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -15,7 +15,7 @@ type AzureReplicationConfig struct { BlueprintServicePrincipal string `json:"blueprintServicePrincipal" tfsdk:"blueprint_service_principal"` BlueprintLocation string `json:"blueprintLocation" tfsdk:"blueprint_location"` AzureRoleMappings []AzureRoleMapping `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` - TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` @@ -76,16 +76,6 @@ type AzureRole struct { Id string `json:"id" tfsdk:"id"` } -type AzureTenantTags struct { - NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` - TagMappers []AzureTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` -} - -type AzureTagMapper struct { - Key string `json:"key" tfsdk:"key"` - ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` -} - type AzureMeteringConfig struct { ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` diff --git a/platform_config_azurerg.go b/platform_config_azurerg.go index 82b5e868..f3f8348a 100644 --- a/platform_config_azurerg.go +++ b/platform_config_azurerg.go @@ -12,7 +12,7 @@ type AzureRgReplicationConfig struct { UserGroupNamePattern string `json:"userGroupNamePattern" tfsdk:"user_group_name_pattern"` B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` - TenantTags *AzureTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` AllowHierarchicalManagementGroupAssignment bool `json:"allowHierarchicalManagementGroupAssignment" tfsdk:"allow_hierarchical_management_group_assignment"` diff --git a/platform_config_gcp.go b/platform_config_gcp.go index e6563b9a..4812173e 100644 --- a/platform_config_gcp.go +++ b/platform_config_gcp.go @@ -17,7 +17,7 @@ type GcpReplicationConfig struct { UsedExternalIdType *string `json:"usedExternalIdType,omitempty" tfsdk:"used_external_id_type"` GcpRoleMappings []GcpPlatformRoleMapping `json:"gcpRoleMappings" tfsdk:"gcp_role_mappings"` AllowHierarchicalFolderAssignment bool `json:"allowHierarchicalFolderAssignment" tfsdk:"allow_hierarchical_folder_assignment"` - TenantTags *GcpTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` } @@ -35,16 +35,6 @@ type GcpServiceAccountWorkloadIdentityConfig struct { ServiceAccountEmail string `json:"serviceAccountEmail" tfsdk:"service_account_email"` } -type GcpTenantTags struct { - NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` - TagMappers []GcpTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` -} - -type GcpTagMapper struct { - Key string `json:"key" tfsdk:"key"` - ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` -} - type GcpPlatformRoleMapping struct { MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` GcpRole string `json:"gcpRole" tfsdk:"gcp_role"` diff --git a/platform_config_openshift.go b/platform_config_openshift.go index 80f5ca63..3d2efba1 100644 --- a/platform_config_openshift.go +++ b/platform_config_openshift.go @@ -14,7 +14,7 @@ type OpenShiftReplicationConfig struct { EnableTemplateInstantiation *bool `json:"enableTemplateInstantiation,omitempty" tfsdk:"enable_template_instantiation"` OpenShiftRoleMappings []OpenShiftPlatformRoleMapping `json:"openshiftRoleMappings,omitempty" tfsdk:"openshift_role_mappings"` IdentityProviderName *string `json:"identityProviderName,omitempty" tfsdk:"identity_provider_name"` - TenantTags *OpenShiftTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` } type OpenShiftClientConfig struct { @@ -26,15 +26,6 @@ type OpenShiftMeteringConfig struct { Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` } -type OpenShiftTenantTags struct { - NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` - TagMappers []OpenShiftTagMapper `json:"tagMappers" tfsdk:"tag_mappers"` -} - -type OpenShiftTagMapper struct { - Key string `json:"key" tfsdk:"key"` - ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` -} type OpenShiftPlatformRoleMapping struct { MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` From eb661645af97a88a1a0f339bfcf9a772a54dd1e1 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Thu, 13 Nov 2025 16:39:44 +0100 Subject: [PATCH 050/215] fix: small issues --- platform_config_aws.go | 30 +++++++++++++++--------------- platform_config_kubernetes.go | 2 +- platform_config_openshift.go | 19 +++++++------------ 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/platform_config_aws.go b/platform_config_aws.go index 6bd924c8..a193b094 100644 --- a/platform_config_aws.go +++ b/platform_config_aws.go @@ -1,26 +1,26 @@ package client type AwsPlatformConfig struct { - Region *string `json:"region,omitempty" tfsdk:"region"` + Region string `json:"region,omitempty" tfsdk:"region"` Replication *AwsReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` Metering *AwsMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` } type AwsReplicationConfig struct { - AccessConfig *AwsAccessConfig `json:"accessConfig,omitempty" tfsdk:"access_config"` - WaitForExternalAvm *bool `json:"waitForExternalAvm,omitempty" tfsdk:"wait_for_external_avm"` - AutomationAccountRole *string `json:"automationAccountRole,omitempty" tfsdk:"automation_account_role"` + AccessConfig AwsAccessConfig `json:"accessConfig" tfsdk:"access_config"` + WaitForExternalAvm bool `json:"waitForExternalAvm" tfsdk:"wait_for_external_avm"` + AutomationAccountRole string `json:"automationAccountRole" tfsdk:"automation_account_role"` AutomationAccountExternalId *string `json:"automationAccountExternalId,omitempty" tfsdk:"automation_account_external_id"` - AccountAccessRole *string `json:"accountAccessRole,omitempty" tfsdk:"account_access_role"` - AccountAliasPattern *string `json:"accountAliasPattern,omitempty" tfsdk:"account_alias_pattern"` - EnforceAccountAlias *bool `json:"enforceAccountAlias,omitempty" tfsdk:"enforce_account_alias"` - AccountEmailPattern *string `json:"accountEmailPattern,omitempty" tfsdk:"account_email_pattern"` + AccountAccessRole string `json:"accountAccessRole" tfsdk:"account_access_role"` + AccountAliasPattern string `json:"accountAliasPattern" tfsdk:"account_alias_pattern"` + EnforceAccountAlias bool `json:"enforceAccountAlias" tfsdk:"enforce_account_alias"` + AccountEmailPattern string `json:"accountEmailPattern" tfsdk:"account_email_pattern"` TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` AwsSso *AwsSsoConfig `json:"awsSso,omitempty" tfsdk:"aws_sso"` EnrollmentConfiguration *AwsEnrollmentConfiguration `json:"enrollmentConfiguration,omitempty" tfsdk:"enrollment_configuration"` - SelfDowngradeAccessRole *bool `json:"selfDowngradeAccessRole,omitempty" tfsdk:"self_downgrade_access_role"` - SkipUserGroupPermissionCleanup *bool `json:"skipUserGroupPermissionCleanup,omitempty" tfsdk:"skip_user_group_permission_cleanup"` - AllowHierarchicalOrganizationalUnitAssignment *bool `json:"allowHierarchicalOrganizationalUnitAssignment,omitempty" tfsdk:"allow_hierarchical_organizational_unit_assignment"` + SelfDowngradeAccessRole bool `json:"selfDowngradeAccessRole" tfsdk:"self_downgrade_access_role"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` + AllowHierarchicalOrganizationalUnitAssignment bool `json:"allowHierarchicalOrganizationalUnitAssignment" tfsdk:"allow_hierarchical_organizational_unit_assignment"` } type AwsAccessConfig struct { @@ -31,8 +31,8 @@ type AwsAccessConfig struct { } type AwsServiceUserConfig struct { - AccessKey string `json:"accessKey" tfsdk:"access_key"` - SecretKey *string `json:"secretKey,omitempty" tfsdk:"secret_key"` + AccessKey string `json:"accessKey" tfsdk:"access_key"` + SecretKey string `json:"secretKey" tfsdk:"secret_key"` } type AwsWorkloadIdentityConfig struct { @@ -43,9 +43,9 @@ type AwsSsoConfig struct { ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` Arn string `json:"arn" tfsdk:"arn"` GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` - SsoAccessToken *string `json:"ssoAccessToken,omitempty" tfsdk:"sso_access_token"` + SsoAccessToken string `json:"ssoAccessToken" tfsdk:"sso_access_token"` AwsRoleMappings []AwsSsoRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` - SignInUrl *string `json:"signInUrl,omitempty" tfsdk:"sign_in_url"` + SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` } type AwsSsoRoleMapping struct { diff --git a/platform_config_kubernetes.go b/platform_config_kubernetes.go index d817861d..72b0d964 100644 --- a/platform_config_kubernetes.go +++ b/platform_config_kubernetes.go @@ -9,7 +9,7 @@ type KubernetesPlatformConfig struct { type KubernetesReplicationConfig struct { ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` - NamespaceNamePattern string `json:"namespaceNamePattern,omitempty" tfsdk:"namespace_name_pattern"` + NamespaceNamePattern string `json:"namespaceNamePattern" tfsdk:"namespace_name_pattern"` } type KubernetesClientConfig struct { diff --git a/platform_config_openshift.go b/platform_config_openshift.go index 3d2efba1..2dc05c7e 100644 --- a/platform_config_openshift.go +++ b/platform_config_openshift.go @@ -8,25 +8,20 @@ type OpenShiftPlatformConfig struct { } type OpenShiftReplicationConfig struct { - ClientConfig *OpenShiftClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` - ProjectNamePattern *string `json:"projectNamePattern,omitempty" tfsdk:"project_name_pattern"` - EnableTemplateInstantiation *bool `json:"enableTemplateInstantiation,omitempty" tfsdk:"enable_template_instantiation"` - OpenShiftRoleMappings []OpenShiftPlatformRoleMapping `json:"openshiftRoleMappings,omitempty" tfsdk:"openshift_role_mappings"` - IdentityProviderName *string `json:"identityProviderName,omitempty" tfsdk:"identity_provider_name"` + ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` + EnableTemplateInstantiation bool `json:"enableTemplateInstantiation" tfsdk:"enable_template_instantiation"` + OpenShiftRoleMappings []OpenShiftPlatformRoleMapping `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` + IdentityProviderName string `json:"identityProviderName" tfsdk:"identity_provider_name"` TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` } -type OpenShiftClientConfig struct { - AccessToken *string `json:"accessToken,omitempty" tfsdk:"access_token"` -} - type OpenShiftMeteringConfig struct { - ClientConfig *OpenShiftClientConfig `json:"clientConfig,omitempty" tfsdk:"client_config"` - Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` } - type OpenShiftPlatformRoleMapping struct { MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` OpenShiftRole string `json:"openshiftRole" tfsdk:"openshift_role"` From 60a7c119459b30aee27b5fa2867c648c8dc1f74a Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Mon, 17 Nov 2025 13:21:24 +0100 Subject: [PATCH 051/215] chore: generate docs --- platform_config_azurerg.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/platform_config_azurerg.go b/platform_config_azurerg.go index f3f8348a..d553f56f 100644 --- a/platform_config_azurerg.go +++ b/platform_config_azurerg.go @@ -6,14 +6,14 @@ type AzureRgPlatformConfig struct { } type AzureRgReplicationConfig struct { - ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` - Subscription string `json:"subscription" tfsdk:"subscription"` - ResourceGroupNamePattern string `json:"resourceGroupNamePattern" tfsdk:"resource_group_name_pattern"` - UserGroupNamePattern string `json:"userGroupNamePattern" tfsdk:"user_group_name_pattern"` - B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` - UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` - AllowHierarchicalManagementGroupAssignment bool `json:"allowHierarchicalManagementGroupAssignment" tfsdk:"allow_hierarchical_management_group_assignment"` + ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + Subscription string `json:"subscription" tfsdk:"subscription"` + ResourceGroupNamePattern string `json:"resourceGroupNamePattern" tfsdk:"resource_group_name_pattern"` + UserGroupNamePattern string `json:"userGroupNamePattern" tfsdk:"user_group_name_pattern"` + B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AllowHierarchicalManagementGroupAssignment bool `json:"allowHierarchicalManagementGroupAssignment" tfsdk:"allow_hierarchical_management_group_assignment"` } From 88cfb6c06e697090e29d9364414bd6f1295a7901 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Mon, 17 Nov 2025 14:43:44 +0100 Subject: [PATCH 052/215] refactor: AksServicePrincipal --- platform_config_aks.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/platform_config_aks.go b/platform_config_aks.go index 66f251fc..927d1199 100644 --- a/platform_config_aks.go +++ b/platform_config_aks.go @@ -8,20 +8,20 @@ type AksPlatformConfig struct { } type AksReplicationConfig struct { - AccessToken string `json:"accessToken" tfsdk:"access_token"` - NamespaceNamePattern string `json:"namespaceNamePattern" tfsdk:"namespace_name_pattern"` - GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` - ServicePrincipal ServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` - AksSubscriptionId string `json:"aksSubscriptionId" tfsdk:"aks_subscription_id"` - AksClusterName string `json:"aksClusterName" tfsdk:"aks_cluster_name"` - AksResourceGroup string `json:"aksResourceGroup" tfsdk:"aks_resource_group"` - RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` - SendAzureInvitationMail bool `json:"sendAzureInvitationMail" tfsdk:"send_azure_invitation_mail"` - UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AccessToken string `json:"accessToken" tfsdk:"access_token"` + NamespaceNamePattern string `json:"namespaceNamePattern" tfsdk:"namespace_name_pattern"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + ServicePrincipal AksServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + AksSubscriptionId string `json:"aksSubscriptionId" tfsdk:"aks_subscription_id"` + AksClusterName string `json:"aksClusterName" tfsdk:"aks_cluster_name"` + AksResourceGroup string `json:"aksResourceGroup" tfsdk:"aks_resource_group"` + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` + SendAzureInvitationMail bool `json:"sendAzureInvitationMail" tfsdk:"send_azure_invitation_mail"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` } -type ServicePrincipalConfig struct { +type AksServicePrincipalConfig struct { ClientId string `json:"clientId" tfsdk:"client_id"` AuthType string `json:"authType" tfsdk:"auth_type"` CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` From 0b00147023e3f16bff04522da25a8540ad4a40e3 Mon Sep 17 00:00:00 2001 From: Young-Hwan Date: Wed, 19 Nov 2025 12:20:19 +0100 Subject: [PATCH 053/215] feat: added payment method resource and data endpoint --- client.go | 2 + payment_method.go | 169 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 payment_method.go diff --git a/client.go b/client.go index 608b7780..fb657cab 100644 --- a/client.go +++ b/client.go @@ -44,6 +44,7 @@ type endpoints struct { TagDefinitions *url.URL `json:"meshtagdefinitions"` LandingZones *url.URL `json:"meshlandingzones"` Platforms *url.URL `json:"meshplatforms"` + PaymentMethods *url.URL `json:"meshpaymentmethods"` } type loginRequest struct { @@ -80,6 +81,7 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), LandingZones: rootUrl.JoinPath(apiMeshObjectsRoot, "meshlandingzones"), Platforms: rootUrl.JoinPath(apiMeshObjectsRoot, "meshplatforms"), + PaymentMethods: rootUrl.JoinPath(apiMeshObjectsRoot, "meshpaymentmethods"), } return client, nil diff --git a/payment_method.go b/payment_method.go new file mode 100644 index 00000000..2b7c2498 --- /dev/null +++ b/payment_method.go @@ -0,0 +1,169 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const CONTENT_TYPE_PAYMENT_METHOD = "application/vnd.meshcloud.api.meshpaymentmethod.v2.hal+json" + +type MeshPaymentMethod struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshPaymentMethodMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPaymentMethodSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPaymentMethodMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` +} + +type MeshPaymentMethodSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + ExpirationDate *string `json:"expirationDate,omitempty" tfsdk:"expiration_date"` + Amount *int64 `json:"amount,omitempty" tfsdk:"amount"` + Tags map[string][]string `json:"tags,omitempty" tfsdk:"tags"` +} + +type MeshPaymentMethodCreate struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Metadata MeshPaymentMethodCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPaymentMethodSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPaymentMethodCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +func (c *MeshStackProviderClient) urlForPaymentMethod(workspace string, identifier string) *url.URL { + return c.endpoints.PaymentMethods.JoinPath(identifier) +} + +func (c *MeshStackProviderClient) ReadPaymentMethod(workspace string, identifier string) (*MeshPaymentMethod, error) { + targetUrl := c.urlForPaymentMethod(workspace, identifier) + + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return nil, nil + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var paymentMethod MeshPaymentMethod + err = json.Unmarshal(data, &paymentMethod) + if err != nil { + return nil, err + } + + return &paymentMethod, nil +} + +func (c *MeshStackProviderClient) CreatePaymentMethod(paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { + payload, err := json.Marshal(paymentMethod) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.PaymentMethods.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_PAYMENT_METHOD) + req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdPaymentMethod MeshPaymentMethod + err = json.Unmarshal(data, &createdPaymentMethod) + if err != nil { + return nil, err + } + + return &createdPaymentMethod, nil +} + +func (c *MeshStackProviderClient) UpdatePaymentMethod(workspace string, identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { + targetUrl := c.urlForPaymentMethod(workspace, identifier) + + payload, err := json.Marshal(paymentMethod) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_PAYMENT_METHOD) + req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var updatedPaymentMethod MeshPaymentMethod + err = json.Unmarshal(data, &updatedPaymentMethod) + if err != nil { + return nil, err + } + + return &updatedPaymentMethod, nil +} + +func (c *MeshStackProviderClient) DeletePaymentMethod(workspace string, identifier string) error { + targetUrl := c.urlForPaymentMethod(workspace, identifier) + return c.deleteMeshObject(*targetUrl, 204) +} From a59ffa1367219a1909f508145f84909146f7aeb5 Mon Sep 17 00:00:00 2001 From: "Young-Hwan K." Date: Wed, 19 Nov 2025 12:28:58 +0100 Subject: [PATCH 054/215] Update client/payment_method.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- payment_method.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payment_method.go b/payment_method.go index 2b7c2498..573f27f7 100644 --- a/payment_method.go +++ b/payment_method.go @@ -44,7 +44,7 @@ type MeshPaymentMethodCreateMetadata struct { } func (c *MeshStackProviderClient) urlForPaymentMethod(workspace string, identifier string) *url.URL { - return c.endpoints.PaymentMethods.JoinPath(identifier) + return c.endpoints.PaymentMethods.JoinPath(workspace, identifier) } func (c *MeshStackProviderClient) ReadPaymentMethod(workspace string, identifier string) (*MeshPaymentMethod, error) { From 485b1fc59d7a0b22da369afb9113ef7d25b36a87 Mon Sep 17 00:00:00 2001 From: Young-Hwan Date: Wed, 19 Nov 2025 12:30:18 +0100 Subject: [PATCH 055/215] feat: removed workspace identifier for URL creation as not required --- payment_method.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/payment_method.go b/payment_method.go index 573f27f7..1555fa56 100644 --- a/payment_method.go +++ b/payment_method.go @@ -43,12 +43,12 @@ type MeshPaymentMethodCreateMetadata struct { OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } -func (c *MeshStackProviderClient) urlForPaymentMethod(workspace string, identifier string) *url.URL { - return c.endpoints.PaymentMethods.JoinPath(workspace, identifier) +func (c *MeshStackProviderClient) urlForPaymentMethod(identifier string) *url.URL { + return c.endpoints.PaymentMethods.JoinPath(identifier) } func (c *MeshStackProviderClient) ReadPaymentMethod(workspace string, identifier string) (*MeshPaymentMethod, error) { - targetUrl := c.urlForPaymentMethod(workspace, identifier) + targetUrl := c.urlForPaymentMethod(identifier) req, err := http.NewRequest("GET", targetUrl.String(), nil) if err != nil { @@ -123,8 +123,8 @@ func (c *MeshStackProviderClient) CreatePaymentMethod(paymentMethod *MeshPayment return &createdPaymentMethod, nil } -func (c *MeshStackProviderClient) UpdatePaymentMethod(workspace string, identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { - targetUrl := c.urlForPaymentMethod(workspace, identifier) +func (c *MeshStackProviderClient) UpdatePaymentMethod(identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { + targetUrl := c.urlForPaymentMethod(identifier) payload, err := json.Marshal(paymentMethod) if err != nil { @@ -163,7 +163,7 @@ func (c *MeshStackProviderClient) UpdatePaymentMethod(workspace string, identifi return &updatedPaymentMethod, nil } -func (c *MeshStackProviderClient) DeletePaymentMethod(workspace string, identifier string) error { - targetUrl := c.urlForPaymentMethod(workspace, identifier) +func (c *MeshStackProviderClient) DeletePaymentMethod(identifier string) error { + targetUrl := c.urlForPaymentMethod(identifier) return c.deleteMeshObject(*targetUrl, 204) } From 9928220621cf2c1fa720d509e691716038b02cdd Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 19 Nov 2025 11:40:01 +0100 Subject: [PATCH 056/215] feat: quotas in landing zone data source --- landingzone.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/landingzone.go b/landingzone.go index 20cca500..c3058696 100644 --- a/landingzone.go +++ b/landingzone.go @@ -25,13 +25,14 @@ type MeshLandingZoneMetadata struct { } type MeshLandingZoneSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Description string `json:"description" tfsdk:"description"` - AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` - AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` - InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` - PlatformRef PlatformRef `json:"platformRef" tfsdk:"platform_ref"` - PlatformProperties *PlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` + AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` + InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` + PlatformRef MeshLandingZonePlatformRef `json:"platformRef" tfsdk:"platform_ref"` + PlatformProperties *MeshLandingZonePlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` + Quotas []MeshLandingZoneQuota `json:"quotas" tfsdk:"quotas"` } type MeshLandingZoneStatus struct { @@ -39,12 +40,12 @@ type MeshLandingZoneStatus struct { Restricted bool `json:"restricted" tfsdk:"restricted"` } -type PlatformRef struct { +type MeshLandingZonePlatformRef struct { Uuid string `json:"uuid" tfsdk:"uuid"` Kind string `json:"kind" tfsdk:"kind"` } -type PlatformProperties struct { +type MeshLandingZonePlatformProperties struct { Type string `json:"type" tfsdk:"type"` Aws *AwsPlatformProperties `json:"aws" tfsdk:"aws"` Aks *AksPlatformProperties `json:"aks" tfsdk:"aks"` @@ -55,6 +56,11 @@ type PlatformProperties struct { OpenShift *OpenShiftPlatformProperties `json:"openshift" tfsdk:"openshift"` } +type MeshLandingZoneQuota struct { + Key string `json:"key" tfsdk:"key"` + Value int64 `json:"value" tfsdk:"value"` +} + type MeshLandingZoneCreate struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` From a7a35d7704cacbc331816007267f02bd8d24b2da Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Tue, 25 Nov 2025 11:10:57 +0100 Subject: [PATCH 057/215] feat: multi select building block inputs Adds support for multi select inputs and cleans up the building block resources code. Also corrects schema restrictions for allowed input/output types. --- buildingblock.go | 1 + buildingblock_v2.go | 8 ++------ tenant_v4.go | 6 +----- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index 2867be41..a63e3ee8 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -14,6 +14,7 @@ const ( MESH_BUILDING_BLOCK_IO_TYPE_INTEGER = "INTEGER" MESH_BUILDING_BLOCK_IO_TYPE_BOOLEAN = "BOOLEAN" MESH_BUILDING_BLOCK_IO_TYPE_SINGLE_SELECT = "SINGLE_SELECT" + MESH_BUILDING_BLOCK_IO_TYPE_MULTI_SELECT = "MULTI_SELECT" MESH_BUILDING_BLOCK_IO_TYPE_FILE = "FILE" MESH_BUILDING_BLOCK_IO_TYPE_LIST = "LIST" MESH_BUILDING_BLOCK_IO_TYPE_CODE = "CODE" diff --git a/buildingblock_v2.go b/buildingblock_v2.go index bf2bc55c..df2b3b04 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -158,11 +158,7 @@ func (c *MeshStackProviderClient) PollBuildingBlockV2UntilCompletion(ctx context var result *MeshBuildingBlockV2 err := retry.RetryContext(ctx, 30*time.Minute, c.waitForBuildingBlockV2CompletionFunc(uuid, &result)) - if err != nil { - return nil, err - } - - return result, nil + return result, err } // waitForBuildingBlockV2CompletionFunc returns a RetryFunc that checks building block completion status @@ -176,12 +172,12 @@ func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(uuid stri if current == nil { return retry.NonRetryableError(fmt.Errorf("building block was not found while waiting for completion")) } + *result = current // Check if we've reached a terminal state status := current.Status.Status switch status { case BUILDING_BLOCK_STATUS_SUCCEEDED: - *result = current return nil // Success, stop retrying case BUILDING_BLOCK_STATUS_FAILED: return retry.NonRetryableError(fmt.Errorf("building block %s reached FAILED state", uuid)) diff --git a/tenant_v4.go b/tenant_v4.go index 4768c69d..6c6a1965 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -153,11 +153,7 @@ func (c *MeshStackProviderClient) PollTenantV4UntilCreation(ctx context.Context, var result *MeshTenantV4 err := retry.RetryContext(ctx, 30*time.Minute, c.waitForTenantV4CreationFunc(uuid, &result)) - if err != nil { - return nil, err - } - - return result, nil + return result, err } // waitForTenantV4CreationFunc returns a RetryFunc that checks tenant creation status From cd9785b4393e069c8ad3bb6ac7077dda1ed2b516 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Fri, 5 Dec 2025 12:34:00 +0100 Subject: [PATCH 058/215] feat: integrations client --- client.go | 4 +- integrations.go | 196 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 integrations.go diff --git a/client.go b/client.go index fb657cab..aea3f894 100644 --- a/client.go +++ b/client.go @@ -45,6 +45,7 @@ type endpoints struct { LandingZones *url.URL `json:"meshlandingzones"` Platforms *url.URL `json:"meshplatforms"` PaymentMethods *url.URL `json:"meshpaymentmethods"` + Integrations *url.URL `json:"meshintegrations"` } type loginRequest struct { @@ -82,6 +83,7 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro LandingZones: rootUrl.JoinPath(apiMeshObjectsRoot, "meshlandingzones"), Platforms: rootUrl.JoinPath(apiMeshObjectsRoot, "meshplatforms"), PaymentMethods: rootUrl.JoinPath(apiMeshObjectsRoot, "meshpaymentmethods"), + Integrations: rootUrl.JoinPath(apiMeshObjectsRoot, "meshintegrations"), } return client, nil @@ -111,7 +113,7 @@ func (c *MeshStackProviderClient) login() error { if err != nil { return err } else if res.StatusCode != 200 { - return errors.New(fmt.Sprintf("Status %d: %s", res.StatusCode, ERROR_AUTHENTICATION_FAILURE)) + return fmt.Errorf("Status %d: %s", res.StatusCode, ERROR_AUTHENTICATION_FAILURE) } defer res.Body.Close() diff --git a/integrations.go b/integrations.go new file mode 100644 index 00000000..2e8545f8 --- /dev/null +++ b/integrations.go @@ -0,0 +1,196 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const CONTENT_TYPE_INTEGRATION = "application/vnd.meshcloud.api.meshintegration.v1-preview.hal+json" + +type MeshIntegration struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshIntegrationMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshIntegrationSpec `json:"spec" tfsdk:"spec"` + Status *MeshIntegrationStatus `json:"status,omitempty" tfsdk:"status"` +} + +type MeshIntegrationMetadata struct { + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn *string `json:"createdOn,omitempty" tfsdk:"created_on"` +} + +type MeshIntegrationSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Config MeshIntegrationConfig `json:"config" tfsdk:"config"` +} + +type MeshIntegrationStatus struct { + IsBuiltIn bool `json:"isBuiltIn" tfsdk:"is_built_in"` + WorkloadIdentityFederation *MeshWorkloadIdentityFederation `json:"workloadIdentityFederation,omitempty" tfsdk:"workload_identity_federation"` +} + +// Integration Config wrapper with type discrimination +type MeshIntegrationConfig struct { + Type string `json:"type" tfsdk:"type"` + Github *MeshGithubIntegrationProperties `json:"github,omitempty" tfsdk:"github"` + Gitlab *MeshGitlabIntegrationProperties `json:"gitlab,omitempty" tfsdk:"gitlab"` + AzureDevops *MeshAzureDevopsIntegrationProperties `json:"azuredevops,omitempty" tfsdk:"azuredevops"` +} + +// GitHub Integration +type MeshGithubIntegrationProperties struct { + Owner string `json:"owner" tfsdk:"owner"` + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + AppId string `json:"appId" tfsdk:"app_id"` + AppPrivateKey string `json:"appPrivateKey" tfsdk:"app_private_key"` + RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` +} + +// GitLab Integration +type MeshGitlabIntegrationProperties struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` +} + +// Azure DevOps Integration +type MeshAzureDevopsIntegrationProperties struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + Organization string `json:"organization" tfsdk:"organization"` + PersonalAccessToken string `json:"personalAccessToken" tfsdk:"personal_access_token"` + RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` +} + +// Building Block Runner Reference +type BuildingBlockRunnerRef struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + Kind string `json:"kind,omitempty" tfsdk:"kind"` +} + +// Workload Identity Federation +type MeshWorkloadIdentityFederation struct { + Issuer string `json:"issuer" tfsdk:"issuer"` + Subject string `json:"subject" tfsdk:"subject"` + Gcp *MeshWifProvider `json:"gcp,omitempty" tfsdk:"gcp"` + Aws *MeshAwsWifProvider `json:"aws,omitempty" tfsdk:"aws"` + Azure *MeshWifProvider `json:"azure,omitempty" tfsdk:"azure"` +} + +type MeshWifProvider struct { + Audience string `json:"audience" tfsdk:"audience"` +} + +type MeshAwsWifProvider struct { + Audience string `json:"audience" tfsdk:"audience"` + Thumbprint string `json:"thumbprint" tfsdk:"thumbprint"` +} + +func (c *MeshStackProviderClient) urlForIntegration(workspace string, uuid string) *url.URL { + return c.endpoints.Integrations.JoinPath(workspace, uuid) +} + +func (c *MeshStackProviderClient) ReadIntegration(workspace string, uuid string) (*MeshIntegration, error) { + targetUrl := c.urlForIntegration(workspace, uuid) + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_INTEGRATION) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode == http.StatusNotFound { + return nil, nil + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var integration MeshIntegration + err = json.Unmarshal(data, &integration) + if err != nil { + return nil, err + } + + return &integration, nil +} + +func (c *MeshStackProviderClient) ReadIntegrations(workspaceIdentifier string) (*[]MeshIntegration, error) { + var allIntegrations []MeshIntegration + + pageNumber := 0 + targetUrl := c.endpoints.Integrations + query := targetUrl.Query() + query.Set("workspaceIdentifier", workspaceIdentifier) + + for { + query.Set("page", fmt.Sprintf("%d", pageNumber)) + targetUrl.RawQuery = query.Encode() + + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", CONTENT_TYPE_INTEGRATION) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var response struct { + Embedded struct { + MeshIntegrations []MeshIntegration `json:"meshIntegrations"` + } `json:"_embedded"` + Page struct { + Size int `json:"size"` + TotalElements int `json:"totalElements"` + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` + } + + err = json.Unmarshal(data, &response) + if err != nil { + return nil, err + } + + allIntegrations = append(allIntegrations, response.Embedded.MeshIntegrations...) + + // Check if there are more pages + if response.Page.Number >= response.Page.TotalPages-1 { + break + } + + pageNumber++ + } + + return &allIntegrations, nil +} From 4225ddc63e9d508a3e49b74b38683c4bfefd257a Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Thu, 11 Dec 2025 17:24:27 +0100 Subject: [PATCH 059/215] feat: integrations data source --- integrations.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/integrations.go b/integrations.go index 2e8545f8..0b686f51 100644 --- a/integrations.go +++ b/integrations.go @@ -36,14 +36,14 @@ type MeshIntegrationStatus struct { // Integration Config wrapper with type discrimination type MeshIntegrationConfig struct { - Type string `json:"type" tfsdk:"type"` - Github *MeshGithubIntegrationProperties `json:"github,omitempty" tfsdk:"github"` - Gitlab *MeshGitlabIntegrationProperties `json:"gitlab,omitempty" tfsdk:"gitlab"` - AzureDevops *MeshAzureDevopsIntegrationProperties `json:"azuredevops,omitempty" tfsdk:"azuredevops"` + Type string `json:"type" tfsdk:"type"` + Github *MeshIntegrationGithubConfig `json:"github,omitempty" tfsdk:"github"` + Gitlab *MeshIntegrationGitlabConfig `json:"gitlab,omitempty" tfsdk:"gitlab"` + AzureDevops *MeshIntegrationAzureDevopsConfig `json:"azuredevops,omitempty" tfsdk:"azuredevops"` } // GitHub Integration -type MeshGithubIntegrationProperties struct { +type MeshIntegrationGithubConfig struct { Owner string `json:"owner" tfsdk:"owner"` BaseUrl string `json:"baseUrl" tfsdk:"base_url"` AppId string `json:"appId" tfsdk:"app_id"` @@ -52,13 +52,13 @@ type MeshGithubIntegrationProperties struct { } // GitLab Integration -type MeshGitlabIntegrationProperties struct { +type MeshIntegrationGitlabConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` } // Azure DevOps Integration -type MeshAzureDevopsIntegrationProperties struct { +type MeshIntegrationAzureDevopsConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` Organization string `json:"organization" tfsdk:"organization"` PersonalAccessToken string `json:"personalAccessToken" tfsdk:"personal_access_token"` @@ -68,7 +68,7 @@ type MeshAzureDevopsIntegrationProperties struct { // Building Block Runner Reference type BuildingBlockRunnerRef struct { Uuid string `json:"uuid" tfsdk:"uuid"` - Kind string `json:"kind,omitempty" tfsdk:"kind"` + Kind string `json:"kind" tfsdk:"kind"` } // Workload Identity Federation @@ -130,13 +130,12 @@ func (c *MeshStackProviderClient) ReadIntegration(workspace string, uuid string) return &integration, nil } -func (c *MeshStackProviderClient) ReadIntegrations(workspaceIdentifier string) (*[]MeshIntegration, error) { +func (c *MeshStackProviderClient) ReadIntegrations() (*[]MeshIntegration, error) { var allIntegrations []MeshIntegration pageNumber := 0 targetUrl := c.endpoints.Integrations query := targetUrl.Query() - query.Set("workspaceIdentifier", workspaceIdentifier) for { query.Set("page", fmt.Sprintf("%d", pageNumber)) From ba0a6861bda1c7d5384875ec8d39eff837217aef Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Mon, 8 Dec 2025 15:50:33 +0100 Subject: [PATCH 060/215] fix: upstream api changed secret handling --- platform.go | 5 +++++ platform_config_aks.go | 13 ++++++------- platform_config_aws.go | 23 ++++++++++++++--------- platform_config_azure.go | 19 +++++++++++-------- platform_config_azurerg.go | 2 +- platform_config_gcp.go | 13 +++++-------- platform_config_kubernetes.go | 2 +- platform_config_openshift.go | 4 ++-- 8 files changed, 45 insertions(+), 36 deletions(-) diff --git a/platform.go b/platform.go index 01500b02..a993c264 100644 --- a/platform.go +++ b/platform.go @@ -39,6 +39,11 @@ type MeshPlatformSpec struct { QuotaDefinitions []QuotaDefinition `json:"quotaDefinitions" tfsdk:"quota_definitions"` } +type SecretEmbedded struct { + Plaintext *string `json:"plaintext,omitempty" tfsdk:"plaintext"` + // TODO: add Hash field +} + type QuotaDefinition struct { QuotaKey string `json:"quotaKey" tfsdk:"quota_key"` MinValue int `json:"minValue" tfsdk:"min_value"` diff --git a/platform_config_aks.go b/platform_config_aks.go index 927d1199..369650d2 100644 --- a/platform_config_aks.go +++ b/platform_config_aks.go @@ -8,7 +8,7 @@ type AksPlatformConfig struct { } type AksReplicationConfig struct { - AccessToken string `json:"accessToken" tfsdk:"access_token"` + AccessToken SecretEmbedded `json:"accessToken" tfsdk:"access_token"` NamespaceNamePattern string `json:"namespaceNamePattern" tfsdk:"namespace_name_pattern"` GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` ServicePrincipal AksServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` @@ -17,16 +17,15 @@ type AksReplicationConfig struct { AksResourceGroup string `json:"aksResourceGroup" tfsdk:"aks_resource_group"` RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` SendAzureInvitationMail bool `json:"sendAzureInvitationMail" tfsdk:"send_azure_invitation_mail"` - UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` + UserLookupStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` } type AksServicePrincipalConfig struct { - ClientId string `json:"clientId" tfsdk:"client_id"` - AuthType string `json:"authType" tfsdk:"auth_type"` - CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` - EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` - ObjectId string `json:"objectId" tfsdk:"object_id"` + EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` + ObjectId string `json:"objectId" tfsdk:"object_id"` + ClientId string `json:"clientId" tfsdk:"client_id"` + Auth AzureAuthConfig `json:"auth" tfsdk:"auth"` } type AksMeteringConfig struct { diff --git a/platform_config_aws.go b/platform_config_aws.go index a193b094..01805e6f 100644 --- a/platform_config_aws.go +++ b/platform_config_aws.go @@ -24,18 +24,23 @@ type AwsReplicationConfig struct { } type AwsAccessConfig struct { - OrganizationRootAccountRole string `json:"organizationRootAccountRole" tfsdk:"organization_root_account_role"` - OrganizationRootAccountExternalId *string `json:"organizationRootAccountExternalId,omitempty" tfsdk:"organization_root_account_external_id"` - ServiceUserConfig *AwsServiceUserConfig `json:"serviceUserConfig,omitempty" tfsdk:"service_user_config"` - WorkloadIdentityConfig *AwsWorkloadIdentityConfig `json:"workloadIdentityConfig,omitempty" tfsdk:"workload_identity_config"` + OrganizationRootAccountRole string `json:"organizationRootAccountRole" tfsdk:"organization_root_account_role"` + OrganizationRootAccountExternalId *string `json:"organizationRootAccountExternalId,omitempty" tfsdk:"organization_root_account_external_id"` + Auth AwsAuth `json:"auth" tfsdk:"auth"` } -type AwsServiceUserConfig struct { - AccessKey string `json:"accessKey" tfsdk:"access_key"` - SecretKey string `json:"secretKey" tfsdk:"secret_key"` +type AwsAuth struct { + Type string `json:"type" tfsdk:"type"` + Credential *AwsServiceUserCredential `json:"credential,omitempty" tfsdk:"credential"` + WorkloadIdentity *AwsWorkloadIdentityCredential `json:"workloadIdentity,omitempty" tfsdk:"workload_identity"` } -type AwsWorkloadIdentityConfig struct { +type AwsServiceUserCredential struct { + AccessKey string `json:"accessKey" tfsdk:"access_key"` + SecretKey SecretEmbedded `json:"secretKey" tfsdk:"secret_key"` +} + +type AwsWorkloadIdentityCredential struct { RoleArn string `json:"roleArn" tfsdk:"role_arn"` } @@ -43,7 +48,7 @@ type AwsSsoConfig struct { ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` Arn string `json:"arn" tfsdk:"arn"` GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` - SsoAccessToken string `json:"ssoAccessToken" tfsdk:"sso_access_token"` + SsoAccessToken SecretEmbedded `json:"ssoAccessToken" tfsdk:"sso_access_token"` AwsRoleMappings []AwsSsoRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` } diff --git a/platform_config_azure.go b/platform_config_azure.go index 6541a420..2d0375d1 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -16,23 +16,26 @@ type AzureReplicationConfig struct { BlueprintLocation string `json:"blueprintLocation" tfsdk:"blueprint_location"` AzureRoleMappings []AzureRoleMapping `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` AllowHierarchicalManagementGroupAssignment bool `json:"allowHierarchicalManagementGroupAssignment" tfsdk:"allow_hierarchical_management_group_assignment"` } type AzureServicePrincipalConfig struct { - ClientId string `json:"clientId" tfsdk:"client_id"` - AuthType string `json:"authType" tfsdk:"auth_type"` - CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` - ObjectId string `json:"objectId" tfsdk:"object_id"` + ClientId string `json:"clientId" tfsdk:"client_id"` + ObjectId string `json:"objectId" tfsdk:"object_id"` + Auth AzureAuthConfig `json:"auth" tfsdk:"auth"` +} + +type AzureAuthConfig struct { + Type string `json:"type" tfsdk:"type"` + Credential *SecretEmbedded `json:"credential,omitempty" tfsdk:"credential"` } type AzureGraphApiCredentials struct { - ClientId string `json:"clientId" tfsdk:"client_id"` - AuthType string `json:"authType" tfsdk:"auth_type"` - CredentialsAuthClientSecret *string `json:"credentialsAuthClientSecret,omitempty" tfsdk:"credentials_auth_client_secret"` + ClientId string `json:"clientId" tfsdk:"client_id"` + Auth AzureAuthConfig `json:"auth" tfsdk:"auth"` } type AzureSubscriptionProvisioningConfig struct { diff --git a/platform_config_azurerg.go b/platform_config_azurerg.go index d553f56f..e984d303 100644 --- a/platform_config_azurerg.go +++ b/platform_config_azurerg.go @@ -11,7 +11,7 @@ type AzureRgReplicationConfig struct { ResourceGroupNamePattern string `json:"resourceGroupNamePattern" tfsdk:"resource_group_name_pattern"` UserGroupNamePattern string `json:"userGroupNamePattern" tfsdk:"user_group_name_pattern"` B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` - UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_look_up_strategy"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` diff --git a/platform_config_gcp.go b/platform_config_gcp.go index 4812173e..83cc0377 100644 --- a/platform_config_gcp.go +++ b/platform_config_gcp.go @@ -6,7 +6,7 @@ type GcpPlatformConfig struct { } type GcpReplicationConfig struct { - ServiceAccountConfig GcpServiceAccountConfig `json:"serviceAccountConfig" tfsdk:"service_account_config"` + ServiceAccount GcpServiceAccountConfig `json:"serviceAccount" tfsdk:"service_account"` Domain string `json:"domain" tfsdk:"domain"` CustomerId string `json:"customerId" tfsdk:"customer_id"` GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` @@ -22,12 +22,9 @@ type GcpReplicationConfig struct { } type GcpServiceAccountConfig struct { - ServiceAccountCredentialsConfig *GcpServiceAccountCredentialsConfig `json:"serviceAccountCredentialsConfig,omitempty" tfsdk:"service_account_credentials_config"` - ServiceAccountWorkloadIdentityConfig *GcpServiceAccountWorkloadIdentityConfig `json:"serviceAccountWorkloadIdentityConfig,omitempty" tfsdk:"service_account_workload_identity_config"` -} - -type GcpServiceAccountCredentialsConfig struct { - ServiceAccountCredentialsB64 string `json:"serviceAccountCredentialsB64" tfsdk:"service_account_credentials_b64"` + Type string `json:"type" tfsdk:"type"` + Credential *SecretEmbedded `json:"credential,omitempty" tfsdk:"credential"` + WorkloadIdentity *GcpServiceAccountWorkloadIdentityConfig `json:"workloadIdentity,omitempty" tfsdk:"workload_identity"` } type GcpServiceAccountWorkloadIdentityConfig struct { @@ -41,7 +38,7 @@ type GcpPlatformRoleMapping struct { } type GcpMeteringConfig struct { - ServiceAccountConfig GcpServiceAccountConfig `json:"serviceAccountConfig" tfsdk:"service_account_config"` + ServiceAccount GcpServiceAccountConfig `json:"serviceAccount" tfsdk:"service_account"` BigqueryTable string `json:"bigqueryTable" tfsdk:"bigquery_table"` BigqueryTableForCarbonFootprint *string `json:"bigqueryTableForCarbonFootprint,omitempty" tfsdk:"bigquery_table_for_carbon_footprint"` CarbonFootprintDataCollectionStartMonth *string `json:"carbonFootprintDataCollectionStartMonth,omitempty" tfsdk:"carbon_footprint_data_collection_start_month"` diff --git a/platform_config_kubernetes.go b/platform_config_kubernetes.go index 72b0d964..893ba2d8 100644 --- a/platform_config_kubernetes.go +++ b/platform_config_kubernetes.go @@ -13,7 +13,7 @@ type KubernetesReplicationConfig struct { } type KubernetesClientConfig struct { - AccessToken string `json:"accessToken" tfsdk:"access_token"` + AccessToken SecretEmbedded `json:"accessToken" tfsdk:"access_token"` } type KubernetesMeteringConfig struct { diff --git a/platform_config_openshift.go b/platform_config_openshift.go index 2dc05c7e..1dcf7222 100644 --- a/platform_config_openshift.go +++ b/platform_config_openshift.go @@ -12,7 +12,7 @@ type OpenShiftReplicationConfig struct { WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` EnableTemplateInstantiation bool `json:"enableTemplateInstantiation" tfsdk:"enable_template_instantiation"` - OpenShiftRoleMappings []OpenShiftPlatformRoleMapping `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` + OpenshiftRoleMappings []OpenShiftPlatformRoleMapping `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` IdentityProviderName string `json:"identityProviderName" tfsdk:"identity_provider_name"` TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` } @@ -24,5 +24,5 @@ type OpenShiftMeteringConfig struct { type OpenShiftPlatformRoleMapping struct { MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - OpenShiftRole string `json:"openshiftRole" tfsdk:"openshift_role"` + OpenshiftRole string `json:"openshiftRole" tfsdk:"openshift_role"` } From a2c08fda0afb39d02679ef5b7aceab5daad8fb7e Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 19 Dec 2025 11:41:43 +0100 Subject: [PATCH 061/215] fix: many golangci-lint issues, remove unused lookUpEndpoints in client --- buildingblock.go | 9 ++++-- buildingblock_v2.go | 18 ++++++----- client.go | 71 +++++++------------------------------------- integrations.go | 18 ++++++----- landingzone.go | 10 +++++-- payment_method.go | 12 ++++++-- platform.go | 10 +++++-- project.go | 14 ++++++--- project_binding.go | 12 +++++--- tag_definition.go | 23 ++++++++++---- tenant.go | 8 +++-- tenant_v4.go | 16 ++++++---- workspace.go | 10 +++++-- workspace_binding.go | 14 +++++---- 14 files changed, 128 insertions(+), 117 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index a63e3ee8..cb3d5abf 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -94,7 +94,9 @@ func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingB return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -135,8 +137,9 @@ func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreat if err != nil { return nil, err } - - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { diff --git a/buildingblock_v2.go b/buildingblock_v2.go index df2b3b04..095a3918 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -15,7 +15,7 @@ import ( const ( CONTENT_TYPE_BUILDING_BLOCK_V2 = "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json" - // Building Block Status Constants + // Building Block Status Constants. BUILDING_BLOCK_STATUS_WAITING_FOR_DEPENDENT_INPUT = "WAITING_FOR_DEPENDENT_INPUT" BUILDING_BLOCK_STATUS_WAITING_FOR_OPERATOR_INPUT = "WAITING_FOR_OPERATOR_INPUT" BUILDING_BLOCK_STATUS_PENDING = "PENDING" @@ -85,7 +85,9 @@ func (c *MeshStackProviderClient) ReadBuildingBlockV2(uuid string) (*MeshBuildin return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -127,7 +129,9 @@ func (c *MeshStackProviderClient) CreateBuildingBlockV2(bb *MeshBuildingBlockV2C return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -153,7 +157,7 @@ func (c *MeshStackProviderClient) DeleteBuildingBlockV2(uuid string) error { } // PollBuildingBlockV2UntilCompletion polls a building block until it reaches a terminal state (SUCCEEDED or FAILED) -// Returns the final building block state or an error if polling fails or times out +// Returns the final building block state or an error if polling fails or times out. func (c *MeshStackProviderClient) PollBuildingBlockV2UntilCompletion(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { var result *MeshBuildingBlockV2 @@ -161,7 +165,7 @@ func (c *MeshStackProviderClient) PollBuildingBlockV2UntilCompletion(ctx context return result, err } -// waitForBuildingBlockV2CompletionFunc returns a RetryFunc that checks building block completion status +// waitForBuildingBlockV2CompletionFunc returns a RetryFunc that checks building block completion status. func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(uuid string, result **MeshBuildingBlockV2) retry.RetryFunc { return func() *retry.RetryError { current, err := c.ReadBuildingBlockV2(uuid) @@ -189,12 +193,12 @@ func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(uuid stri } // PollBuildingBlockV2UntilDeletion polls a building block until it is deleted (not found) -// Returns nil on successful deletion or an error if polling fails or times out +// Returns nil on successful deletion or an error if polling fails or times out. func (c *MeshStackProviderClient) PollBuildingBlockV2UntilDeletion(ctx context.Context, uuid string) error { return retry.RetryContext(ctx, 30*time.Minute, c.waitForBuildingBlockV2DeletionFunc(uuid)) } -// waitForBuildingBlockV2DeletionFunc returns a RetryFunc that checks building block deletion status +// waitForBuildingBlockV2DeletionFunc returns a RetryFunc that checks building block deletion status. func (c *MeshStackProviderClient) waitForBuildingBlockV2DeletionFunc(uuid string) retry.RetryFunc { return func() *retry.RetryError { current, err := c.ReadBuildingBlockV2(uuid) diff --git a/client.go b/client.go index aea3f894..b7370dd7 100644 --- a/client.go +++ b/client.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "fmt" "io" "log" @@ -15,11 +14,6 @@ import ( const ( apiMeshObjectsRoot = "/api/meshobjects" loginEndpoint = "/api/login" - - ERROR_GENERIC_CLIENT_ERROR = "client error" - ERROR_GENERIC_API_ERROR = "api error" - ERROR_AUTHENTICATION_FAILURE = "Not authorized. Check api key and secret." - ERROR_ENDPOINT_LOOKUP = "Could not fetch endpoints for meshStack." ) type MeshStackProviderClient struct { @@ -109,14 +103,16 @@ func (c *MeshStackProviderClient) login() error { req.Header.Add("Content-Type", "application/json") res, err := c.httpClient.Do(req) - if err != nil { return err - } else if res.StatusCode != 200 { - return fmt.Errorf("Status %d: %s", res.StatusCode, ERROR_AUTHENTICATION_FAILURE) } + defer func() { + _ = res.Body.Close() + }() - defer res.Body.Close() + if res.StatusCode != 200 { + return fmt.Errorf("login failed with status %d, check api key and secret", res.StatusCode) + } data, err := io.ReadAll(res.Body) if err != nil { @@ -142,53 +138,6 @@ func (c *MeshStackProviderClient) ensureValidToken() error { return nil } -// nolint: unused -func (c *MeshStackProviderClient) lookUpEndpoints() error { - if c.ensureValidToken() != nil { - return errors.New(ERROR_AUTHENTICATION_FAILURE) - } - - meshObjectsPath, err := url.JoinPath(c.url.String(), apiMeshObjectsRoot) - if err != nil { - return err - } - meshObjects, _ := url.Parse(meshObjectsPath) - - res, err := c.httpClient.Do( - &http.Request{ - URL: meshObjects, - Method: "GET", - Header: http.Header{ - "Authorization": {c.token}, - }, - }, - ) - - if err != nil { - return errors.New(ERROR_GENERIC_CLIENT_ERROR) - } - - defer res.Body.Close() - - if res.StatusCode != 200 { - return errors.New(ERROR_AUTHENTICATION_FAILURE) - } - - data, err := io.ReadAll(res.Body) - if err != nil { - return err - } - - var endpoints endpoints - err = json.Unmarshal(data, &endpoints) - if err != nil { - return err - } - - c.endpoints = endpoints - return nil -} - func (c *MeshStackProviderClient) doAuthenticatedRequest(req *http.Request) (*http.Response, error) { // ensure that headeres are initialized if req.Header == nil { @@ -224,10 +173,12 @@ func (c *MeshStackProviderClient) deleteMeshObject(targetUrl url.URL, expectedSt res, err := c.doAuthenticatedRequest(req) if err != nil { - return errors.New(ERROR_GENERIC_CLIENT_ERROR) + return fmt.Errorf("cannot authenticate for delete request: %w ", err) } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -235,7 +186,7 @@ func (c *MeshStackProviderClient) deleteMeshObject(targetUrl url.URL, expectedSt } if res.StatusCode != expectedStatus { - return fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + return fmt.Errorf("expected status code %d, but got %d, body: '%s'", expectedStatus, res.StatusCode, string(data)) } return nil diff --git a/integrations.go b/integrations.go index 0b686f51..92ef1b7a 100644 --- a/integrations.go +++ b/integrations.go @@ -34,7 +34,7 @@ type MeshIntegrationStatus struct { WorkloadIdentityFederation *MeshWorkloadIdentityFederation `json:"workloadIdentityFederation,omitempty" tfsdk:"workload_identity_federation"` } -// Integration Config wrapper with type discrimination +// Integration Config wrapper with type discrimination. type MeshIntegrationConfig struct { Type string `json:"type" tfsdk:"type"` Github *MeshIntegrationGithubConfig `json:"github,omitempty" tfsdk:"github"` @@ -42,7 +42,7 @@ type MeshIntegrationConfig struct { AzureDevops *MeshIntegrationAzureDevopsConfig `json:"azuredevops,omitempty" tfsdk:"azuredevops"` } -// GitHub Integration +// GitHub Integration. type MeshIntegrationGithubConfig struct { Owner string `json:"owner" tfsdk:"owner"` BaseUrl string `json:"baseUrl" tfsdk:"base_url"` @@ -51,13 +51,13 @@ type MeshIntegrationGithubConfig struct { RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` } -// GitLab Integration +// GitLab Integration. type MeshIntegrationGitlabConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` } -// Azure DevOps Integration +// Azure DevOps Integration. type MeshIntegrationAzureDevopsConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` Organization string `json:"organization" tfsdk:"organization"` @@ -65,13 +65,13 @@ type MeshIntegrationAzureDevopsConfig struct { RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` } -// Building Block Runner Reference +// Building Block Runner Reference. type BuildingBlockRunnerRef struct { Uuid string `json:"uuid" tfsdk:"uuid"` Kind string `json:"kind" tfsdk:"kind"` } -// Workload Identity Federation +// Workload Identity Federation. type MeshWorkloadIdentityFederation struct { Issuer string `json:"issuer" tfsdk:"issuer"` Subject string `json:"subject" tfsdk:"subject"` @@ -106,7 +106,7 @@ func (c *MeshStackProviderClient) ReadIntegration(workspace string, uuid string) return nil, err } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() data, err := io.ReadAll(res.Body) if err != nil { @@ -153,7 +153,9 @@ func (c *MeshStackProviderClient) ReadIntegrations() (*[]MeshIntegration, error) return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { diff --git a/landingzone.go b/landingzone.go index c3058696..fd2a371f 100644 --- a/landingzone.go +++ b/landingzone.go @@ -84,7 +84,7 @@ func (c *MeshStackProviderClient) ReadLandingZone(name string) (*MeshLandingZone return nil, err } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode == http.StatusNotFound { return nil, nil // Not found is not an error @@ -124,7 +124,9 @@ func (c *MeshStackProviderClient) CreateLandingZone(landingZone *MeshLandingZone if err != nil { return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -162,7 +164,9 @@ func (c *MeshStackProviderClient) UpdateLandingZone(name string, landingZone *Me if err != nil { return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { diff --git a/payment_method.go b/payment_method.go index 1555fa56..102aef67 100644 --- a/payment_method.go +++ b/payment_method.go @@ -61,7 +61,9 @@ func (c *MeshStackProviderClient) ReadPaymentMethod(workspace string, identifier return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() if res.StatusCode == http.StatusNotFound { return nil, nil @@ -103,7 +105,9 @@ func (c *MeshStackProviderClient) CreatePaymentMethod(paymentMethod *MeshPayment return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -143,7 +147,9 @@ func (c *MeshStackProviderClient) UpdatePaymentMethod(identifier string, payment return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { diff --git a/platform.go b/platform.go index a993c264..dd456edb 100644 --- a/platform.go +++ b/platform.go @@ -131,7 +131,7 @@ func (c *MeshStackProviderClient) ReadPlatform(uuid string) (*MeshPlatform, erro return nil, err } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode == http.StatusNotFound { return nil, nil // Not found is not an error @@ -171,7 +171,9 @@ func (c *MeshStackProviderClient) CreatePlatform(platform *MeshPlatformCreate) ( if err != nil { return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -214,7 +216,9 @@ func (c *MeshStackProviderClient) UpdatePlatform(uuid string, platform *MeshPlat if err != nil { return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { diff --git a/project.go b/project.go index eca293af..eb397c46 100644 --- a/project.go +++ b/project.go @@ -60,7 +60,9 @@ func (c *MeshStackProviderClient) ReadProject(workspace string, name string) (*M return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -112,7 +114,7 @@ func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, payme return nil, err } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() data, err := io.ReadAll(res.Body) if err != nil { @@ -171,7 +173,9 @@ func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*Me return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -212,7 +216,9 @@ func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*Me return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { diff --git a/project_binding.go b/project_binding.go index 9e7138e9..3d278e00 100644 --- a/project_binding.go +++ b/project_binding.go @@ -52,7 +52,7 @@ func (c *MeshStackProviderClient) readProjectBinding(name string, contentType st targetUrl = c.urlForPojectGroupBinding(name) default: - return nil, fmt.Errorf("Unexpected content type: %s", contentType) + return nil, fmt.Errorf("unexpected content type '%s'", contentType) } req, err := http.NewRequest("GET", targetUrl.String(), nil) @@ -66,7 +66,9 @@ func (c *MeshStackProviderClient) readProjectBinding(name string, contentType st return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -100,7 +102,7 @@ func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBindi targetUrl = c.endpoints.ProjectGroupBindings default: - return nil, fmt.Errorf("Unexpected content type: %s", contentType) + return nil, fmt.Errorf("unexpected content type '%s'", contentType) } payload, err := json.Marshal(binding) @@ -120,7 +122,9 @@ func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBindi return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { diff --git a/tag_definition.go b/tag_definition.go index 7916b9cb..0986a8e1 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -113,7 +113,9 @@ func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, er return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -156,7 +158,10 @@ func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefini if err != nil { return nil, err } - defer resp.Body.Close() + + defer func() { + _ = resp.Body.Close() + }() if !isSuccessHTTPStatus(resp) { return nil, fmt.Errorf("failed to read tag definition: %s", resp.Status) @@ -191,7 +196,9 @@ func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefi if err != nil { return nil, fmt.Errorf("failed to do authenticated request: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if !isSuccessHTTPStatus(resp) { return nil, fmt.Errorf("failed to create tag definition: %s", resp.Status) @@ -224,7 +231,10 @@ func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefi if err != nil { return nil, fmt.Errorf("failed to do authenticated request: %w", err) } - defer resp.Body.Close() + + defer func() { + _ = resp.Body.Close() + }() if !isSuccessHTTPStatus(resp) { return nil, fmt.Errorf("failed to update tag definition: %s", resp.Status) @@ -251,7 +261,10 @@ func (c *MeshStackProviderClient) DeleteTagDefinition(name string) error { if err != nil { return fmt.Errorf("failed to do authenticated request: %w", err) } - defer resp.Body.Close() + + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to delete tag definition: %s", resp.Status) diff --git a/tenant.go b/tenant.go index d10556d9..3442414d 100644 --- a/tenant.go +++ b/tenant.go @@ -72,7 +72,9 @@ func (c *MeshStackProviderClient) ReadTenant(workspace string, project string, p return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -114,7 +116,9 @@ func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshT return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { diff --git a/tenant_v4.go b/tenant_v4.go index 6c6a1965..61199fc7 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -80,7 +80,9 @@ func (c *MeshStackProviderClient) ReadTenantV4(uuid string) (*MeshTenantV4, erro return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -122,7 +124,9 @@ func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantV4Create) (*M return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -148,7 +152,7 @@ func (c *MeshStackProviderClient) DeleteTenantV4(uuid string) error { } // PollTenantV4UntilCreation polls a tenant until creation completes (platformTenantId is set) -// Returns the final tenant state or an error if polling fails or times out +// Returns the final tenant state or an error if polling fails or times out. func (c *MeshStackProviderClient) PollTenantV4UntilCreation(ctx context.Context, uuid string) (*MeshTenantV4, error) { var result *MeshTenantV4 @@ -156,7 +160,7 @@ func (c *MeshStackProviderClient) PollTenantV4UntilCreation(ctx context.Context, return result, err } -// waitForTenantV4CreationFunc returns a RetryFunc that checks tenant creation status +// waitForTenantV4CreationFunc returns a RetryFunc that checks tenant creation status. func (c *MeshStackProviderClient) waitForTenantV4CreationFunc(uuid string, result **MeshTenantV4) retry.RetryFunc { return func() *retry.RetryError { current, err := c.ReadTenantV4(uuid) @@ -180,12 +184,12 @@ func (c *MeshStackProviderClient) waitForTenantV4CreationFunc(uuid string, resul } // PollTenantV4UntilDeletion polls a tenant until it is deleted (not found) -// Returns nil on successful deletion or an error if polling fails or times out +// Returns nil on successful deletion or an error if polling fails or times out. func (c *MeshStackProviderClient) PollTenantV4UntilDeletion(ctx context.Context, uuid string) error { return retry.RetryContext(ctx, 30*time.Minute, c.waitForTenantV4DeletionFunc(uuid)) } -// waitForTenantV4DeletionFunc returns a RetryFunc that checks tenant deletion status +// waitForTenantV4DeletionFunc returns a RetryFunc that checks tenant deletion status. func (c *MeshStackProviderClient) waitForTenantV4DeletionFunc(uuid string) retry.RetryFunc { return func() *retry.RetryError { current, err := c.ReadTenantV4(uuid) diff --git a/workspace.go b/workspace.go index bdf878f3..59adc477 100644 --- a/workspace.go +++ b/workspace.go @@ -57,7 +57,7 @@ func (c *MeshStackProviderClient) ReadWorkspace(name string) (*MeshWorkspace, er return nil, err } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode == http.StatusNotFound { return nil, nil // Not found is not an error @@ -97,7 +97,9 @@ func (c *MeshStackProviderClient) CreateWorkspace(workspace *MeshWorkspaceCreate if err != nil { return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -135,7 +137,9 @@ func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWo if err != nil { return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { diff --git a/workspace_binding.go b/workspace_binding.go index a03b5867..907ac63f 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -44,7 +44,7 @@ func (c *MeshStackProviderClient) readWorkspaceBinding(name string, contentType targetUrl = c.urlForWorkspaceGroupBinding(name) default: - return nil, fmt.Errorf("Unexpected content type: %s", contentType) + return nil, fmt.Errorf("unexpected content type '%s'", contentType) } req, err := http.NewRequest("GET", targetUrl.String(), nil) @@ -58,7 +58,9 @@ func (c *MeshStackProviderClient) readWorkspaceBinding(name string, contentType return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { @@ -87,12 +89,10 @@ func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceB switch contentType { case CONTENT_TYPE_WORKSPACE_USER_BINDING: targetUrl = c.endpoints.WorkspaceUserBindings - case CONTENT_TYPE_WORKSPACE_GROUP_BINDING: targetUrl = c.endpoints.WorkspaceGroupBindings - default: - return nil, fmt.Errorf("Unexpected content type: %s", contentType) + return nil, fmt.Errorf("unexpected content type '%s'", contentType) } payload, err := json.Marshal(binding) @@ -112,7 +112,9 @@ func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceB return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() data, err := io.ReadAll(res.Body) if err != nil { From 497bda6e270aa7c54e6ecc4b577d489b86eb059f Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 18 Dec 2025 12:27:25 +0100 Subject: [PATCH 062/215] feat: add meshstack_location resource --- client.go | 2 + location.go | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 location.go diff --git a/client.go b/client.go index b7370dd7..f59759e9 100644 --- a/client.go +++ b/client.go @@ -40,6 +40,7 @@ type endpoints struct { Platforms *url.URL `json:"meshplatforms"` PaymentMethods *url.URL `json:"meshpaymentmethods"` Integrations *url.URL `json:"meshintegrations"` + Locations *url.URL `json:"meshlocations"` } type loginRequest struct { @@ -78,6 +79,7 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro Platforms: rootUrl.JoinPath(apiMeshObjectsRoot, "meshplatforms"), PaymentMethods: rootUrl.JoinPath(apiMeshObjectsRoot, "meshpaymentmethods"), Integrations: rootUrl.JoinPath(apiMeshObjectsRoot, "meshintegrations"), + Locations: rootUrl.JoinPath(apiMeshObjectsRoot, "meshlocations"), } return client, nil diff --git a/location.go b/location.go new file mode 100644 index 00000000..31ac2636 --- /dev/null +++ b/location.go @@ -0,0 +1,174 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const CONTENT_TYPE_LOCATION = "application/vnd.meshcloud.api.meshlocation.v1-preview.hal+json" + +type MeshLocation struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Metadata MeshLocationMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` + Status MeshLocationStatus `json:"status" tfsdk:"status"` +} + +type MeshLocationMetadata struct { + Name string `json:"name" tfsdk:"name"` + Uuid string `json:"uuid" tfsdk:"uuid"` +} + +type MeshLocationSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` +} + +type MeshLocationStatus struct { + IsPublic bool `json:"isPublic" tfsdk:"is_public"` +} + +type MeshLocationCreate struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Metadata MeshLocationCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` +} + +type MeshLocationCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +func (c *MeshStackProviderClient) urlForLocation(name string) *url.URL { + return c.endpoints.Locations.JoinPath(name) +} + +func (c *MeshStackProviderClient) ReadLocation(name string) (*MeshLocation, error) { + targetUrl := c.urlForLocation(name) + + req, err := http.NewRequest("GET", targetUrl.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", CONTENT_TYPE_LOCATION) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer func() { + _ = res.Body.Close() + }() + + if res.StatusCode == http.StatusNotFound { + return nil, nil + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var location MeshLocation + err = json.Unmarshal(data, &location) + if err != nil { + return nil, err + } + + return &location, nil +} + +func (c *MeshStackProviderClient) CreateLocation(location *MeshLocationCreate) (*MeshLocation, error) { + payload, err := json.Marshal(location) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.endpoints.Locations.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_LOCATION) + req.Header.Set("Accept", CONTENT_TYPE_LOCATION) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer func() { + _ = res.Body.Close() + }() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var createdLocation MeshLocation + err = json.Unmarshal(data, &createdLocation) + if err != nil { + return nil, err + } + + return &createdLocation, nil +} + +func (c *MeshStackProviderClient) UpdateLocation(name string, location *MeshLocationCreate) (*MeshLocation, error) { + targetUrl := c.urlForLocation(name) + + payload, err := json.Marshal(location) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", CONTENT_TYPE_LOCATION) + req.Header.Set("Accept", CONTENT_TYPE_LOCATION) + + res, err := c.doAuthenticatedRequest(req) + if err != nil { + return nil, err + } + + defer func() { + _ = res.Body.Close() + }() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if !isSuccessHTTPStatus(res) { + return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) + } + + var updatedLocation MeshLocation + err = json.Unmarshal(data, &updatedLocation) + if err != nil { + return nil, err + } + + return &updatedLocation, nil +} + +func (c *MeshStackProviderClient) DeleteLocation(name string) error { + targetUrl := c.urlForLocation(name) + return c.deleteMeshObject(*targetUrl, 204) +} From 44caff03dd8d5cf7f1bde97f5e6a326133fc56bd Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Wed, 7 Jan 2026 14:38:53 +0100 Subject: [PATCH 063/215] feat: add metadata.owned_by_workspace for landing zones CU-86c75e6bt --- landingzone.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/landingzone.go b/landingzone.go index fd2a371f..1b3ecf67 100644 --- a/landingzone.go +++ b/landingzone.go @@ -20,8 +20,9 @@ type MeshLandingZone struct { } type MeshLandingZoneMetadata struct { - Name string `json:"name" tfsdk:"name"` - Tags map[string][]string `json:"tags" tfsdk:"tags"` + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` } type MeshLandingZoneSpec struct { From 87903c19bc71ac579cd849dbe1ccfb32f1582cf4 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 14 Jan 2026 10:37:11 +0100 Subject: [PATCH 064/215] fix: building block refs required by landing zones --- buildingblock.go | 5 +++++ landingzone.go | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index cb3d5abf..c0067b07 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -76,6 +76,11 @@ type MeshBuildingBlockCreateMetadata struct { TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` } +type MeshBuildingBlockDefinitionRef struct { + Kind string `json:"kind" tfsdk:"kind"` + Uuid string `json:"uuid" tfsdk:"uuid"` +} + func (c *MeshStackProviderClient) urlForBuildingBlock(uuid string) *url.URL { return c.endpoints.BuildingBlocks.JoinPath(uuid) } diff --git a/landingzone.go b/landingzone.go index 1b3ecf67..29dcab16 100644 --- a/landingzone.go +++ b/landingzone.go @@ -26,14 +26,16 @@ type MeshLandingZoneMetadata struct { } type MeshLandingZoneSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Description string `json:"description" tfsdk:"description"` - AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` - AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` - InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` - PlatformRef MeshLandingZonePlatformRef `json:"platformRef" tfsdk:"platform_ref"` - PlatformProperties *MeshLandingZonePlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` - Quotas []MeshLandingZoneQuota `json:"quotas" tfsdk:"quotas"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` + AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` + InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` + PlatformRef MeshLandingZonePlatformRef `json:"platformRef" tfsdk:"platform_ref"` + PlatformProperties *MeshLandingZonePlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` + Quotas []MeshLandingZoneQuota `json:"quotas" tfsdk:"quotas"` + MandatoryBuildingBlockRefs []MeshBuildingBlockDefinitionRef `json:"mandatoryBuildingBlockRefs" tfsdk:"mandatory_building_block_refs"` + RecommendedBuildingBlockRefs []MeshBuildingBlockDefinitionRef `json:"recommendedBuildingBlockRefs" tfsdk:"recommended_building_block_refs"` } type MeshLandingZoneStatus struct { From 135bf96984d23553e123a660043a975de1a3f681 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 11:01:07 +0100 Subject: [PATCH 065/215] refactor: read and return body already in doAuthenticatedRequest, verify response with options (default verifies success) --- buildingblock.go | 41 ++++--------------- buildingblock_v2.go | 41 ++++--------------- client.go | 92 +++++++++++++++++++++++++++++------------- integrations.go | 39 ++++-------------- landingzone.go | 55 +++++-------------------- location.go | 59 +++++---------------------- payment_method.go | 59 +++++---------------------- platform.go | 55 +++++-------------------- project.go | 73 +++++---------------------------- project_binding.go | 41 ++++--------------- status_code_checker.go | 13 ------ tag_definition.go | 63 +++++------------------------ tenant.go | 42 ++++--------------- tenant_v4.go | 41 ++++--------------- workspace.go | 55 +++++-------------------- workspace_binding.go | 41 ++++--------------- 16 files changed, 179 insertions(+), 631 deletions(-) delete mode 100644 status_code_checker.go diff --git a/buildingblock.go b/buildingblock.go index c0067b07..3e2f9bc6 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -3,8 +3,7 @@ package client import ( "bytes" "encoding/json" - "fmt" - "io" + "errors" "net/http" "net/url" ) @@ -94,30 +93,16 @@ func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingB } req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode == 404 { - return nil, nil - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var bb MeshBuildingBlock - err = json.Unmarshal(data, &bb) + err = json.Unmarshal(body, &bb) if err != nil { return nil, err } @@ -138,25 +123,13 @@ func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreat req.Header.Set("Content-Type", CONTENT_TYPE_BUILDING_BLOCK) req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } var createdBb MeshBuildingBlock - err = json.Unmarshal(data, &createdBb) + err = json.Unmarshal(body, &createdBb) if err != nil { return nil, err } diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 095a3918..23bce725 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -4,8 +4,8 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" - "io" "net/http" "time" @@ -80,30 +80,16 @@ func (c *MeshStackProviderClient) ReadBuildingBlockV2(uuid string) (*MeshBuildin } req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK_V2) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode == 404 { - return nil, nil - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var bb MeshBuildingBlockV2 - err = json.Unmarshal(data, &bb) + err = json.Unmarshal(body, &bb) if err != nil { return nil, err } @@ -124,26 +110,13 @@ func (c *MeshStackProviderClient) CreateBuildingBlockV2(bb *MeshBuildingBlockV2C req.Header.Set("Content-Type", CONTENT_TYPE_BUILDING_BLOCK_V2) req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK_V2) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdBb MeshBuildingBlockV2 - err = json.Unmarshal(data, &createdBb) + err = json.Unmarshal(body, &createdBb) if err != nil { return nil, err } diff --git a/client.go b/client.go index f59759e9..d477b6bf 100644 --- a/client.go +++ b/client.go @@ -3,6 +3,7 @@ package client import ( "bytes" "encoding/json" + "errors" "fmt" "io" "log" @@ -16,6 +17,10 @@ const ( loginEndpoint = "/api/login" ) +var ( + errNotFound = errors.New("request failed with status Not Found (404)") +) + type MeshStackProviderClient struct { url *url.URL httpClient *http.Client @@ -140,8 +145,51 @@ func (c *MeshStackProviderClient) ensureValidToken() error { return nil } -func (c *MeshStackProviderClient) doAuthenticatedRequest(req *http.Request) (*http.Response, error) { - // ensure that headeres are initialized +type doRequestOption func(opts *doRequestOptions) + +type responseVerifier func(res *http.Response, body []byte) error + +type doRequestOptions struct { + responseVerifier responseVerifier +} + +func ensureSuccessfulRequest(res *http.Response, body []byte) error { + if res.StatusCode >= 200 && res.StatusCode <= 299 { + return nil + } + return handleErrWithNotFound(fmt.Errorf("request failed with status %d (not 2XX successful)", res.StatusCode), res.StatusCode, body) +} + +func withExpectedStatusCode(statusCode int) doRequestOption { + return func(opts *doRequestOptions) { + opts.responseVerifier = func(res *http.Response, body []byte) error { + if res.StatusCode == statusCode { + return nil + } + return handleErrWithNotFound(fmt.Errorf("expected status %d, but got %d", statusCode, res.StatusCode), res.StatusCode, body) + } + } +} + +func handleErrWithNotFound(err error, statusCode int, body []byte) error { + errs := []error{err, fmt.Errorf("error body: %s", string(body))} + if statusCode == http.StatusNotFound { + errs = append([]error{errNotFound}, errs...) + } + return errors.Join(errs...) +} + +func (c *MeshStackProviderClient) doAuthenticatedRequest(req *http.Request, options ...doRequestOption) ([]byte, error) { + opts := doRequestOptions{ + // by default, verify successful response + // can be made more specific with withExpectedStatusCode option + responseVerifier: ensureSuccessfulRequest, + } + for _, option := range options { + option(&opts) + } + + // ensure that headers are initialized if req.Header == nil { req.Header = map[string][]string{} } @@ -151,8 +199,7 @@ func (c *MeshStackProviderClient) doAuthenticatedRequest(req *http.Request) (*ht log.Println(req) // add authentication - err := c.ensureValidToken() - if err != nil { + if err := c.ensureValidToken(); err != nil { return nil, err } req.Header.Set("Authorization", c.token) @@ -161,35 +208,26 @@ func (c *MeshStackProviderClient) doAuthenticatedRequest(req *http.Request) (*ht if err != nil { return nil, err } - log.Println(res) - - return res, nil -} - -func (c *MeshStackProviderClient) deleteMeshObject(targetUrl url.URL, expectedStatus int) error { - req, err := http.NewRequest("DELETE", targetUrl.String(), nil) - if err != nil { - return err - } - - res, err := c.doAuthenticatedRequest(req) - - if err != nil { - return fmt.Errorf("cannot authenticate for delete request: %w ", err) - } - defer func() { _ = res.Body.Close() }() + log.Println(res) - data, err := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) if err != nil { - return err + return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) } + log.Printf("Got response body with %d bytes", len(body)) + // always return body, even if the response is not successfully verified + // this allows clients to investigate the body even further if desirable. + return body, opts.responseVerifier(res, body) +} - if res.StatusCode != expectedStatus { - return fmt.Errorf("expected status code %d, but got %d, body: '%s'", expectedStatus, res.StatusCode, string(data)) +func (c *MeshStackProviderClient) deleteMeshObject(targetUrl url.URL, expectedStatus int) (err error) { + req, err := http.NewRequest("DELETE", targetUrl.String(), nil) + if err != nil { + return err } - - return nil + _, err = c.doAuthenticatedRequest(req, withExpectedStatusCode(expectedStatus)) + return } diff --git a/integrations.go b/integrations.go index 92ef1b7a..32c192a1 100644 --- a/integrations.go +++ b/integrations.go @@ -2,8 +2,8 @@ package client import ( "encoding/json" + "errors" "fmt" - "io" "net/http" "net/url" ) @@ -101,28 +101,16 @@ func (c *MeshStackProviderClient) ReadIntegration(workspace string, uuid string) } req.Header.Set("Accept", CONTENT_TYPE_INTEGRATION) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - defer func() { _ = res.Body.Close() }() - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode == http.StatusNotFound { - return nil, nil - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var integration MeshIntegration - err = json.Unmarshal(data, &integration) + err = json.Unmarshal(body, &integration) if err != nil { return nil, err } @@ -148,24 +136,11 @@ func (c *MeshStackProviderClient) ReadIntegrations() (*[]MeshIntegration, error) req.Header.Set("Accept", CONTENT_TYPE_INTEGRATION) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var response struct { Embedded struct { MeshIntegrations []MeshIntegration `json:"meshIntegrations"` @@ -178,7 +153,7 @@ func (c *MeshStackProviderClient) ReadIntegrations() (*[]MeshIntegration, error) } `json:"page"` } - err = json.Unmarshal(data, &response) + err = json.Unmarshal(body, &response) if err != nil { return nil, err } diff --git a/landingzone.go b/landingzone.go index 29dcab16..ce88e5d1 100644 --- a/landingzone.go +++ b/landingzone.go @@ -3,8 +3,7 @@ package client import ( "bytes" "encoding/json" - "fmt" - "io" + "errors" "net/http" "net/url" ) @@ -82,28 +81,16 @@ func (c *MeshStackProviderClient) ReadLandingZone(name string) (*MeshLandingZone } req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - defer func() { _ = res.Body.Close() }() - - if res.StatusCode == http.StatusNotFound { - return nil, nil // Not found is not an error + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var landingZone MeshLandingZone - err = json.Unmarshal(data, &landingZone) + err = json.Unmarshal(body, &landingZone) if err != nil { return nil, err } @@ -123,25 +110,13 @@ func (c *MeshStackProviderClient) CreateLandingZone(landingZone *MeshLandingZone req.Header.Set("Content-Type", CONTENT_TYPE_LANDINGZONE) req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdLandingZone MeshLandingZone - err = json.Unmarshal(data, &createdLandingZone) + err = json.Unmarshal(body, &createdLandingZone) if err != nil { return nil, err } @@ -163,25 +138,13 @@ func (c *MeshStackProviderClient) UpdateLandingZone(name string, landingZone *Me req.Header.Set("Content-Type", CONTENT_TYPE_LANDINGZONE) req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var updatedLandingZone MeshLandingZone - err = json.Unmarshal(data, &updatedLandingZone) + err = json.Unmarshal(body, &updatedLandingZone) if err != nil { return nil, err } diff --git a/location.go b/location.go index 31ac2636..3949f144 100644 --- a/location.go +++ b/location.go @@ -3,8 +3,7 @@ package client import ( "bytes" "encoding/json" - "fmt" - "io" + "errors" "net/http" "net/url" ) @@ -55,30 +54,16 @@ func (c *MeshStackProviderClient) ReadLocation(name string) (*MeshLocation, erro } req.Header.Set("Accept", CONTENT_TYPE_LOCATION) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - defer func() { - _ = res.Body.Close() - }() - - if res.StatusCode == http.StatusNotFound { - return nil, nil + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var location MeshLocation - err = json.Unmarshal(data, &location) + err = json.Unmarshal(body, &location) if err != nil { return nil, err } @@ -99,26 +84,13 @@ func (c *MeshStackProviderClient) CreateLocation(location *MeshLocationCreate) ( req.Header.Set("Content-Type", CONTENT_TYPE_LOCATION) req.Header.Set("Accept", CONTENT_TYPE_LOCATION) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdLocation MeshLocation - err = json.Unmarshal(data, &createdLocation) + err = json.Unmarshal(body, &createdLocation) if err != nil { return nil, err } @@ -141,26 +113,13 @@ func (c *MeshStackProviderClient) UpdateLocation(name string, location *MeshLoca req.Header.Set("Content-Type", CONTENT_TYPE_LOCATION) req.Header.Set("Accept", CONTENT_TYPE_LOCATION) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var updatedLocation MeshLocation - err = json.Unmarshal(data, &updatedLocation) + err = json.Unmarshal(body, &updatedLocation) if err != nil { return nil, err } diff --git a/payment_method.go b/payment_method.go index 102aef67..fee94b64 100644 --- a/payment_method.go +++ b/payment_method.go @@ -3,8 +3,7 @@ package client import ( "bytes" "encoding/json" - "fmt" - "io" + "errors" "net/http" "net/url" ) @@ -56,30 +55,16 @@ func (c *MeshStackProviderClient) ReadPaymentMethod(workspace string, identifier } req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - defer func() { - _ = res.Body.Close() - }() - - if res.StatusCode == http.StatusNotFound { - return nil, nil + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var paymentMethod MeshPaymentMethod - err = json.Unmarshal(data, &paymentMethod) + err = json.Unmarshal(body, &paymentMethod) if err != nil { return nil, err } @@ -100,26 +85,13 @@ func (c *MeshStackProviderClient) CreatePaymentMethod(paymentMethod *MeshPayment req.Header.Set("Content-Type", CONTENT_TYPE_PAYMENT_METHOD) req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdPaymentMethod MeshPaymentMethod - err = json.Unmarshal(data, &createdPaymentMethod) + err = json.Unmarshal(body, &createdPaymentMethod) if err != nil { return nil, err } @@ -142,26 +114,13 @@ func (c *MeshStackProviderClient) UpdatePaymentMethod(identifier string, payment req.Header.Set("Content-Type", CONTENT_TYPE_PAYMENT_METHOD) req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var updatedPaymentMethod MeshPaymentMethod - err = json.Unmarshal(data, &updatedPaymentMethod) + err = json.Unmarshal(body, &updatedPaymentMethod) if err != nil { return nil, err } diff --git a/platform.go b/platform.go index dd456edb..3f10d266 100644 --- a/platform.go +++ b/platform.go @@ -3,8 +3,7 @@ package client import ( "bytes" "encoding/json" - "fmt" - "io" + "errors" "net/http" "net/url" ) @@ -126,28 +125,16 @@ func (c *MeshStackProviderClient) ReadPlatform(uuid string) (*MeshPlatform, erro } req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - defer func() { _ = res.Body.Close() }() - - if res.StatusCode == http.StatusNotFound { - return nil, nil // Not found is not an error + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var platform MeshPlatform - err = json.Unmarshal(data, &platform) + err = json.Unmarshal(body, &platform) if err != nil { return nil, err } @@ -167,25 +154,13 @@ func (c *MeshStackProviderClient) CreatePlatform(platform *MeshPlatformCreate) ( req.Header.Set("Content-Type", CONTENT_TYPE_PLATFORM) req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdPlatform MeshPlatform - err = json.Unmarshal(data, &createdPlatform) + err = json.Unmarshal(body, &createdPlatform) if err != nil { return nil, err } @@ -212,25 +187,13 @@ func (c *MeshStackProviderClient) UpdatePlatform(uuid string, platform *MeshPlat req.Header.Set("Content-Type", CONTENT_TYPE_PLATFORM) req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var updatedPlatform MeshPlatform - err = json.Unmarshal(data, &updatedPlatform) + err = json.Unmarshal(body, &updatedPlatform) if err != nil { return nil, err } diff --git a/project.go b/project.go index eb397c46..5abd7dd1 100644 --- a/project.go +++ b/project.go @@ -3,8 +3,8 @@ package client import ( "bytes" "encoding/json" + "errors" "fmt" - "io" "net/http" "net/url" ) @@ -55,30 +55,16 @@ func (c *MeshStackProviderClient) ReadProject(workspace string, name string) (*M } req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode == http.StatusNotFound { - return nil, nil - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var project MeshProject - err = json.Unmarshal(data, &project) + err = json.Unmarshal(body, &project) if err != nil { return nil, err } @@ -109,22 +95,11 @@ func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, payme req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { _ = res.Body.Close() }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var response struct { Embedded struct { MeshProjects []MeshProject `json:"meshProjects"` @@ -137,7 +112,7 @@ func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, payme } `json:"page"` } - err = json.Unmarshal(data, &response) + err = json.Unmarshal(body, &response) if err != nil { return nil, err } @@ -168,26 +143,13 @@ func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*Me req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT) req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdProject MeshProject - err = json.Unmarshal(data, &createdProject) + err = json.Unmarshal(body, &createdProject) if err != nil { return nil, err } @@ -210,27 +172,14 @@ func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*Me req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT) req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var updatedProject MeshProject - err = json.Unmarshal(data, &updatedProject) + err = json.Unmarshal(body, &updatedProject) if err != nil { return nil, err } diff --git a/project_binding.go b/project_binding.go index 3d278e00..84a153b3 100644 --- a/project_binding.go +++ b/project_binding.go @@ -3,8 +3,8 @@ package client import ( "bytes" "encoding/json" + "errors" "fmt" - "io" "net/http" "net/url" ) @@ -61,30 +61,16 @@ func (c *MeshStackProviderClient) readProjectBinding(name string, contentType st } req.Header.Set("Accept", contentType) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode == 404 { - return nil, nil - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var binding MeshProjectBinding - err = json.Unmarshal(data, &binding) + err = json.Unmarshal(body, &binding) if err != nil { return nil, err } @@ -117,26 +103,13 @@ func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBindi req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", contentType) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdBinding MeshProjectBinding - err = json.Unmarshal(data, &createdBinding) + err = json.Unmarshal(body, &createdBinding) if err != nil { return nil, err } diff --git a/status_code_checker.go b/status_code_checker.go deleted file mode 100644 index bd6f24b8..00000000 --- a/status_code_checker.go +++ /dev/null @@ -1,13 +0,0 @@ -package client - -import ( - "net/http" -) - -func isSuccessHTTPStatus(resp *http.Response) bool { - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return false - } - - return true -} diff --git a/tag_definition.go b/tag_definition.go index 0986a8e1..f6a95e58 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "io" "net/http" "net/url" ) @@ -108,26 +107,13 @@ func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, er req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var response tagsResponse - err = json.Unmarshal(data, &response) + err = json.Unmarshal(body, &response) if err != nil { return nil, err } @@ -154,21 +140,13 @@ func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefini req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - resp, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = resp.Body.Close() - }() - - if !isSuccessHTTPStatus(resp) { - return nil, fmt.Errorf("failed to read tag definition: %s", resp.Status) - } - var tagDefinition MeshTagDefinition - if err := json.NewDecoder(resp.Body).Decode(&tagDefinition); err != nil { + if err := json.Unmarshal(body, &tagDefinition); err != nil { return nil, err } @@ -192,20 +170,13 @@ func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefi req.Header.Set("Content-Type", CONTENT_TYPE_TAG_DEFINITION) req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - resp, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, fmt.Errorf("failed to do authenticated request: %w", err) } - defer func() { - _ = resp.Body.Close() - }() - - if !isSuccessHTTPStatus(resp) { - return nil, fmt.Errorf("failed to create tag definition: %s", resp.Status) - } var createdTagDefinition MeshTagDefinition - if err := json.NewDecoder(resp.Body).Decode(&createdTagDefinition); err != nil { + if err := json.Unmarshal(body, &createdTagDefinition); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } @@ -227,21 +198,13 @@ func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefi req.Header.Set("Content-Type", CONTENT_TYPE_TAG_DEFINITION) req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - resp, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, fmt.Errorf("failed to do authenticated request: %w", err) } - defer func() { - _ = resp.Body.Close() - }() - - if !isSuccessHTTPStatus(resp) { - return nil, fmt.Errorf("failed to update tag definition: %s", resp.Status) - } - var updatedTagDefinition MeshTagDefinition - if err := json.NewDecoder(resp.Body).Decode(&updatedTagDefinition); err != nil { + if err := json.Unmarshal(body, &updatedTagDefinition); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } @@ -257,18 +220,10 @@ func (c *MeshStackProviderClient) DeleteTagDefinition(name string) error { req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - resp, err := c.doAuthenticatedRequest(req) + _, err = c.doAuthenticatedRequest(req, withExpectedStatusCode(http.StatusNoContent)) if err != nil { return fmt.Errorf("failed to do authenticated request: %w", err) } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode != http.StatusNoContent { - return fmt.Errorf("failed to delete tag definition: %s", resp.Status) - } - return nil } diff --git a/tenant.go b/tenant.go index 3442414d..05c6e11a 100644 --- a/tenant.go +++ b/tenant.go @@ -3,8 +3,7 @@ package client import ( "bytes" "encoding/json" - "fmt" - "io" + "errors" "net/http" "net/url" ) @@ -67,30 +66,16 @@ func (c *MeshStackProviderClient) ReadTenant(workspace string, project string, p } req.Header.Set("Accept", CONTENT_TYPE_TENANT) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode == 404 { - return nil, nil - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var tenant MeshTenant - err = json.Unmarshal(data, &tenant) + err = json.Unmarshal(body, &tenant) if err != nil { return nil, err } @@ -111,26 +96,13 @@ func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshT req.Header.Set("Content-Type", CONTENT_TYPE_TENANT) req.Header.Set("Accept", CONTENT_TYPE_TENANT) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdTenant MeshTenant - err = json.Unmarshal(data, &createdTenant) + err = json.Unmarshal(body, &createdTenant) if err != nil { return nil, err } diff --git a/tenant_v4.go b/tenant_v4.go index 61199fc7..bd3535b8 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -4,8 +4,8 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" - "io" "net/http" "net/url" "time" @@ -75,30 +75,16 @@ func (c *MeshStackProviderClient) ReadTenantV4(uuid string) (*MeshTenantV4, erro } req.Header.Set("Accept", CONTENT_TYPE_TENANT_V4) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode == 404 { - return nil, nil - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var tenant MeshTenantV4 - err = json.Unmarshal(data, &tenant) + err = json.Unmarshal(body, &tenant) if err != nil { return nil, err } @@ -119,26 +105,13 @@ func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantV4Create) (*M req.Header.Set("Content-Type", CONTENT_TYPE_TENANT_V4) req.Header.Set("Accept", CONTENT_TYPE_TENANT_V4) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdTenant MeshTenantV4 - err = json.Unmarshal(data, &createdTenant) + err = json.Unmarshal(body, &createdTenant) if err != nil { return nil, err } diff --git a/workspace.go b/workspace.go index 59adc477..8a3b107e 100644 --- a/workspace.go +++ b/workspace.go @@ -3,8 +3,7 @@ package client import ( "bytes" "encoding/json" - "fmt" - "io" + "errors" "net/http" "net/url" ) @@ -52,28 +51,16 @@ func (c *MeshStackProviderClient) ReadWorkspace(name string) (*MeshWorkspace, er } req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - defer func() { _ = res.Body.Close() }() - - if res.StatusCode == http.StatusNotFound { - return nil, nil // Not found is not an error + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var workspace MeshWorkspace - err = json.Unmarshal(data, &workspace) + err = json.Unmarshal(body, &workspace) if err != nil { return nil, err } @@ -93,25 +80,13 @@ func (c *MeshStackProviderClient) CreateWorkspace(workspace *MeshWorkspaceCreate req.Header.Set("Content-Type", CONTENT_TYPE_WORKSPACE) req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdWorkspace MeshWorkspace - err = json.Unmarshal(data, &createdWorkspace) + err = json.Unmarshal(body, &createdWorkspace) if err != nil { return nil, err } @@ -133,25 +108,13 @@ func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWo req.Header.Set("Content-Type", CONTENT_TYPE_WORKSPACE) req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var updatedWorkspace MeshWorkspace - err = json.Unmarshal(data, &updatedWorkspace) + err = json.Unmarshal(body, &updatedWorkspace) if err != nil { return nil, err } diff --git a/workspace_binding.go b/workspace_binding.go index 907ac63f..5ad80336 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -3,8 +3,8 @@ package client import ( "bytes" "encoding/json" + "errors" "fmt" - "io" "net/http" "net/url" ) @@ -53,30 +53,16 @@ func (c *MeshStackProviderClient) readWorkspaceBinding(name string, contentType } req.Header.Set("Accept", contentType) - res, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err + body, err := c.doAuthenticatedRequest(req) + if errors.Is(err, errNotFound) { + return nil, nil // Not found } - - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode == 404 { - return nil, nil - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var binding MeshWorkspaceBinding - err = json.Unmarshal(data, &binding) + err = json.Unmarshal(body, &binding) if err != nil { return nil, err } @@ -107,26 +93,13 @@ func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceB req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", contentType) - res, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest(req) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if !isSuccessHTTPStatus(res) { - return nil, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, data) - } - var createdBinding MeshWorkspaceBinding - err = json.Unmarshal(data, &createdBinding) + err = json.Unmarshal(body, &createdBinding) if err != nil { return nil, err } From 5eebb125c3a52d72d0f1eeb72a4f28621dd471b3 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 12:31:35 +0100 Subject: [PATCH 066/215] refactor: use generate unmarshalBody(IfPresent) --- buildingblock.go | 30 ++------------------ buildingblock_v2.go | 30 ++------------------ client.go | 42 ++++++++++++++++++++++++++++ integrations.go | 38 ++------------------------ landingzone.go | 40 ++------------------------- location.go | 43 ++--------------------------- payment_method.go | 43 ++--------------------------- platform.go | 40 ++------------------------- project.go | 65 ++++---------------------------------------- project_binding.go | 30 ++------------------ tag_definition.go | 54 +++++------------------------------- tenant.go | 30 ++------------------ tenant_v4.go | 30 ++------------------ workspace.go | 40 ++------------------------- workspace_binding.go | 30 ++------------------ 15 files changed, 84 insertions(+), 501 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index 3e2f9bc6..c95cc0b9 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "net/http" "net/url" ) @@ -93,21 +92,7 @@ func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingB } req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var bb MeshBuildingBlock - err = json.Unmarshal(body, &bb) - if err != nil { - return nil, err - } - - return &bb, nil + return unmarshalBodyIfPresent[MeshBuildingBlock](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { @@ -123,18 +108,7 @@ func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreat req.Header.Set("Content-Type", CONTENT_TYPE_BUILDING_BLOCK) req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdBb MeshBuildingBlock - err = json.Unmarshal(body, &createdBb) - if err != nil { - return nil, err - } - - return &createdBb, nil + return unmarshalBody[MeshBuildingBlock](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeleteBuildingBlock(uuid string) error { diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 23bce725..27d92b1c 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "net/http" "time" @@ -80,21 +79,7 @@ func (c *MeshStackProviderClient) ReadBuildingBlockV2(uuid string) (*MeshBuildin } req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK_V2) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var bb MeshBuildingBlockV2 - err = json.Unmarshal(body, &bb) - if err != nil { - return nil, err - } - - return &bb, nil + return unmarshalBodyIfPresent[MeshBuildingBlockV2](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreateBuildingBlockV2(bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { @@ -110,18 +95,7 @@ func (c *MeshStackProviderClient) CreateBuildingBlockV2(bb *MeshBuildingBlockV2C req.Header.Set("Content-Type", CONTENT_TYPE_BUILDING_BLOCK_V2) req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK_V2) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdBb MeshBuildingBlockV2 - err = json.Unmarshal(body, &createdBb) - if err != nil { - return nil, err - } - - return &createdBb, nil + return unmarshalBody[MeshBuildingBlockV2](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeleteBuildingBlockV2(uuid string) error { diff --git a/client.go b/client.go index d477b6bf..946b83fa 100644 --- a/client.go +++ b/client.go @@ -231,3 +231,45 @@ func (c *MeshStackProviderClient) deleteMeshObject(targetUrl url.URL, expectedSt _, err = c.doAuthenticatedRequest(req, withExpectedStatusCode(expectedStatus)) return } + +func unmarshalBody[T any](body []byte, err error) (*T, error) { + if err != nil { + return nil, err + } + var target T + if err := json.Unmarshal(body, &target); err != nil { + return nil, err + } + return &target, nil +} + +func unmarshalBodyIfPresent[T any](body []byte, err error) (*T, error) { + if errors.Is(err, errNotFound) { + return nil, nil + } + return unmarshalBody[T](body, err) +} + +// paginatedResponse is a generic structure for HAL paginated responses +type paginatedResponse[T any] struct { + Embedded map[string][]T `json:"_embedded"` + Page struct { + Size int `json:"size"` + TotalElements int `json:"totalElements"` + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` +} + +// unmarshalPaginatedBody unmarshals a paginated HAL response and extracts items using the provided key +func unmarshalPaginatedBody[T any](body []byte, err error, embeddedKey string) ([]T, *paginatedResponse[T], error) { + if err != nil { + return nil, nil, err + } + var response paginatedResponse[T] + if err := json.Unmarshal(body, &response); err != nil { + return nil, nil, err + } + items := response.Embedded[embeddedKey] + return items, &response, nil +} diff --git a/integrations.go b/integrations.go index 32c192a1..06a60141 100644 --- a/integrations.go +++ b/integrations.go @@ -1,8 +1,6 @@ package client import ( - "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -101,21 +99,7 @@ func (c *MeshStackProviderClient) ReadIntegration(workspace string, uuid string) } req.Header.Set("Accept", CONTENT_TYPE_INTEGRATION) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var integration MeshIntegration - err = json.Unmarshal(body, &integration) - if err != nil { - return nil, err - } - - return &integration, nil + return unmarshalBodyIfPresent[MeshIntegration](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) ReadIntegrations() (*[]MeshIntegration, error) { @@ -137,28 +121,12 @@ func (c *MeshStackProviderClient) ReadIntegrations() (*[]MeshIntegration, error) req.Header.Set("Accept", CONTENT_TYPE_INTEGRATION) body, err := c.doAuthenticatedRequest(req) + items, response, err := unmarshalPaginatedBody[MeshIntegration](body, err, "meshIntegrations") if err != nil { return nil, err } - var response struct { - Embedded struct { - MeshIntegrations []MeshIntegration `json:"meshIntegrations"` - } `json:"_embedded"` - Page struct { - Size int `json:"size"` - TotalElements int `json:"totalElements"` - TotalPages int `json:"totalPages"` - Number int `json:"number"` - } `json:"page"` - } - - err = json.Unmarshal(body, &response) - if err != nil { - return nil, err - } - - allIntegrations = append(allIntegrations, response.Embedded.MeshIntegrations...) + allIntegrations = append(allIntegrations, items...) // Check if there are more pages if response.Page.Number >= response.Page.TotalPages-1 { diff --git a/landingzone.go b/landingzone.go index ce88e5d1..97a394bc 100644 --- a/landingzone.go +++ b/landingzone.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "net/http" "net/url" ) @@ -81,20 +80,7 @@ func (c *MeshStackProviderClient) ReadLandingZone(name string) (*MeshLandingZone } req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var landingZone MeshLandingZone - err = json.Unmarshal(body, &landingZone) - if err != nil { - return nil, err - } - return &landingZone, nil + return unmarshalBodyIfPresent[MeshLandingZone](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreateLandingZone(landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { @@ -110,17 +96,7 @@ func (c *MeshStackProviderClient) CreateLandingZone(landingZone *MeshLandingZone req.Header.Set("Content-Type", CONTENT_TYPE_LANDINGZONE) req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdLandingZone MeshLandingZone - err = json.Unmarshal(body, &createdLandingZone) - if err != nil { - return nil, err - } - return &createdLandingZone, nil + return unmarshalBody[MeshLandingZone](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) UpdateLandingZone(name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { @@ -138,17 +114,7 @@ func (c *MeshStackProviderClient) UpdateLandingZone(name string, landingZone *Me req.Header.Set("Content-Type", CONTENT_TYPE_LANDINGZONE) req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var updatedLandingZone MeshLandingZone - err = json.Unmarshal(body, &updatedLandingZone) - if err != nil { - return nil, err - } - return &updatedLandingZone, nil + return unmarshalBody[MeshLandingZone](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeleteLandingZone(name string) error { diff --git a/location.go b/location.go index 3949f144..4ff0d0d3 100644 --- a/location.go +++ b/location.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "net/http" "net/url" ) @@ -54,21 +53,7 @@ func (c *MeshStackProviderClient) ReadLocation(name string) (*MeshLocation, erro } req.Header.Set("Accept", CONTENT_TYPE_LOCATION) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var location MeshLocation - err = json.Unmarshal(body, &location) - if err != nil { - return nil, err - } - - return &location, nil + return unmarshalBodyIfPresent[MeshLocation](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreateLocation(location *MeshLocationCreate) (*MeshLocation, error) { @@ -84,18 +69,7 @@ func (c *MeshStackProviderClient) CreateLocation(location *MeshLocationCreate) ( req.Header.Set("Content-Type", CONTENT_TYPE_LOCATION) req.Header.Set("Accept", CONTENT_TYPE_LOCATION) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdLocation MeshLocation - err = json.Unmarshal(body, &createdLocation) - if err != nil { - return nil, err - } - - return &createdLocation, nil + return unmarshalBody[MeshLocation](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) UpdateLocation(name string, location *MeshLocationCreate) (*MeshLocation, error) { @@ -113,18 +87,7 @@ func (c *MeshStackProviderClient) UpdateLocation(name string, location *MeshLoca req.Header.Set("Content-Type", CONTENT_TYPE_LOCATION) req.Header.Set("Accept", CONTENT_TYPE_LOCATION) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var updatedLocation MeshLocation - err = json.Unmarshal(body, &updatedLocation) - if err != nil { - return nil, err - } - - return &updatedLocation, nil + return unmarshalBody[MeshLocation](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeleteLocation(name string) error { diff --git a/payment_method.go b/payment_method.go index fee94b64..f62651d5 100644 --- a/payment_method.go +++ b/payment_method.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "net/http" "net/url" ) @@ -55,21 +54,7 @@ func (c *MeshStackProviderClient) ReadPaymentMethod(workspace string, identifier } req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var paymentMethod MeshPaymentMethod - err = json.Unmarshal(body, &paymentMethod) - if err != nil { - return nil, err - } - - return &paymentMethod, nil + return unmarshalBodyIfPresent[MeshPaymentMethod](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreatePaymentMethod(paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { @@ -85,18 +70,7 @@ func (c *MeshStackProviderClient) CreatePaymentMethod(paymentMethod *MeshPayment req.Header.Set("Content-Type", CONTENT_TYPE_PAYMENT_METHOD) req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdPaymentMethod MeshPaymentMethod - err = json.Unmarshal(body, &createdPaymentMethod) - if err != nil { - return nil, err - } - - return &createdPaymentMethod, nil + return unmarshalBody[MeshPaymentMethod](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) UpdatePaymentMethod(identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { @@ -114,18 +88,7 @@ func (c *MeshStackProviderClient) UpdatePaymentMethod(identifier string, payment req.Header.Set("Content-Type", CONTENT_TYPE_PAYMENT_METHOD) req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var updatedPaymentMethod MeshPaymentMethod - err = json.Unmarshal(body, &updatedPaymentMethod) - if err != nil { - return nil, err - } - - return &updatedPaymentMethod, nil + return unmarshalBody[MeshPaymentMethod](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeletePaymentMethod(identifier string) error { diff --git a/platform.go b/platform.go index 3f10d266..609f8cfd 100644 --- a/platform.go +++ b/platform.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "net/http" "net/url" ) @@ -125,20 +124,7 @@ func (c *MeshStackProviderClient) ReadPlatform(uuid string) (*MeshPlatform, erro } req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var platform MeshPlatform - err = json.Unmarshal(body, &platform) - if err != nil { - return nil, err - } - return &platform, nil + return unmarshalBodyIfPresent[MeshPlatform](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreatePlatform(platform *MeshPlatformCreate) (*MeshPlatform, error) { @@ -154,17 +140,7 @@ func (c *MeshStackProviderClient) CreatePlatform(platform *MeshPlatformCreate) ( req.Header.Set("Content-Type", CONTENT_TYPE_PLATFORM) req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdPlatform MeshPlatform - err = json.Unmarshal(body, &createdPlatform) - if err != nil { - return nil, err - } - return &createdPlatform, nil + return unmarshalBody[MeshPlatform](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeletePlatform(uuid string) error { @@ -187,15 +163,5 @@ func (c *MeshStackProviderClient) UpdatePlatform(uuid string, platform *MeshPlat req.Header.Set("Content-Type", CONTENT_TYPE_PLATFORM) req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var updatedPlatform MeshPlatform - err = json.Unmarshal(body, &updatedPlatform) - if err != nil { - return nil, err - } - return &updatedPlatform, nil + return unmarshalBody[MeshPlatform](c.doAuthenticatedRequest(req)) } diff --git a/project.go b/project.go index 5abd7dd1..f18638cf 100644 --- a/project.go +++ b/project.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -55,21 +54,7 @@ func (c *MeshStackProviderClient) ReadProject(workspace string, name string) (*M } req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var project MeshProject - err = json.Unmarshal(body, &project) - if err != nil { - return nil, err - } - - return &project, nil + return unmarshalBodyIfPresent[MeshProject](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, paymentMethodIdentifier *string) (*[]MeshProject, error) { @@ -85,7 +70,6 @@ func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, payme for { query.Set("page", fmt.Sprintf("%d", pageNumber)) - targetUrl.RawQuery = query.Encode() req, err := http.NewRequest("GET", targetUrl.String(), nil) @@ -96,28 +80,12 @@ func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, payme req.Header.Set("Accept", CONTENT_TYPE_PROJECT) body, err := c.doAuthenticatedRequest(req) + items, response, err := unmarshalPaginatedBody[MeshProject](body, err, "meshProjects") if err != nil { return nil, err } - var response struct { - Embedded struct { - MeshProjects []MeshProject `json:"meshProjects"` - } `json:"_embedded"` - Page struct { - Size int `json:"size"` - TotalElements int `json:"totalElements"` - TotalPages int `json:"totalPages"` - Number int `json:"number"` - } `json:"page"` - } - - err = json.Unmarshal(body, &response) - if err != nil { - return nil, err - } - - allProjects = append(allProjects, response.Embedded.MeshProjects...) + allProjects = append(allProjects, items...) // Check if there are more pages if response.Page.Number >= response.Page.TotalPages-1 { @@ -143,18 +111,7 @@ func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*Me req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT) req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdProject MeshProject - err = json.Unmarshal(body, &createdProject) - if err != nil { - return nil, err - } - - return &createdProject, nil + return unmarshalBody[MeshProject](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*MeshProject, error) { @@ -172,19 +129,7 @@ func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*Me req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT) req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - body, err := c.doAuthenticatedRequest(req) - - if err != nil { - return nil, err - } - - var updatedProject MeshProject - err = json.Unmarshal(body, &updatedProject) - if err != nil { - return nil, err - } - - return &updatedProject, nil + return unmarshalBody[MeshProject](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeleteProject(workspace string, name string) error { diff --git a/project_binding.go b/project_binding.go index 84a153b3..e4c01d90 100644 --- a/project_binding.go +++ b/project_binding.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -61,21 +60,7 @@ func (c *MeshStackProviderClient) readProjectBinding(name string, contentType st } req.Header.Set("Accept", contentType) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var binding MeshProjectBinding - err = json.Unmarshal(body, &binding) - if err != nil { - return nil, err - } - - return &binding, nil + return unmarshalBodyIfPresent[MeshProjectBinding](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBinding, contentType string) (*MeshProjectBinding, error) { @@ -103,16 +88,5 @@ func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBindi req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", contentType) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdBinding MeshProjectBinding - err = json.Unmarshal(body, &createdBinding) - if err != nil { - return nil, err - } - - return &createdBinding, nil + return unmarshalBody[MeshProjectBinding](c.doAuthenticatedRequest(req)) } diff --git a/tag_definition.go b/tag_definition.go index f6a95e58..386e1a53 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -83,21 +83,8 @@ func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, er targetUrl := c.endpoints.TagDefinitions query := targetUrl.Query() - type tagsResponse struct { - Embedded struct { - MeshTagDefinitions []MeshTagDefinition `json:"meshTagDefinitions"` - } `json:"_embedded"` - Page struct { - Size int `json:"size"` - TotalElements int `json:"totalElements"` - TotalPages int `json:"totalPages"` - Number int `json:"number"` - } `json:"page"` - } - for { query.Set("page", fmt.Sprintf("%d", pageNumber)) - targetUrl.RawQuery = query.Encode() req, err := http.NewRequest("GET", targetUrl.String(), nil) @@ -108,17 +95,12 @@ func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, er req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) body, err := c.doAuthenticatedRequest(req) + items, response, err := unmarshalPaginatedBody[MeshTagDefinition](body, err, "meshTagDefinitions") if err != nil { return nil, err } - var response tagsResponse - err = json.Unmarshal(body, &response) - if err != nil { - return nil, err - } - - all = append(all, response.Embedded.MeshTagDefinitions...) + all = append(all, items...) // Check if there are more pages if response.Page.Number >= response.Page.TotalPages-1 { @@ -140,17 +122,7 @@ func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefini req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var tagDefinition MeshTagDefinition - if err := json.Unmarshal(body, &tagDefinition); err != nil { - return nil, err - } - - return &tagDefinition, nil + return unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { @@ -170,17 +142,11 @@ func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefi req.Header.Set("Content-Type", CONTENT_TYPE_TAG_DEFINITION) req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - body, err := c.doAuthenticatedRequest(req) + result, err := unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest(req)) if err != nil { return nil, fmt.Errorf("failed to do authenticated request: %w", err) } - - var createdTagDefinition MeshTagDefinition - if err := json.Unmarshal(body, &createdTagDefinition); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return &createdTagDefinition, nil + return result, nil } func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { @@ -198,17 +164,11 @@ func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefi req.Header.Set("Content-Type", CONTENT_TYPE_TAG_DEFINITION) req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - body, err := c.doAuthenticatedRequest(req) + result, err := unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest(req)) if err != nil { return nil, fmt.Errorf("failed to do authenticated request: %w", err) } - - var updatedTagDefinition MeshTagDefinition - if err := json.Unmarshal(body, &updatedTagDefinition); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return &updatedTagDefinition, nil + return result, nil } func (c *MeshStackProviderClient) DeleteTagDefinition(name string) error { diff --git a/tenant.go b/tenant.go index 05c6e11a..43f682ff 100644 --- a/tenant.go +++ b/tenant.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "net/http" "net/url" ) @@ -66,21 +65,7 @@ func (c *MeshStackProviderClient) ReadTenant(workspace string, project string, p } req.Header.Set("Accept", CONTENT_TYPE_TENANT) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var tenant MeshTenant - err = json.Unmarshal(body, &tenant) - if err != nil { - return nil, err - } - - return &tenant, nil + return unmarshalBodyIfPresent[MeshTenant](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshTenant, error) { @@ -96,18 +81,7 @@ func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshT req.Header.Set("Content-Type", CONTENT_TYPE_TENANT) req.Header.Set("Accept", CONTENT_TYPE_TENANT) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdTenant MeshTenant - err = json.Unmarshal(body, &createdTenant) - if err != nil { - return nil, err - } - - return &createdTenant, nil + return unmarshalBody[MeshTenant](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeleteTenant(workspace string, project string, platform string) error { diff --git a/tenant_v4.go b/tenant_v4.go index bd3535b8..26c89009 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -75,21 +74,7 @@ func (c *MeshStackProviderClient) ReadTenantV4(uuid string) (*MeshTenantV4, erro } req.Header.Set("Accept", CONTENT_TYPE_TENANT_V4) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var tenant MeshTenantV4 - err = json.Unmarshal(body, &tenant) - if err != nil { - return nil, err - } - - return &tenant, nil + return unmarshalBodyIfPresent[MeshTenantV4](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantV4Create) (*MeshTenantV4, error) { @@ -105,18 +90,7 @@ func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantV4Create) (*M req.Header.Set("Content-Type", CONTENT_TYPE_TENANT_V4) req.Header.Set("Accept", CONTENT_TYPE_TENANT_V4) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdTenant MeshTenantV4 - err = json.Unmarshal(body, &createdTenant) - if err != nil { - return nil, err - } - - return &createdTenant, nil + return unmarshalBody[MeshTenantV4](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeleteTenantV4(uuid string) error { diff --git a/workspace.go b/workspace.go index 8a3b107e..83d1eab9 100644 --- a/workspace.go +++ b/workspace.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "net/http" "net/url" ) @@ -51,20 +50,7 @@ func (c *MeshStackProviderClient) ReadWorkspace(name string) (*MeshWorkspace, er } req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var workspace MeshWorkspace - err = json.Unmarshal(body, &workspace) - if err != nil { - return nil, err - } - return &workspace, nil + return unmarshalBodyIfPresent[MeshWorkspace](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) CreateWorkspace(workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { @@ -80,17 +66,7 @@ func (c *MeshStackProviderClient) CreateWorkspace(workspace *MeshWorkspaceCreate req.Header.Set("Content-Type", CONTENT_TYPE_WORKSPACE) req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdWorkspace MeshWorkspace - err = json.Unmarshal(body, &createdWorkspace) - if err != nil { - return nil, err - } - return &createdWorkspace, nil + return unmarshalBody[MeshWorkspace](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { @@ -108,17 +84,7 @@ func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWo req.Header.Set("Content-Type", CONTENT_TYPE_WORKSPACE) req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var updatedWorkspace MeshWorkspace - err = json.Unmarshal(body, &updatedWorkspace) - if err != nil { - return nil, err - } - return &updatedWorkspace, nil + return unmarshalBody[MeshWorkspace](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) DeleteWorkspace(name string) error { diff --git a/workspace_binding.go b/workspace_binding.go index 5ad80336..326860d9 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -3,7 +3,6 @@ package client import ( "bytes" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -53,21 +52,7 @@ func (c *MeshStackProviderClient) readWorkspaceBinding(name string, contentType } req.Header.Set("Accept", contentType) - body, err := c.doAuthenticatedRequest(req) - if errors.Is(err, errNotFound) { - return nil, nil // Not found - } - if err != nil { - return nil, err - } - - var binding MeshWorkspaceBinding - err = json.Unmarshal(body, &binding) - if err != nil { - return nil, err - } - - return &binding, nil + return unmarshalBodyIfPresent[MeshWorkspaceBinding](c.doAuthenticatedRequest(req)) } func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceBinding, contentType string) (*MeshWorkspaceBinding, error) { @@ -93,16 +78,5 @@ func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceB req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", contentType) - body, err := c.doAuthenticatedRequest(req) - if err != nil { - return nil, err - } - - var createdBinding MeshWorkspaceBinding - err = json.Unmarshal(body, &createdBinding) - if err != nil { - return nil, err - } - - return &createdBinding, nil + return unmarshalBody[MeshWorkspaceBinding](c.doAuthenticatedRequest(req)) } From 8128350065f1d58f54312795ed3c0b9a6c03132f Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 14:50:10 +0100 Subject: [PATCH 067/215] refactor: use request modifiers and build http.Request only in doAuthenticatedRequest --- buildingblock.go | 33 +++----------- buildingblock_v2.go | 33 +++----------- client.go | 92 +++++++++++++++++++++++++++----------- integrations.go | 23 +++------- landingzone.go | 50 +++++---------------- location.go | 51 +++++---------------- payment_method.go | 51 +++++---------------- platform.go | 50 +++++---------------- project.go | 61 ++++++------------------- project_binding.go | 29 +++--------- project_group_binding.go | 2 +- project_user_binding.go | 2 +- tag_definition.go | 77 +++++++------------------------ tenant.go | 32 +++---------- tenant_v4.go | 32 +++---------- workspace.go | 50 +++++---------------- workspace_binding.go | 29 +++--------- workspace_group_binding.go | 2 +- workspace_user_binding.go | 2 +- 19 files changed, 194 insertions(+), 507 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index c95cc0b9..bd13fe75 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -1,9 +1,6 @@ package client import ( - "bytes" - "encoding/json" - "net/http" "net/url" ) @@ -84,34 +81,18 @@ func (c *MeshStackProviderClient) urlForBuildingBlock(uuid string) *url.URL { } func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingBlock, error) { - targetUrl := c.urlForBuildingBlock(uuid) - - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK) - - return unmarshalBodyIfPresent[MeshBuildingBlock](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshBuildingBlock](c.doAuthenticatedRequest("GET", c.urlForBuildingBlock(uuid), + withAccept(CONTENT_TYPE_BUILDING_BLOCK), + )) } func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { - payload, err := json.Marshal(bb) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.BuildingBlocks.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_BUILDING_BLOCK) - req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK) - - return unmarshalBody[MeshBuildingBlock](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshBuildingBlock](c.doAuthenticatedRequest("POST", c.endpoints.BuildingBlocks, + withPayload(bb, CONTENT_TYPE_BUILDING_BLOCK), + )) } func (c *MeshStackProviderClient) DeleteBuildingBlock(uuid string) error { targetUrl := c.urlForBuildingBlock(uuid) - return c.deleteMeshObject(*targetUrl, 202) + return c.deleteMeshObject(targetUrl, 202) } diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 27d92b1c..fa60a988 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -1,11 +1,8 @@ package client import ( - "bytes" "context" - "encoding/json" "fmt" - "net/http" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" @@ -71,36 +68,20 @@ type MeshBuildingBlockV2Status struct { } func (c *MeshStackProviderClient) ReadBuildingBlockV2(uuid string) (*MeshBuildingBlockV2, error) { - targetUrl := c.urlForBuildingBlock(uuid) - - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK_V2) - - return unmarshalBodyIfPresent[MeshBuildingBlockV2](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshBuildingBlockV2](c.doAuthenticatedRequest("GET", c.urlForBuildingBlock(uuid), + withAccept(CONTENT_TYPE_BUILDING_BLOCK_V2), + )) } func (c *MeshStackProviderClient) CreateBuildingBlockV2(bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { - payload, err := json.Marshal(bb) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.BuildingBlocks.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_BUILDING_BLOCK_V2) - req.Header.Set("Accept", CONTENT_TYPE_BUILDING_BLOCK_V2) - - return unmarshalBody[MeshBuildingBlockV2](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshBuildingBlockV2](c.doAuthenticatedRequest("POST", c.endpoints.BuildingBlocks, + withPayload(bb, CONTENT_TYPE_BUILDING_BLOCK_V2), + )) } func (c *MeshStackProviderClient) DeleteBuildingBlockV2(uuid string) error { targetUrl := c.urlForBuildingBlock(uuid) - return c.deleteMeshObject(*targetUrl, 202) + return c.deleteMeshObject(targetUrl, 202) } // PollBuildingBlockV2UntilCompletion polls a building block until it reaches a terminal state (SUCCEEDED or FAILED) diff --git a/client.go b/client.go index 946b83fa..8ac114fa 100644 --- a/client.go +++ b/client.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "net/url" + "slices" "time" ) @@ -147,19 +148,16 @@ func (c *MeshStackProviderClient) ensureValidToken() error { type doRequestOption func(opts *doRequestOptions) +type requestModifier func(req *http.Request) + type responseVerifier func(res *http.Response, body []byte) error type doRequestOptions struct { + requestPayload any + requestModifiers []requestModifier responseVerifier responseVerifier } -func ensureSuccessfulRequest(res *http.Response, body []byte) error { - if res.StatusCode >= 200 && res.StatusCode <= 299 { - return nil - } - return handleErrWithNotFound(fmt.Errorf("request failed with status %d (not 2XX successful)", res.StatusCode), res.StatusCode, body) -} - func withExpectedStatusCode(statusCode int) doRequestOption { return func(opts *doRequestOptions) { opts.responseVerifier = func(res *http.Response, body []byte) error { @@ -171,6 +169,38 @@ func withExpectedStatusCode(statusCode int) doRequestOption { } } +func withAccept(accept string) doRequestOption { + return withHeader("Accept", accept) +} + +func withHeader(key, value string) doRequestOption { + return func(opts *doRequestOptions) { + opts.requestModifiers = append(opts.requestModifiers, func(req *http.Request) { + req.Header.Set(key, value) + }) + } +} + +func withPayload(payload any, contentType string) doRequestOption { + return func(opts *doRequestOptions) { + // always provide Accept header with the same value as content-type, + // as meshObject API currently does not version that differently. + // that convention can still be overridden/broken by a later withAccept option + withAccept(contentType)(opts) + withHeader("Content-Type", contentType)(opts) + opts.requestPayload = payload + } +} + +func ensureSuccessfulRequest(opts *doRequestOptions) { + opts.responseVerifier = func(res *http.Response, body []byte) error { + if res.StatusCode >= 200 && res.StatusCode <= 299 { + return nil + } + return handleErrWithNotFound(fmt.Errorf("request failed with status %d (not 2XX successful)", res.StatusCode), res.StatusCode, body) + } +} + func handleErrWithNotFound(err error, statusCode int, body []byte) error { errs := []error{err, fmt.Errorf("error body: %s", string(body))} if statusCode == http.StatusNotFound { @@ -179,22 +209,34 @@ func handleErrWithNotFound(err error, statusCode int, body []byte) error { return errors.Join(errs...) } -func (c *MeshStackProviderClient) doAuthenticatedRequest(req *http.Request, options ...doRequestOption) ([]byte, error) { - opts := doRequestOptions{ +func (c *MeshStackProviderClient) doAuthenticatedRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { + // prepend (aka insert at 0) some default options such that given options may be overridden by caller + options = slices.Insert(options, 0, + withHeader("User-Agent", "meshStack Terraform Provider"), // by default, verify successful response // can be made more specific with withExpectedStatusCode option - responseVerifier: ensureSuccessfulRequest, - } + ensureSuccessfulRequest, + ) + opts := doRequestOptions{} for _, option := range options { option(&opts) } - // ensure that headers are initialized - if req.Header == nil { - req.Header = map[string][]string{} + var requestBody io.ReadWriter + if opts.requestPayload != nil { + requestBody = new(bytes.Buffer) + if err := json.NewEncoder(requestBody).Encode(opts.requestPayload); err != nil { + return nil, fmt.Errorf("failed to encode request body payload: %w", err) + } } - req.Header.Set("User-Agent", "meshStack Terraform Provider") + req, err := http.NewRequest(method, url.String(), requestBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + for _, requestModifier := range opts.requestModifiers { + requestModifier(req) + } // log request before adding auth log.Println(req) @@ -213,22 +255,18 @@ func (c *MeshStackProviderClient) doAuthenticatedRequest(req *http.Request, opti }() log.Println(res) - body, err := io.ReadAll(res.Body) + responseBody, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) } - log.Printf("Got response body with %d bytes", len(body)) - // always return body, even if the response is not successfully verified - // this allows clients to investigate the body even further if desirable. - return body, opts.responseVerifier(res, body) + log.Printf("Got response body with %d bytes", len(responseBody)) + // always return responseBody, even if the response is not successfully verified + // this allows clients to investigate the responseBody even further if desirable. + return responseBody, opts.responseVerifier(res, responseBody) } -func (c *MeshStackProviderClient) deleteMeshObject(targetUrl url.URL, expectedStatus int) (err error) { - req, err := http.NewRequest("DELETE", targetUrl.String(), nil) - if err != nil { - return err - } - _, err = c.doAuthenticatedRequest(req, withExpectedStatusCode(expectedStatus)) +func (c *MeshStackProviderClient) deleteMeshObject(targetUrl *url.URL, expectedStatus int) (err error) { + _, err = c.doAuthenticatedRequest("DELETE", targetUrl, withExpectedStatusCode(expectedStatus)) return } @@ -261,7 +299,7 @@ type paginatedResponse[T any] struct { } `json:"page"` } -// unmarshalPaginatedBody unmarshals a paginated HAL response and extracts items using the provided key +// unmarshalPaginatedBody unmarshalls a paginated HAL response and extracts items using the provided key func unmarshalPaginatedBody[T any](body []byte, err error, embeddedKey string) ([]T, *paginatedResponse[T], error) { if err != nil { return nil, nil, err diff --git a/integrations.go b/integrations.go index 06a60141..a4950461 100644 --- a/integrations.go +++ b/integrations.go @@ -2,7 +2,6 @@ package client import ( "fmt" - "net/http" "net/url" ) @@ -92,14 +91,9 @@ func (c *MeshStackProviderClient) urlForIntegration(workspace string, uuid strin } func (c *MeshStackProviderClient) ReadIntegration(workspace string, uuid string) (*MeshIntegration, error) { - targetUrl := c.urlForIntegration(workspace, uuid) - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_INTEGRATION) - - return unmarshalBodyIfPresent[MeshIntegration](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshIntegration](c.doAuthenticatedRequest("GET", c.urlForIntegration(workspace, uuid), + withAccept(CONTENT_TYPE_INTEGRATION), + )) } func (c *MeshStackProviderClient) ReadIntegrations() (*[]MeshIntegration, error) { @@ -113,14 +107,9 @@ func (c *MeshStackProviderClient) ReadIntegrations() (*[]MeshIntegration, error) query.Set("page", fmt.Sprintf("%d", pageNumber)) targetUrl.RawQuery = query.Encode() - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", CONTENT_TYPE_INTEGRATION) - - body, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest("GET", targetUrl, + withAccept(CONTENT_TYPE_INTEGRATION), + ) items, response, err := unmarshalPaginatedBody[MeshIntegration](body, err, "meshIntegrations") if err != nil { return nil, err diff --git a/landingzone.go b/landingzone.go index 97a394bc..a84d1c83 100644 --- a/landingzone.go +++ b/landingzone.go @@ -1,9 +1,6 @@ package client import ( - "bytes" - "encoding/json" - "net/http" "net/url" ) @@ -73,51 +70,24 @@ func (c *MeshStackProviderClient) urlForLandingZone(name string) *url.URL { } func (c *MeshStackProviderClient) ReadLandingZone(name string) (*MeshLandingZone, error) { - targetUrl := c.urlForLandingZone(name) - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) - - return unmarshalBodyIfPresent[MeshLandingZone](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshLandingZone](c.doAuthenticatedRequest("GET", c.urlForLandingZone(name), + withAccept(CONTENT_TYPE_LANDINGZONE), + )) } func (c *MeshStackProviderClient) CreateLandingZone(landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { - payload, err := json.Marshal(landingZone) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.LandingZones.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_LANDINGZONE) - req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) - - return unmarshalBody[MeshLandingZone](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshLandingZone](c.doAuthenticatedRequest("POST", c.endpoints.LandingZones, + withPayload(landingZone, CONTENT_TYPE_LANDINGZONE), + )) } func (c *MeshStackProviderClient) UpdateLandingZone(name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { - targetUrl := c.urlForLandingZone(name) - - payload, err := json.Marshal(landingZone) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_LANDINGZONE) - req.Header.Set("Accept", CONTENT_TYPE_LANDINGZONE) - - return unmarshalBody[MeshLandingZone](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshLandingZone](c.doAuthenticatedRequest("PUT", c.urlForLandingZone(name), + withPayload(landingZone, CONTENT_TYPE_LANDINGZONE), + )) } func (c *MeshStackProviderClient) DeleteLandingZone(name string) error { targetUrl := c.urlForLandingZone(name) - return c.deleteMeshObject(*targetUrl, 204) + return c.deleteMeshObject(targetUrl, 204) } diff --git a/location.go b/location.go index 4ff0d0d3..c82fc02d 100644 --- a/location.go +++ b/location.go @@ -1,9 +1,6 @@ package client import ( - "bytes" - "encoding/json" - "net/http" "net/url" ) @@ -45,52 +42,24 @@ func (c *MeshStackProviderClient) urlForLocation(name string) *url.URL { } func (c *MeshStackProviderClient) ReadLocation(name string) (*MeshLocation, error) { - targetUrl := c.urlForLocation(name) - - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_LOCATION) - - return unmarshalBodyIfPresent[MeshLocation](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshLocation](c.doAuthenticatedRequest("GET", c.urlForLocation(name), + withAccept(CONTENT_TYPE_LOCATION), + )) } func (c *MeshStackProviderClient) CreateLocation(location *MeshLocationCreate) (*MeshLocation, error) { - payload, err := json.Marshal(location) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.Locations.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_LOCATION) - req.Header.Set("Accept", CONTENT_TYPE_LOCATION) - - return unmarshalBody[MeshLocation](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshLocation](c.doAuthenticatedRequest("POST", c.endpoints.Locations, + withPayload(location, CONTENT_TYPE_LOCATION), + )) } func (c *MeshStackProviderClient) UpdateLocation(name string, location *MeshLocationCreate) (*MeshLocation, error) { - targetUrl := c.urlForLocation(name) - - payload, err := json.Marshal(location) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_LOCATION) - req.Header.Set("Accept", CONTENT_TYPE_LOCATION) - - return unmarshalBody[MeshLocation](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshLocation](c.doAuthenticatedRequest("PUT", c.urlForLocation(name), + withPayload(location, CONTENT_TYPE_LOCATION), + )) } func (c *MeshStackProviderClient) DeleteLocation(name string) error { targetUrl := c.urlForLocation(name) - return c.deleteMeshObject(*targetUrl, 204) + return c.deleteMeshObject(targetUrl, 204) } diff --git a/payment_method.go b/payment_method.go index f62651d5..71450792 100644 --- a/payment_method.go +++ b/payment_method.go @@ -1,9 +1,6 @@ package client import ( - "bytes" - "encoding/json" - "net/http" "net/url" ) @@ -46,52 +43,24 @@ func (c *MeshStackProviderClient) urlForPaymentMethod(identifier string) *url.UR } func (c *MeshStackProviderClient) ReadPaymentMethod(workspace string, identifier string) (*MeshPaymentMethod, error) { - targetUrl := c.urlForPaymentMethod(identifier) - - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) - - return unmarshalBodyIfPresent[MeshPaymentMethod](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshPaymentMethod](c.doAuthenticatedRequest("GET", c.urlForPaymentMethod(identifier), + withAccept(CONTENT_TYPE_PAYMENT_METHOD), + )) } func (c *MeshStackProviderClient) CreatePaymentMethod(paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { - payload, err := json.Marshal(paymentMethod) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.PaymentMethods.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_PAYMENT_METHOD) - req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) - - return unmarshalBody[MeshPaymentMethod](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshPaymentMethod](c.doAuthenticatedRequest("POST", c.endpoints.PaymentMethods, + withPayload(paymentMethod, CONTENT_TYPE_PAYMENT_METHOD), + )) } func (c *MeshStackProviderClient) UpdatePaymentMethod(identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { - targetUrl := c.urlForPaymentMethod(identifier) - - payload, err := json.Marshal(paymentMethod) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_PAYMENT_METHOD) - req.Header.Set("Accept", CONTENT_TYPE_PAYMENT_METHOD) - - return unmarshalBody[MeshPaymentMethod](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshPaymentMethod](c.doAuthenticatedRequest("PUT", c.urlForPaymentMethod(identifier), + withPayload(paymentMethod, CONTENT_TYPE_PAYMENT_METHOD), + )) } func (c *MeshStackProviderClient) DeletePaymentMethod(identifier string) error { targetUrl := c.urlForPaymentMethod(identifier) - return c.deleteMeshObject(*targetUrl, 204) + return c.deleteMeshObject(targetUrl, 204) } diff --git a/platform.go b/platform.go index 609f8cfd..58e32192 100644 --- a/platform.go +++ b/platform.go @@ -1,9 +1,6 @@ package client import ( - "bytes" - "encoding/json" - "net/http" "net/url" ) @@ -117,51 +114,24 @@ func (c *MeshStackProviderClient) urlForPlatform(uuid string) *url.URL { } func (c *MeshStackProviderClient) ReadPlatform(uuid string) (*MeshPlatform, error) { - targetUrl := c.urlForPlatform(uuid) - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) - - return unmarshalBodyIfPresent[MeshPlatform](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshPlatform](c.doAuthenticatedRequest("GET", c.urlForPlatform(uuid), + withAccept(CONTENT_TYPE_PLATFORM), + )) } func (c *MeshStackProviderClient) CreatePlatform(platform *MeshPlatformCreate) (*MeshPlatform, error) { - payload, err := json.Marshal(platform) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.Platforms.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_PLATFORM) - req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) - - return unmarshalBody[MeshPlatform](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshPlatform](c.doAuthenticatedRequest("POST", c.endpoints.Platforms, + withPayload(platform, CONTENT_TYPE_PLATFORM), + )) } func (c *MeshStackProviderClient) DeletePlatform(uuid string) error { targetUrl := c.urlForPlatform(uuid) - return c.deleteMeshObject(*targetUrl, 204) + return c.deleteMeshObject(targetUrl, 204) } func (c *MeshStackProviderClient) UpdatePlatform(uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { - targetUrl := c.urlForPlatform(uuid) - - payload, err := json.Marshal(platform) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_PLATFORM) - req.Header.Set("Accept", CONTENT_TYPE_PLATFORM) - - return unmarshalBody[MeshPlatform](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshPlatform](c.doAuthenticatedRequest("PUT", c.urlForPlatform(uuid), + withPayload(platform, CONTENT_TYPE_PLATFORM), + )) } diff --git a/project.go b/project.go index f18638cf..0ddec958 100644 --- a/project.go +++ b/project.go @@ -1,10 +1,7 @@ package client import ( - "bytes" - "encoding/json" "fmt" - "net/http" "net/url" ) @@ -47,14 +44,9 @@ func (c *MeshStackProviderClient) urlForProject(workspace string, name string) * } func (c *MeshStackProviderClient) ReadProject(workspace string, name string) (*MeshProject, error) { - targetUrl := c.urlForProject(workspace, name) - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - - return unmarshalBodyIfPresent[MeshProject](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshProject](c.doAuthenticatedRequest("GET", c.urlForProject(workspace, name), + withAccept(CONTENT_TYPE_PROJECT), + )) } func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, paymentMethodIdentifier *string) (*[]MeshProject, error) { @@ -72,14 +64,9 @@ func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, payme query.Set("page", fmt.Sprintf("%d", pageNumber)) targetUrl.RawQuery = query.Encode() - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - - body, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest("GET", targetUrl, + withAccept(CONTENT_TYPE_PROJECT), + ) items, response, err := unmarshalPaginatedBody[MeshProject](body, err, "meshProjects") if err != nil { return nil, err @@ -99,40 +86,18 @@ func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, payme } func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*MeshProject, error) { - payload, err := json.Marshal(project) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.Projects.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT) - req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - - return unmarshalBody[MeshProject](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshProject](c.doAuthenticatedRequest("POST", c.endpoints.Projects, + withPayload(project, CONTENT_TYPE_PROJECT), + )) } func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*MeshProject, error) { - targetUrl := c.urlForProject(project.Metadata.OwnedByWorkspace, project.Metadata.Name) - - payload, err := json.Marshal(project) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_PROJECT) - req.Header.Set("Accept", CONTENT_TYPE_PROJECT) - - return unmarshalBody[MeshProject](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshProject](c.doAuthenticatedRequest("PUT", c.urlForProject(project.Metadata.OwnedByWorkspace, project.Metadata.Name), + withPayload(project, CONTENT_TYPE_PROJECT), + )) } func (c *MeshStackProviderClient) DeleteProject(workspace string, name string) error { targetUrl := c.urlForProject(workspace, name) - return c.deleteMeshObject(*targetUrl, 202) + return c.deleteMeshObject(targetUrl, 202) } diff --git a/project_binding.go b/project_binding.go index e4c01d90..4c924eb7 100644 --- a/project_binding.go +++ b/project_binding.go @@ -1,10 +1,7 @@ package client import ( - "bytes" - "encoding/json" "fmt" - "net/http" "net/url" ) @@ -54,13 +51,9 @@ func (c *MeshStackProviderClient) readProjectBinding(name string, contentType st return nil, fmt.Errorf("unexpected content type '%s'", contentType) } - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", contentType) - - return unmarshalBodyIfPresent[MeshProjectBinding](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshProjectBinding](c.doAuthenticatedRequest("GET", targetUrl, + withAccept(contentType), + )) } func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBinding, contentType string) (*MeshProjectBinding, error) { @@ -76,17 +69,7 @@ func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBindi return nil, fmt.Errorf("unexpected content type '%s'", contentType) } - payload, err := json.Marshal(binding) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", targetUrl.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", contentType) - req.Header.Set("Accept", contentType) - - return unmarshalBody[MeshProjectBinding](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshProjectBinding](c.doAuthenticatedRequest("POST", targetUrl, + withPayload(binding, contentType), + )) } diff --git a/project_group_binding.go b/project_group_binding.go index 8b66818c..7044724b 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -22,5 +22,5 @@ func (c *MeshStackProviderClient) CreateProjectGroupBinding(binding *MeshProject func (c *MeshStackProviderClient) DeleteProjecGroupBinding(name string) error { targetUrl := c.urlForPojectGroupBinding(name) - return c.deleteMeshObject(*targetUrl, 204) + return c.deleteMeshObject(targetUrl, 204) } diff --git a/project_user_binding.go b/project_user_binding.go index 3de8e225..bfa689d0 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -22,5 +22,5 @@ func (c *MeshStackProviderClient) CreateProjectUserBinding(binding *MeshProjectU func (c *MeshStackProviderClient) DeleteProjecUserBinding(name string) error { targetUrl := c.urlForPojectUserBinding(name) - return c.deleteMeshObject(*targetUrl, 204) + return c.deleteMeshObject(targetUrl, 204) } diff --git a/tag_definition.go b/tag_definition.go index 386e1a53..469faca1 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -1,10 +1,7 @@ package client import ( - "bytes" - "encoding/json" "fmt" - "net/http" "net/url" ) @@ -87,14 +84,9 @@ func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, er query.Set("page", fmt.Sprintf("%d", pageNumber)) targetUrl.RawQuery = query.Encode() - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - - body, err := c.doAuthenticatedRequest(req) + body, err := c.doAuthenticatedRequest("GET", targetUrl, + withAccept(CONTENT_TYPE_TAG_DEFINITION), + ) items, response, err := unmarshalPaginatedBody[MeshTagDefinition](body, err, "meshTagDefinitions") if err != nil { return nil, err @@ -114,35 +106,15 @@ func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, er } func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefinition, error) { - targetUrl := c.urlForTagDefinition(name) - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - - return unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("GET", c.urlForTagDefinition(name), + withAccept(CONTENT_TYPE_TAG_DEFINITION), + )) } func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - targetUrl := c.endpoints.TagDefinitions - data, err := json.Marshal(tagDefinition) - if err != nil { - return nil, fmt.Errorf("failed to marshal tag definition: %w", err) - } - - fmt.Printf("JSON Payload: %s\n", string(data)) - - req, err := http.NewRequest("POST", targetUrl.String(), bytes.NewBuffer(data)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", CONTENT_TYPE_TAG_DEFINITION) - req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - - result, err := unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest(req)) + result, err := unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("POST", c.endpoints.TagDefinitions, + withPayload(tagDefinition, CONTENT_TYPE_TAG_DEFINITION), + )) if err != nil { return nil, fmt.Errorf("failed to do authenticated request: %w", err) } @@ -150,21 +122,9 @@ func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefi } func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - targetUrl := c.urlForTagDefinition(tagDefinition.Metadata.Name) - data, err := json.Marshal(tagDefinition) - if err != nil { - return nil, fmt.Errorf("failed to marshal tag definition: %w", err) - } - - req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(data)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", CONTENT_TYPE_TAG_DEFINITION) - req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - - result, err := unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest(req)) + result, err := unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("PUT", c.urlForTagDefinition(tagDefinition.Metadata.Name), + withPayload(tagDefinition, CONTENT_TYPE_TAG_DEFINITION), + )) if err != nil { return nil, fmt.Errorf("failed to do authenticated request: %w", err) } @@ -172,15 +132,10 @@ func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefi } func (c *MeshStackProviderClient) DeleteTagDefinition(name string) error { - targetUrl := c.urlForTagDefinition(name) - req, err := http.NewRequest("DELETE", targetUrl.String(), nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Accept", CONTENT_TYPE_TAG_DEFINITION) - - _, err = c.doAuthenticatedRequest(req, withExpectedStatusCode(http.StatusNoContent)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForTagDefinition(name), + withAccept(CONTENT_TYPE_TAG_DEFINITION), + withExpectedStatusCode(204), + ) if err != nil { return fmt.Errorf("failed to do authenticated request: %w", err) } diff --git a/tenant.go b/tenant.go index 43f682ff..b43c9029 100644 --- a/tenant.go +++ b/tenant.go @@ -1,9 +1,6 @@ package client import ( - "bytes" - "encoding/json" - "net/http" "net/url" ) @@ -58,33 +55,18 @@ func (c *MeshStackProviderClient) urlForTenant(workspace string, project string, } func (c *MeshStackProviderClient) ReadTenant(workspace string, project string, platform string) (*MeshTenant, error) { - targetUrl := c.urlForTenant(workspace, project, platform) - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_TENANT) - - return unmarshalBodyIfPresent[MeshTenant](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshTenant](c.doAuthenticatedRequest("GET", c.urlForTenant(workspace, project, platform), + withAccept(CONTENT_TYPE_TENANT), + )) } func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshTenant, error) { - payload, err := json.Marshal(tenant) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.Tenants.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_TENANT) - req.Header.Set("Accept", CONTENT_TYPE_TENANT) - - return unmarshalBody[MeshTenant](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshTenant](c.doAuthenticatedRequest("POST", c.endpoints.Tenants, + withPayload(tenant, CONTENT_TYPE_TENANT), + )) } func (c *MeshStackProviderClient) DeleteTenant(workspace string, project string, platform string) error { targetUrl := c.urlForTenant(workspace, project, platform) - return c.deleteMeshObject(*targetUrl, 202) + return c.deleteMeshObject(targetUrl, 202) } diff --git a/tenant_v4.go b/tenant_v4.go index 26c89009..d9dd9098 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -1,11 +1,8 @@ package client import ( - "bytes" "context" - "encoding/json" "fmt" - "net/http" "net/url" "time" @@ -67,35 +64,20 @@ func (c *MeshStackProviderClient) urlForTenantV4(uuid string) *url.URL { } func (c *MeshStackProviderClient) ReadTenantV4(uuid string) (*MeshTenantV4, error) { - targetUrl := c.urlForTenantV4(uuid) - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_TENANT_V4) - - return unmarshalBodyIfPresent[MeshTenantV4](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshTenantV4](c.doAuthenticatedRequest("GET", c.urlForTenantV4(uuid), + withAccept(CONTENT_TYPE_TENANT_V4), + )) } func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantV4Create) (*MeshTenantV4, error) { - payload, err := json.Marshal(tenant) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.Tenants.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_TENANT_V4) - req.Header.Set("Accept", CONTENT_TYPE_TENANT_V4) - - return unmarshalBody[MeshTenantV4](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshTenantV4](c.doAuthenticatedRequest("POST", c.endpoints.Tenants, + withPayload(tenant, CONTENT_TYPE_TENANT_V4), + )) } func (c *MeshStackProviderClient) DeleteTenantV4(uuid string) error { targetUrl := c.urlForTenantV4(uuid) - return c.deleteMeshObject(*targetUrl, 202) + return c.deleteMeshObject(targetUrl, 202) } // PollTenantV4UntilCreation polls a tenant until creation completes (platformTenantId is set) diff --git a/workspace.go b/workspace.go index 83d1eab9..10e72a20 100644 --- a/workspace.go +++ b/workspace.go @@ -1,9 +1,6 @@ package client import ( - "bytes" - "encoding/json" - "net/http" "net/url" ) @@ -43,51 +40,24 @@ func (c *MeshStackProviderClient) urlForWorkspace(name string) *url.URL { } func (c *MeshStackProviderClient) ReadWorkspace(name string) (*MeshWorkspace, error) { - targetUrl := c.urlForWorkspace(name) - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) - - return unmarshalBodyIfPresent[MeshWorkspace](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshWorkspace](c.doAuthenticatedRequest("GET", c.urlForWorkspace(name), + withAccept(CONTENT_TYPE_WORKSPACE), + )) } func (c *MeshStackProviderClient) CreateWorkspace(workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { - payload, err := json.Marshal(workspace) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", c.endpoints.Workspaces.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_WORKSPACE) - req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) - - return unmarshalBody[MeshWorkspace](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshWorkspace](c.doAuthenticatedRequest("POST", c.endpoints.Workspaces, + withPayload(workspace, CONTENT_TYPE_WORKSPACE), + )) } func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { - targetUrl := c.urlForWorkspace(name) - - payload, err := json.Marshal(workspace) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("PUT", targetUrl.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", CONTENT_TYPE_WORKSPACE) - req.Header.Set("Accept", CONTENT_TYPE_WORKSPACE) - - return unmarshalBody[MeshWorkspace](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshWorkspace](c.doAuthenticatedRequest("PUT", c.urlForWorkspace(name), + withPayload(workspace, CONTENT_TYPE_WORKSPACE), + )) } func (c *MeshStackProviderClient) DeleteWorkspace(name string) error { targetUrl := c.urlForWorkspace(name) - return c.deleteMeshObject(*targetUrl, 204) + return c.deleteMeshObject(targetUrl, 204) } diff --git a/workspace_binding.go b/workspace_binding.go index 326860d9..17355fd0 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -1,10 +1,7 @@ package client import ( - "bytes" - "encoding/json" "fmt" - "net/http" "net/url" ) @@ -46,13 +43,9 @@ func (c *MeshStackProviderClient) readWorkspaceBinding(name string, contentType return nil, fmt.Errorf("unexpected content type '%s'", contentType) } - req, err := http.NewRequest("GET", targetUrl.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", contentType) - - return unmarshalBodyIfPresent[MeshWorkspaceBinding](c.doAuthenticatedRequest(req)) + return unmarshalBodyIfPresent[MeshWorkspaceBinding](c.doAuthenticatedRequest("GET", targetUrl, + withAccept(contentType), + )) } func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceBinding, contentType string) (*MeshWorkspaceBinding, error) { @@ -66,17 +59,7 @@ func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceB return nil, fmt.Errorf("unexpected content type '%s'", contentType) } - payload, err := json.Marshal(binding) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", targetUrl.String(), bytes.NewBuffer(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", contentType) - req.Header.Set("Accept", contentType) - - return unmarshalBody[MeshWorkspaceBinding](c.doAuthenticatedRequest(req)) + return unmarshalBody[MeshWorkspaceBinding](c.doAuthenticatedRequest("POST", targetUrl, + withPayload(binding, contentType), + )) } diff --git a/workspace_group_binding.go b/workspace_group_binding.go index a32a5827..1844cd9f 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -22,5 +22,5 @@ func (c *MeshStackProviderClient) CreateWorkspaceGroupBinding(binding *MeshWorks func (c *MeshStackProviderClient) DeleteWorkspaceGroupBinding(name string) error { targetUrl := c.urlForWorkspaceGroupBinding(name) - return c.deleteMeshObject(*targetUrl, 204) + return c.deleteMeshObject(targetUrl, 204) } diff --git a/workspace_user_binding.go b/workspace_user_binding.go index 3019df81..28f6a210 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -22,5 +22,5 @@ func (c *MeshStackProviderClient) CreateWorkspaceUserBinding(binding *MeshWorksp func (c *MeshStackProviderClient) DeleteWorkspaceUserBinding(name string) error { targetUrl := c.urlForWorkspaceUserBinding(name) - return c.deleteMeshObject(*targetUrl, 204) + return c.deleteMeshObject(targetUrl, 204) } From 63a8c50e5ff25e140557100383d22e97692a2a31 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 15:21:07 +0100 Subject: [PATCH 068/215] refactor: remove deleteMeshObject and overeager status code validation, remove responseVerifier --- buildingblock.go | 4 +- buildingblock_v2.go | 4 +- client.go | 141 ++++++++++++------------------------- landingzone.go | 4 +- location.go | 4 +- payment_method.go | 4 +- platform.go | 4 +- project.go | 4 +- project_group_binding.go | 4 +- project_user_binding.go | 4 +- tag_definition.go | 19 +---- tenant.go | 4 +- tenant_v4.go | 4 +- workspace.go | 4 +- workspace_group_binding.go | 4 +- workspace_user_binding.go | 4 +- 16 files changed, 75 insertions(+), 141 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index bd13fe75..430cfa19 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -93,6 +93,6 @@ func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreat } func (c *MeshStackProviderClient) DeleteBuildingBlock(uuid string) error { - targetUrl := c.urlForBuildingBlock(uuid) - return c.deleteMeshObject(targetUrl, 202) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForBuildingBlock(uuid)) + return err } diff --git a/buildingblock_v2.go b/buildingblock_v2.go index fa60a988..77d72d91 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -80,8 +80,8 @@ func (c *MeshStackProviderClient) CreateBuildingBlockV2(bb *MeshBuildingBlockV2C } func (c *MeshStackProviderClient) DeleteBuildingBlockV2(uuid string) error { - targetUrl := c.urlForBuildingBlock(uuid) - return c.deleteMeshObject(targetUrl, 202) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForBuildingBlock(uuid)) + return err } // PollBuildingBlockV2UntilCompletion polls a building block until it reaches a terminal state (SUCCEEDED or FAILED) diff --git a/client.go b/client.go index 8ac114fa..771f0abc 100644 --- a/client.go +++ b/client.go @@ -13,11 +13,6 @@ import ( "time" ) -const ( - apiMeshObjectsRoot = "/api/meshobjects" - loginEndpoint = "/api/login" -) - var ( errNotFound = errors.New("request failed with status Not Found (404)") ) @@ -49,16 +44,6 @@ type endpoints struct { Locations *url.URL `json:"meshlocations"` } -type loginRequest struct { - ClientId string `json:"clientId"` - ClientSecret string `json:"clientSecret"` -} - -type loginResponse struct { - Token string `json:"access_token"` - ExpireSec int `json:"expires_in"` -} - func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackProviderClient, error) { client := &MeshStackProviderClient{ url: rootUrl, @@ -71,6 +56,9 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro } // TODO: lookup endpoints + const ( + apiMeshObjectsRoot = "/api/meshobjects" + ) client.endpoints = endpoints{ BuildingBlocks: rootUrl.JoinPath(apiMeshObjectsRoot, "meshbuildingblocks"), Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), @@ -92,50 +80,27 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackPro } func (c *MeshStackProviderClient) login() error { - loginPath, err := url.JoinPath(c.url.String(), loginEndpoint) - if err != nil { - return err - } + loginUrl := c.url.JoinPath("/api/login") - loginRequest := loginRequest{ - ClientId: c.apiKey, - ClientSecret: c.apiSecret, + type loginRequest struct { + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` } - payload, err := json.Marshal(loginRequest) - if err != nil { - return err + type loginResponse struct { + Token string `json:"access_token"` + ExpireSec int `json:"expires_in"` } - req, _ := http.NewRequest(http.MethodPost, loginPath, bytes.NewBuffer(payload)) - req.Header.Add("Content-Type", "application/json") - - res, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer func() { - _ = res.Body.Close() - }() - - if res.StatusCode != 200 { - return fmt.Errorf("login failed with status %d, check api key and secret", res.StatusCode) - } - - data, err := io.ReadAll(res.Body) - if err != nil { - return err - } - - var loginResult loginResponse - err = json.Unmarshal(data, &loginResult) + loginResult, err := unmarshalBody[loginResponse](c.doRequest("POST", loginUrl, + withPayload(loginRequest{ClientId: c.apiKey, ClientSecret: c.apiSecret}, "application/json")), + ) if err != nil { - return err + return fmt.Errorf("login request to %s with API Key '%s' failed: %w", loginUrl, c.apiKey, err) } c.token = fmt.Sprintf("Bearer %s", loginResult.Token) c.tokenExpiry = time.Now().Add(time.Second * time.Duration(loginResult.ExpireSec)) - return nil } @@ -150,22 +115,14 @@ type doRequestOption func(opts *doRequestOptions) type requestModifier func(req *http.Request) -type responseVerifier func(res *http.Response, body []byte) error - type doRequestOptions struct { requestPayload any requestModifiers []requestModifier - responseVerifier responseVerifier } -func withExpectedStatusCode(statusCode int) doRequestOption { +func appendRequestModifier(modifier requestModifier) doRequestOption { return func(opts *doRequestOptions) { - opts.responseVerifier = func(res *http.Response, body []byte) error { - if res.StatusCode == statusCode { - return nil - } - return handleErrWithNotFound(fmt.Errorf("expected status %d, but got %d", statusCode, res.StatusCode), res.StatusCode, body) - } + opts.requestModifiers = append(opts.requestModifiers, modifier) } } @@ -174,11 +131,9 @@ func withAccept(accept string) doRequestOption { } func withHeader(key, value string) doRequestOption { - return func(opts *doRequestOptions) { - opts.requestModifiers = append(opts.requestModifiers, func(req *http.Request) { - req.Header.Set(key, value) - }) - } + return appendRequestModifier(func(req *http.Request) { + req.Header.Set(key, value) + }) } func withPayload(payload any, contentType string) doRequestOption { @@ -192,30 +147,10 @@ func withPayload(payload any, contentType string) doRequestOption { } } -func ensureSuccessfulRequest(opts *doRequestOptions) { - opts.responseVerifier = func(res *http.Response, body []byte) error { - if res.StatusCode >= 200 && res.StatusCode <= 299 { - return nil - } - return handleErrWithNotFound(fmt.Errorf("request failed with status %d (not 2XX successful)", res.StatusCode), res.StatusCode, body) - } -} - -func handleErrWithNotFound(err error, statusCode int, body []byte) error { - errs := []error{err, fmt.Errorf("error body: %s", string(body))} - if statusCode == http.StatusNotFound { - errs = append([]error{errNotFound}, errs...) - } - return errors.Join(errs...) -} - -func (c *MeshStackProviderClient) doAuthenticatedRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { +func (c *MeshStackProviderClient) doRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { // prepend (aka insert at 0) some default options such that given options may be overridden by caller options = slices.Insert(options, 0, withHeader("User-Agent", "meshStack Terraform Provider"), - // by default, verify successful response - // can be made more specific with withExpectedStatusCode option - ensureSuccessfulRequest, ) opts := doRequestOptions{} for _, option := range options { @@ -237,14 +172,6 @@ func (c *MeshStackProviderClient) doAuthenticatedRequest(method string, url *url for _, requestModifier := range opts.requestModifiers { requestModifier(req) } - // log request before adding auth - log.Println(req) - - // add authentication - if err := c.ensureValidToken(); err != nil { - return nil, err - } - req.Header.Set("Authorization", c.token) res, err := c.httpClient.Do(req) if err != nil { @@ -260,14 +187,34 @@ func (c *MeshStackProviderClient) doAuthenticatedRequest(method string, url *url return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) } log.Printf("Got response body with %d bytes", len(responseBody)) + + if res.StatusCode >= 200 && res.StatusCode <= 299 { + return responseBody, nil + } + var errs []error + if res.StatusCode == http.StatusNotFound { + errs = append(errs, errNotFound) + } + errs = append(errs, + fmt.Errorf("request failed with status %d (not 2XX successful)", res.StatusCode), + fmt.Errorf("error response: %s", string(responseBody)), + ) // always return responseBody, even if the response is not successfully verified // this allows clients to investigate the responseBody even further if desirable. - return responseBody, opts.responseVerifier(res, responseBody) + return responseBody, errors.Join(errs...) } -func (c *MeshStackProviderClient) deleteMeshObject(targetUrl *url.URL, expectedStatus int) (err error) { - _, err = c.doAuthenticatedRequest("DELETE", targetUrl, withExpectedStatusCode(expectedStatus)) - return +func (c *MeshStackProviderClient) doAuthenticatedRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { + if err := c.ensureValidToken(); err != nil { + return nil, err + } + return c.doRequest(method, url, append(options, + appendRequestModifier(func(req *http.Request) { + // log request before adding Authorization header below + log.Println(req) + }), + withHeader("Authorization", c.token), + )...) } func unmarshalBody[T any](body []byte, err error) (*T, error) { diff --git a/landingzone.go b/landingzone.go index a84d1c83..c643199b 100644 --- a/landingzone.go +++ b/landingzone.go @@ -88,6 +88,6 @@ func (c *MeshStackProviderClient) UpdateLandingZone(name string, landingZone *Me } func (c *MeshStackProviderClient) DeleteLandingZone(name string) error { - targetUrl := c.urlForLandingZone(name) - return c.deleteMeshObject(targetUrl, 204) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForLandingZone(name)) + return err } diff --git a/location.go b/location.go index c82fc02d..aa82a1ee 100644 --- a/location.go +++ b/location.go @@ -60,6 +60,6 @@ func (c *MeshStackProviderClient) UpdateLocation(name string, location *MeshLoca } func (c *MeshStackProviderClient) DeleteLocation(name string) error { - targetUrl := c.urlForLocation(name) - return c.deleteMeshObject(targetUrl, 204) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForLocation(name)) + return err } diff --git a/payment_method.go b/payment_method.go index 71450792..11b2c279 100644 --- a/payment_method.go +++ b/payment_method.go @@ -61,6 +61,6 @@ func (c *MeshStackProviderClient) UpdatePaymentMethod(identifier string, payment } func (c *MeshStackProviderClient) DeletePaymentMethod(identifier string) error { - targetUrl := c.urlForPaymentMethod(identifier) - return c.deleteMeshObject(targetUrl, 204) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForPaymentMethod(identifier)) + return err } diff --git a/platform.go b/platform.go index 58e32192..c6e2a70d 100644 --- a/platform.go +++ b/platform.go @@ -126,8 +126,8 @@ func (c *MeshStackProviderClient) CreatePlatform(platform *MeshPlatformCreate) ( } func (c *MeshStackProviderClient) DeletePlatform(uuid string) error { - targetUrl := c.urlForPlatform(uuid) - return c.deleteMeshObject(targetUrl, 204) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForPlatform(uuid)) + return err } func (c *MeshStackProviderClient) UpdatePlatform(uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { diff --git a/project.go b/project.go index 0ddec958..c8a8dc0e 100644 --- a/project.go +++ b/project.go @@ -98,6 +98,6 @@ func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*Me } func (c *MeshStackProviderClient) DeleteProject(workspace string, name string) error { - targetUrl := c.urlForProject(workspace, name) - return c.deleteMeshObject(targetUrl, 202) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForProject(workspace, name)) + return err } diff --git a/project_group_binding.go b/project_group_binding.go index 7044724b..d815b17d 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -21,6 +21,6 @@ func (c *MeshStackProviderClient) CreateProjectGroupBinding(binding *MeshProject } func (c *MeshStackProviderClient) DeleteProjecGroupBinding(name string) error { - targetUrl := c.urlForPojectGroupBinding(name) - return c.deleteMeshObject(targetUrl, 204) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForPojectGroupBinding(name)) + return err } diff --git a/project_user_binding.go b/project_user_binding.go index bfa689d0..22614e2b 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -21,6 +21,6 @@ func (c *MeshStackProviderClient) CreateProjectUserBinding(binding *MeshProjectU } func (c *MeshStackProviderClient) DeleteProjecUserBinding(name string) error { - targetUrl := c.urlForPojectUserBinding(name) - return c.deleteMeshObject(targetUrl, 204) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForPojectUserBinding(name)) + return err } diff --git a/tag_definition.go b/tag_definition.go index 469faca1..ed341775 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -112,33 +112,20 @@ func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefini } func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - result, err := unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("POST", c.endpoints.TagDefinitions, + return unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("POST", c.endpoints.TagDefinitions, withPayload(tagDefinition, CONTENT_TYPE_TAG_DEFINITION), )) - if err != nil { - return nil, fmt.Errorf("failed to do authenticated request: %w", err) - } - return result, nil } func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - result, err := unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("PUT", c.urlForTagDefinition(tagDefinition.Metadata.Name), + return unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("PUT", c.urlForTagDefinition(tagDefinition.Metadata.Name), withPayload(tagDefinition, CONTENT_TYPE_TAG_DEFINITION), )) - if err != nil { - return nil, fmt.Errorf("failed to do authenticated request: %w", err) - } - return result, nil } func (c *MeshStackProviderClient) DeleteTagDefinition(name string) error { _, err := c.doAuthenticatedRequest("DELETE", c.urlForTagDefinition(name), withAccept(CONTENT_TYPE_TAG_DEFINITION), - withExpectedStatusCode(204), ) - if err != nil { - return fmt.Errorf("failed to do authenticated request: %w", err) - } - - return nil + return err } diff --git a/tenant.go b/tenant.go index b43c9029..7aba3bb3 100644 --- a/tenant.go +++ b/tenant.go @@ -67,6 +67,6 @@ func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshT } func (c *MeshStackProviderClient) DeleteTenant(workspace string, project string, platform string) error { - targetUrl := c.urlForTenant(workspace, project, platform) - return c.deleteMeshObject(targetUrl, 202) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForTenant(workspace, project, platform)) + return err } diff --git a/tenant_v4.go b/tenant_v4.go index d9dd9098..2a771ab2 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -76,8 +76,8 @@ func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantV4Create) (*M } func (c *MeshStackProviderClient) DeleteTenantV4(uuid string) error { - targetUrl := c.urlForTenantV4(uuid) - return c.deleteMeshObject(targetUrl, 202) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForTenantV4(uuid)) + return err } // PollTenantV4UntilCreation polls a tenant until creation completes (platformTenantId is set) diff --git a/workspace.go b/workspace.go index 10e72a20..4020d76f 100644 --- a/workspace.go +++ b/workspace.go @@ -58,6 +58,6 @@ func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWo } func (c *MeshStackProviderClient) DeleteWorkspace(name string) error { - targetUrl := c.urlForWorkspace(name) - return c.deleteMeshObject(targetUrl, 204) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspace(name)) + return err } diff --git a/workspace_group_binding.go b/workspace_group_binding.go index 1844cd9f..7139c260 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -21,6 +21,6 @@ func (c *MeshStackProviderClient) CreateWorkspaceGroupBinding(binding *MeshWorks } func (c *MeshStackProviderClient) DeleteWorkspaceGroupBinding(name string) error { - targetUrl := c.urlForWorkspaceGroupBinding(name) - return c.deleteMeshObject(targetUrl, 204) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspaceGroupBinding(name)) + return err } diff --git a/workspace_user_binding.go b/workspace_user_binding.go index 28f6a210..5ae4ce8f 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -21,6 +21,6 @@ func (c *MeshStackProviderClient) CreateWorkspaceUserBinding(binding *MeshWorksp } func (c *MeshStackProviderClient) DeleteWorkspaceUserBinding(name string) error { - targetUrl := c.urlForWorkspaceUserBinding(name) - return c.deleteMeshObject(targetUrl, 204) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspaceUserBinding(name)) + return err } From 10f6f889e8f3cd58424e3eaecb94fd85025b94d7 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 15:25:40 +0100 Subject: [PATCH 069/215] refactor: simplify client for workspace/project binding --- project_binding.go | 41 -------------------------------------- project_group_binding.go | 8 ++++++-- project_user_binding.go | 8 ++++++-- workspace_binding.go | 39 ------------------------------------ workspace_group_binding.go | 8 ++++++-- workspace_user_binding.go | 8 ++++++-- 6 files changed, 24 insertions(+), 88 deletions(-) diff --git a/project_binding.go b/project_binding.go index 4c924eb7..96e8b69f 100644 --- a/project_binding.go +++ b/project_binding.go @@ -1,10 +1,5 @@ package client -import ( - "fmt" - "net/url" -) - type MeshProjectBinding struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -37,39 +32,3 @@ type MeshProjectTargetRef struct { type MeshSubject struct { Name string `json:"name" tfsdk:"name"` } - -func (c *MeshStackProviderClient) readProjectBinding(name string, contentType string) (*MeshProjectBinding, error) { - var targetUrl *url.URL - switch contentType { - case CONTENT_TYPE_PROJECT_USER_BINDING: - targetUrl = c.urlForPojectUserBinding(name) - - case CONTENT_TYPE_PROJECT_GROUP_BINDING: - targetUrl = c.urlForPojectGroupBinding(name) - - default: - return nil, fmt.Errorf("unexpected content type '%s'", contentType) - } - - return unmarshalBodyIfPresent[MeshProjectBinding](c.doAuthenticatedRequest("GET", targetUrl, - withAccept(contentType), - )) -} - -func (c *MeshStackProviderClient) createProjectBinding(binding *MeshProjectBinding, contentType string) (*MeshProjectBinding, error) { - var targetUrl *url.URL - switch contentType { - case CONTENT_TYPE_PROJECT_USER_BINDING: - targetUrl = c.endpoints.ProjectUserBindings - - case CONTENT_TYPE_PROJECT_GROUP_BINDING: - targetUrl = c.endpoints.ProjectGroupBindings - - default: - return nil, fmt.Errorf("unexpected content type '%s'", contentType) - } - - return unmarshalBody[MeshProjectBinding](c.doAuthenticatedRequest("POST", targetUrl, - withPayload(binding, contentType), - )) -} diff --git a/project_group_binding.go b/project_group_binding.go index d815b17d..a947af77 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -13,11 +13,15 @@ func (c *MeshStackProviderClient) urlForPojectGroupBinding(name string) *url.URL } func (c *MeshStackProviderClient) ReadProjectGroupBinding(name string) (*MeshProjectGroupBinding, error) { - return c.readProjectBinding(name, CONTENT_TYPE_PROJECT_GROUP_BINDING) + return unmarshalBodyIfPresent[MeshProjectBinding](c.doAuthenticatedRequest("GET", c.urlForPojectGroupBinding(name), + withAccept(CONTENT_TYPE_PROJECT_GROUP_BINDING), + )) } func (c *MeshStackProviderClient) CreateProjectGroupBinding(binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { - return c.createProjectBinding(binding, CONTENT_TYPE_PROJECT_GROUP_BINDING) + return unmarshalBody[MeshProjectBinding](c.doAuthenticatedRequest("POST", c.endpoints.ProjectGroupBindings, + withPayload(binding, CONTENT_TYPE_PROJECT_GROUP_BINDING), + )) } func (c *MeshStackProviderClient) DeleteProjecGroupBinding(name string) error { diff --git a/project_user_binding.go b/project_user_binding.go index 22614e2b..7708748d 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -13,11 +13,15 @@ func (c *MeshStackProviderClient) urlForPojectUserBinding(name string) *url.URL } func (c *MeshStackProviderClient) ReadProjectUserBinding(name string) (*MeshProjectUserBinding, error) { - return c.readProjectBinding(name, CONTENT_TYPE_PROJECT_USER_BINDING) + return unmarshalBodyIfPresent[MeshProjectBinding](c.doAuthenticatedRequest("GET", c.urlForPojectUserBinding(name), + withAccept(CONTENT_TYPE_PROJECT_USER_BINDING), + )) } func (c *MeshStackProviderClient) CreateProjectUserBinding(binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { - return c.createProjectBinding(binding, CONTENT_TYPE_PROJECT_USER_BINDING) + return unmarshalBody[MeshProjectBinding](c.doAuthenticatedRequest("POST", c.endpoints.ProjectUserBindings, + withPayload(binding, CONTENT_TYPE_PROJECT_USER_BINDING), + )) } func (c *MeshStackProviderClient) DeleteProjecUserBinding(name string) error { diff --git a/workspace_binding.go b/workspace_binding.go index 17355fd0..8732b7c6 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -1,10 +1,5 @@ package client -import ( - "fmt" - "net/url" -) - type MeshWorkspaceBinding struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -29,37 +24,3 @@ type MeshWorkspaceTargetRef struct { type MeshWorkspaceSubject struct { Name string `json:"name" tfsdk:"name"` } - -func (c *MeshStackProviderClient) readWorkspaceBinding(name string, contentType string) (*MeshWorkspaceBinding, error) { - var targetUrl *url.URL - switch contentType { - case CONTENT_TYPE_WORKSPACE_USER_BINDING: - targetUrl = c.urlForWorkspaceUserBinding(name) - - case CONTENT_TYPE_WORKSPACE_GROUP_BINDING: - targetUrl = c.urlForWorkspaceGroupBinding(name) - - default: - return nil, fmt.Errorf("unexpected content type '%s'", contentType) - } - - return unmarshalBodyIfPresent[MeshWorkspaceBinding](c.doAuthenticatedRequest("GET", targetUrl, - withAccept(contentType), - )) -} - -func (c *MeshStackProviderClient) createWorkspaceBinding(binding *MeshWorkspaceBinding, contentType string) (*MeshWorkspaceBinding, error) { - var targetUrl *url.URL - switch contentType { - case CONTENT_TYPE_WORKSPACE_USER_BINDING: - targetUrl = c.endpoints.WorkspaceUserBindings - case CONTENT_TYPE_WORKSPACE_GROUP_BINDING: - targetUrl = c.endpoints.WorkspaceGroupBindings - default: - return nil, fmt.Errorf("unexpected content type '%s'", contentType) - } - - return unmarshalBody[MeshWorkspaceBinding](c.doAuthenticatedRequest("POST", targetUrl, - withPayload(binding, contentType), - )) -} diff --git a/workspace_group_binding.go b/workspace_group_binding.go index 7139c260..c2df99dd 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -13,11 +13,15 @@ func (c *MeshStackProviderClient) urlForWorkspaceGroupBinding(name string) *url. } func (c *MeshStackProviderClient) ReadWorkspaceGroupBinding(name string) (*MeshWorkspaceGroupBinding, error) { - return c.readWorkspaceBinding(name, CONTENT_TYPE_WORKSPACE_GROUP_BINDING) + return unmarshalBodyIfPresent[MeshWorkspaceBinding](c.doAuthenticatedRequest("GET", c.urlForWorkspaceGroupBinding(name), + withAccept(CONTENT_TYPE_WORKSPACE_GROUP_BINDING), + )) } func (c *MeshStackProviderClient) CreateWorkspaceGroupBinding(binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { - return c.createWorkspaceBinding(binding, CONTENT_TYPE_WORKSPACE_GROUP_BINDING) + return unmarshalBody[MeshWorkspaceBinding](c.doAuthenticatedRequest("POST", c.endpoints.WorkspaceGroupBindings, + withPayload(binding, CONTENT_TYPE_WORKSPACE_GROUP_BINDING), + )) } func (c *MeshStackProviderClient) DeleteWorkspaceGroupBinding(name string) error { diff --git a/workspace_user_binding.go b/workspace_user_binding.go index 5ae4ce8f..de5a4c7c 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -13,11 +13,15 @@ func (c *MeshStackProviderClient) urlForWorkspaceUserBinding(name string) *url.U } func (c *MeshStackProviderClient) ReadWorkspaceUserBinding(name string) (*MeshWorkspaceUserBinding, error) { - return c.readWorkspaceBinding(name, CONTENT_TYPE_WORKSPACE_USER_BINDING) + return unmarshalBodyIfPresent[MeshWorkspaceBinding](c.doAuthenticatedRequest("GET", c.urlForWorkspaceUserBinding(name), + withAccept(CONTENT_TYPE_WORKSPACE_USER_BINDING), + )) } func (c *MeshStackProviderClient) CreateWorkspaceUserBinding(binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { - return c.createWorkspaceBinding(binding, CONTENT_TYPE_WORKSPACE_USER_BINDING) + return unmarshalBody[MeshWorkspaceBinding](c.doAuthenticatedRequest("POST", c.endpoints.WorkspaceUserBindings, + withPayload(binding, CONTENT_TYPE_WORKSPACE_USER_BINDING), + )) } func (c *MeshStackProviderClient) DeleteWorkspaceUserBinding(name string) error { From 41f210e76cbd0bde59eb2ea52962413e3e0ade93 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 15:28:57 +0100 Subject: [PATCH 070/215] fix: provide (versioning) Accept header when calling DELETE endpoints --- buildingblock.go | 4 +++- buildingblock_v2.go | 4 +++- landingzone.go | 4 +++- location.go | 4 +++- payment_method.go | 4 +++- platform.go | 4 +++- project.go | 4 +++- project_group_binding.go | 4 +++- project_user_binding.go | 4 +++- tenant.go | 4 +++- tenant_v4.go | 4 +++- workspace.go | 4 +++- workspace_group_binding.go | 4 +++- workspace_user_binding.go | 4 +++- 14 files changed, 42 insertions(+), 14 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index 430cfa19..f807a170 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -93,6 +93,8 @@ func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreat } func (c *MeshStackProviderClient) DeleteBuildingBlock(uuid string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForBuildingBlock(uuid)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForBuildingBlock(uuid), + withAccept(CONTENT_TYPE_BUILDING_BLOCK), + ) return err } diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 77d72d91..6f86a1b4 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -80,7 +80,9 @@ func (c *MeshStackProviderClient) CreateBuildingBlockV2(bb *MeshBuildingBlockV2C } func (c *MeshStackProviderClient) DeleteBuildingBlockV2(uuid string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForBuildingBlock(uuid)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForBuildingBlock(uuid), + withAccept(CONTENT_TYPE_BUILDING_BLOCK_V2), + ) return err } diff --git a/landingzone.go b/landingzone.go index c643199b..c5f3ca9e 100644 --- a/landingzone.go +++ b/landingzone.go @@ -88,6 +88,8 @@ func (c *MeshStackProviderClient) UpdateLandingZone(name string, landingZone *Me } func (c *MeshStackProviderClient) DeleteLandingZone(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForLandingZone(name)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForLandingZone(name), + withAccept(CONTENT_TYPE_LANDINGZONE), + ) return err } diff --git a/location.go b/location.go index aa82a1ee..e0a6474e 100644 --- a/location.go +++ b/location.go @@ -60,6 +60,8 @@ func (c *MeshStackProviderClient) UpdateLocation(name string, location *MeshLoca } func (c *MeshStackProviderClient) DeleteLocation(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForLocation(name)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForLocation(name), + withAccept(CONTENT_TYPE_LOCATION), + ) return err } diff --git a/payment_method.go b/payment_method.go index 11b2c279..06498361 100644 --- a/payment_method.go +++ b/payment_method.go @@ -61,6 +61,8 @@ func (c *MeshStackProviderClient) UpdatePaymentMethod(identifier string, payment } func (c *MeshStackProviderClient) DeletePaymentMethod(identifier string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForPaymentMethod(identifier)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForPaymentMethod(identifier), + withAccept(CONTENT_TYPE_PAYMENT_METHOD), + ) return err } diff --git a/platform.go b/platform.go index c6e2a70d..e4d0081d 100644 --- a/platform.go +++ b/platform.go @@ -126,7 +126,9 @@ func (c *MeshStackProviderClient) CreatePlatform(platform *MeshPlatformCreate) ( } func (c *MeshStackProviderClient) DeletePlatform(uuid string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForPlatform(uuid)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForPlatform(uuid), + withAccept(CONTENT_TYPE_PLATFORM), + ) return err } diff --git a/project.go b/project.go index c8a8dc0e..31387949 100644 --- a/project.go +++ b/project.go @@ -98,6 +98,8 @@ func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*Me } func (c *MeshStackProviderClient) DeleteProject(workspace string, name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForProject(workspace, name)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForProject(workspace, name), + withAccept(CONTENT_TYPE_PROJECT), + ) return err } diff --git a/project_group_binding.go b/project_group_binding.go index a947af77..47378242 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -25,6 +25,8 @@ func (c *MeshStackProviderClient) CreateProjectGroupBinding(binding *MeshProject } func (c *MeshStackProviderClient) DeleteProjecGroupBinding(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForPojectGroupBinding(name)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForPojectGroupBinding(name), + withAccept(CONTENT_TYPE_PROJECT_GROUP_BINDING), + ) return err } diff --git a/project_user_binding.go b/project_user_binding.go index 7708748d..9ade8d58 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -25,6 +25,8 @@ func (c *MeshStackProviderClient) CreateProjectUserBinding(binding *MeshProjectU } func (c *MeshStackProviderClient) DeleteProjecUserBinding(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForPojectUserBinding(name)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForPojectUserBinding(name), + withAccept(CONTENT_TYPE_PROJECT_USER_BINDING), + ) return err } diff --git a/tenant.go b/tenant.go index 7aba3bb3..b187f83d 100644 --- a/tenant.go +++ b/tenant.go @@ -67,6 +67,8 @@ func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshT } func (c *MeshStackProviderClient) DeleteTenant(workspace string, project string, platform string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForTenant(workspace, project, platform)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForTenant(workspace, project, platform), + withAccept(CONTENT_TYPE_TENANT), + ) return err } diff --git a/tenant_v4.go b/tenant_v4.go index 2a771ab2..6243a957 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -76,7 +76,9 @@ func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantV4Create) (*M } func (c *MeshStackProviderClient) DeleteTenantV4(uuid string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForTenantV4(uuid)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForTenantV4(uuid), + withAccept(CONTENT_TYPE_TENANT_V4), + ) return err } diff --git a/workspace.go b/workspace.go index 4020d76f..309ec385 100644 --- a/workspace.go +++ b/workspace.go @@ -58,6 +58,8 @@ func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWo } func (c *MeshStackProviderClient) DeleteWorkspace(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspace(name)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspace(name), + withAccept(CONTENT_TYPE_WORKSPACE), + ) return err } diff --git a/workspace_group_binding.go b/workspace_group_binding.go index c2df99dd..2fddb5c3 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -25,6 +25,8 @@ func (c *MeshStackProviderClient) CreateWorkspaceGroupBinding(binding *MeshWorks } func (c *MeshStackProviderClient) DeleteWorkspaceGroupBinding(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspaceGroupBinding(name)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspaceGroupBinding(name), + withAccept(CONTENT_TYPE_WORKSPACE_GROUP_BINDING), + ) return err } diff --git a/workspace_user_binding.go b/workspace_user_binding.go index de5a4c7c..c10d77ca 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -25,6 +25,8 @@ func (c *MeshStackProviderClient) CreateWorkspaceUserBinding(binding *MeshWorksp } func (c *MeshStackProviderClient) DeleteWorkspaceUserBinding(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspaceUserBinding(name)) + _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspaceUserBinding(name), + withAccept(CONTENT_TYPE_WORKSPACE_USER_BINDING), + ) return err } From 6bfabe8d2d697f5588cde39d075cd834a70c1b0f Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 16:34:37 +0100 Subject: [PATCH 071/215] refactor: simplify fetching paginated responses, add simplistic data source test --- client.go | 103 ++++++++++++++++++++++++++++++++++++---------- integrations.go | 33 +-------------- project.go | 40 ++++-------------- tag_definition.go | 33 +-------------- 4 files changed, 93 insertions(+), 116 deletions(-) diff --git a/client.go b/client.go index 771f0abc..3a56804d 100644 --- a/client.go +++ b/client.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "iter" "log" "net/http" "net/url" @@ -113,19 +114,42 @@ func (c *MeshStackProviderClient) ensureValidToken() error { type doRequestOption func(opts *doRequestOptions) +type urlModifier func(url *url.URL) + type requestModifier func(req *http.Request) type doRequestOptions struct { + urlModifiers []urlModifier requestPayload any requestModifiers []requestModifier } +func appendUrlModifier(modifier urlModifier) doRequestOption { + return func(opts *doRequestOptions) { + opts.urlModifiers = append(opts.urlModifiers, modifier) + } +} + func appendRequestModifier(modifier requestModifier) doRequestOption { return func(opts *doRequestOptions) { opts.requestModifiers = append(opts.requestModifiers, modifier) } } +func withUrlQuery(key string, value any) doRequestOption { + return appendUrlModifier(func(url *url.URL) { + var valueStr string + if stringerValue, ok := value.(fmt.Stringer); ok { + valueStr = stringerValue.String() + } else { + valueStr = fmt.Sprintf("%v", value) + } + query := url.Query() + query.Set(key, valueStr) + url.RawQuery = query.Encode() + }) +} + func withAccept(accept string) doRequestOption { return withHeader("Accept", accept) } @@ -157,6 +181,18 @@ func (c *MeshStackProviderClient) doRequest(method string, url *url.URL, options option(&opts) } + if len(opts.urlModifiers) > 0 { + // clone url to prevent modifiers edit the given URL (it's sad that this is a pointer actually) + var err error + url, err = url.Parse(url.String()) + if err != nil { + panic("cloning URL failed: " + err.Error()) + } + for _, modifier := range opts.urlModifiers { + modifier(url) + } + } + var requestBody io.ReadWriter if opts.requestPayload != nil { requestBody = new(bytes.Buffer) @@ -217,6 +253,39 @@ func (c *MeshStackProviderClient) doAuthenticatedRequest(method string, url *url )...) } +func (c *MeshStackProviderClient) doPaginatedRequest(url *url.URL, options ...doRequestOption) iter.Seq2[[]byte, error] { + return func(yield func([]byte, error) bool) { + pageNumber := 0 + for { + body, err := c.doAuthenticatedRequest("GET", url, append(options, withUrlQuery("page", pageNumber))...) + if err != nil { + yield(body, fmt.Errorf("cannot fetch page %d: %w", pageNumber, err)) + return + } + if !yield(body, nil) { + // consumer wants to stop + return + } + // Check if there are more pages to fetch + type paginatedResponse struct { + Page struct { + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` + } + response, err := unmarshalBody[paginatedResponse](body, err) + if err != nil { + yield(body, fmt.Errorf("cannot unmarshal paginated response, page %d: %w", pageNumber, err)) + return + } + if response.Page.Number >= response.Page.TotalPages-1 { + return + } + pageNumber++ + } + } +} + func unmarshalBody[T any](body []byte, err error) (*T, error) { if err != nil { return nil, err @@ -235,26 +304,18 @@ func unmarshalBodyIfPresent[T any](body []byte, err error) (*T, error) { return unmarshalBody[T](body, err) } -// paginatedResponse is a generic structure for HAL paginated responses -type paginatedResponse[T any] struct { - Embedded map[string][]T `json:"_embedded"` - Page struct { - Size int `json:"size"` - TotalElements int `json:"totalElements"` - TotalPages int `json:"totalPages"` - Number int `json:"number"` - } `json:"page"` -} - -// unmarshalPaginatedBody unmarshalls a paginated HAL response and extracts items using the provided key -func unmarshalPaginatedBody[T any](body []byte, err error, embeddedKey string) ([]T, *paginatedResponse[T], error) { - if err != nil { - return nil, nil, err - } - var response paginatedResponse[T] - if err := json.Unmarshal(body, &response); err != nil { - return nil, nil, err +func unmarshalBodyPages[T any](embeddedKey string, bodyPages iter.Seq2[[]byte, error]) (result []T, err error) { + for bodyPage, err := range bodyPages { + type embeddedResponse[T any] struct { + Embedded map[string][]T `json:"_embedded"` + } + if response, err := unmarshalBody[embeddedResponse[T]](bodyPage, err); err != nil { + return result, err + } else if items, ok := response.Embedded[embeddedKey]; !ok { + return result, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey) + } else { + result = append(result, items...) + } } - items := response.Embedded[embeddedKey] - return items, &response, nil + return result, nil } diff --git a/integrations.go b/integrations.go index a4950461..99f351da 100644 --- a/integrations.go +++ b/integrations.go @@ -1,7 +1,6 @@ package client import ( - "fmt" "net/url" ) @@ -96,34 +95,6 @@ func (c *MeshStackProviderClient) ReadIntegration(workspace string, uuid string) )) } -func (c *MeshStackProviderClient) ReadIntegrations() (*[]MeshIntegration, error) { - var allIntegrations []MeshIntegration - - pageNumber := 0 - targetUrl := c.endpoints.Integrations - query := targetUrl.Query() - - for { - query.Set("page", fmt.Sprintf("%d", pageNumber)) - targetUrl.RawQuery = query.Encode() - - body, err := c.doAuthenticatedRequest("GET", targetUrl, - withAccept(CONTENT_TYPE_INTEGRATION), - ) - items, response, err := unmarshalPaginatedBody[MeshIntegration](body, err, "meshIntegrations") - if err != nil { - return nil, err - } - - allIntegrations = append(allIntegrations, items...) - - // Check if there are more pages - if response.Page.Number >= response.Page.TotalPages-1 { - break - } - - pageNumber++ - } - - return &allIntegrations, nil +func (c *MeshStackProviderClient) ReadIntegrations() ([]MeshIntegration, error) { + return unmarshalBodyPages[MeshIntegration]("meshIntegrations", c.doPaginatedRequest(c.endpoints.Integrations, withAccept(CONTENT_TYPE_INTEGRATION))) } diff --git a/project.go b/project.go index 31387949..8a39499b 100644 --- a/project.go +++ b/project.go @@ -1,7 +1,6 @@ package client import ( - "fmt" "net/url" ) @@ -49,40 +48,15 @@ func (c *MeshStackProviderClient) ReadProject(workspace string, name string) (*M )) } -func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, paymentMethodIdentifier *string) (*[]MeshProject, error) { - var allProjects []MeshProject - - pageNumber := 0 - targetUrl := c.endpoints.Projects - query := targetUrl.Query() - query.Set("workspaceIdentifier", workspaceIdentifier) - if paymentMethodIdentifier != nil { - query.Set("paymentIdentifier", *paymentMethodIdentifier) +func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { + options := []doRequestOption{ + withAccept(CONTENT_TYPE_PROJECT), + withUrlQuery("workspaceIdentifier", workspaceIdentifier), } - - for { - query.Set("page", fmt.Sprintf("%d", pageNumber)) - targetUrl.RawQuery = query.Encode() - - body, err := c.doAuthenticatedRequest("GET", targetUrl, - withAccept(CONTENT_TYPE_PROJECT), - ) - items, response, err := unmarshalPaginatedBody[MeshProject](body, err, "meshProjects") - if err != nil { - return nil, err - } - - allProjects = append(allProjects, items...) - - // Check if there are more pages - if response.Page.Number >= response.Page.TotalPages-1 { - break - } - - pageNumber++ + if paymentMethodIdentifier != nil { + options = append(options, withUrlQuery("paymentIdentifier", *paymentMethodIdentifier)) } - - return &allProjects, nil + return unmarshalBodyPages[MeshProject]("meshProjects", c.doPaginatedRequest(c.endpoints.Projects, options...)) } func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*MeshProject, error) { diff --git a/tag_definition.go b/tag_definition.go index ed341775..c5112abe 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -1,7 +1,6 @@ package client import ( - "fmt" "net/url" ) @@ -73,36 +72,8 @@ func (c *MeshStackProviderClient) urlForTagDefinition(name string) *url.URL { return c.endpoints.TagDefinitions.JoinPath(name) } -func (c *MeshStackProviderClient) ReadTagDefinitions() (*[]MeshTagDefinition, error) { - var all []MeshTagDefinition - - pageNumber := 0 - targetUrl := c.endpoints.TagDefinitions - query := targetUrl.Query() - - for { - query.Set("page", fmt.Sprintf("%d", pageNumber)) - targetUrl.RawQuery = query.Encode() - - body, err := c.doAuthenticatedRequest("GET", targetUrl, - withAccept(CONTENT_TYPE_TAG_DEFINITION), - ) - items, response, err := unmarshalPaginatedBody[MeshTagDefinition](body, err, "meshTagDefinitions") - if err != nil { - return nil, err - } - - all = append(all, items...) - - // Check if there are more pages - if response.Page.Number >= response.Page.TotalPages-1 { - break - } - - pageNumber++ - } - - return &all, nil +func (c *MeshStackProviderClient) ReadTagDefinitions() ([]MeshTagDefinition, error) { + return unmarshalBodyPages[MeshTagDefinition]("meshTagDefinitions", c.doPaginatedRequest(c.endpoints.TagDefinitions, withAccept(CONTENT_TYPE_TAG_DEFINITION))) } func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefinition, error) { From b9fdd6f3ee708c60311803c7cc9e908101880676 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 22:09:54 +0100 Subject: [PATCH 072/215] refactor: use generic meshObjectClient to build MeshStackProviderClient, add data source tests --- buildingblock.go | 29 ++---- buildingblock_v2.go | 49 +++++----- client.go | 190 ++++++++++++++++++++++--------------- integrations.go | 24 ++--- landingzone.go | 35 ++----- location.go | 35 ++----- payment_method.go | 35 ++----- platform.go | 35 ++----- project.go | 45 ++++----- project_group_binding.go | 29 ++---- project_user_binding.go | 29 ++---- tag_definition.go | 38 +++----- tenant.go | 34 +++---- tenant_v4.go | 50 ++++------ workspace.go | 35 ++----- workspace_group_binding.go | 29 ++---- workspace_user_binding.go | 29 ++---- 17 files changed, 297 insertions(+), 453 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index f807a170..19f4f440 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -1,9 +1,5 @@ package client -import ( - "net/url" -) - const ( MESH_BUILDING_BLOCK_IO_TYPE_STRING = "STRING" MESH_BUILDING_BLOCK_IO_TYPE_INTEGER = "INTEGER" @@ -13,8 +9,6 @@ const ( MESH_BUILDING_BLOCK_IO_TYPE_FILE = "FILE" MESH_BUILDING_BLOCK_IO_TYPE_LIST = "LIST" MESH_BUILDING_BLOCK_IO_TYPE_CODE = "CODE" - - CONTENT_TYPE_BUILDING_BLOCK = "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json" ) type MeshBuildingBlock struct { @@ -76,25 +70,18 @@ type MeshBuildingBlockDefinitionRef struct { Uuid string `json:"uuid" tfsdk:"uuid"` } -func (c *MeshStackProviderClient) urlForBuildingBlock(uuid string) *url.URL { - return c.endpoints.BuildingBlocks.JoinPath(uuid) +type MeshBuildingBlockClient struct { + meshObjectClient[MeshBuildingBlock] } -func (c *MeshStackProviderClient) ReadBuildingBlock(uuid string) (*MeshBuildingBlock, error) { - return unmarshalBodyIfPresent[MeshBuildingBlock](c.doAuthenticatedRequest("GET", c.urlForBuildingBlock(uuid), - withAccept(CONTENT_TYPE_BUILDING_BLOCK), - )) +func (c MeshBuildingBlockClient) Read(uuid string) (*MeshBuildingBlock, error) { + return c.get(uuid) } -func (c *MeshStackProviderClient) CreateBuildingBlock(bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { - return unmarshalBody[MeshBuildingBlock](c.doAuthenticatedRequest("POST", c.endpoints.BuildingBlocks, - withPayload(bb, CONTENT_TYPE_BUILDING_BLOCK), - )) +func (c MeshBuildingBlockClient) Create(bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { + return c.post(bb) } -func (c *MeshStackProviderClient) DeleteBuildingBlock(uuid string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForBuildingBlock(uuid), - withAccept(CONTENT_TYPE_BUILDING_BLOCK), - ) - return err +func (c MeshBuildingBlockClient) Delete(uuid string) error { + return c.delete(uuid) } diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 6f86a1b4..b767b762 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -9,8 +9,6 @@ import ( ) const ( - CONTENT_TYPE_BUILDING_BLOCK_V2 = "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json" - // Building Block Status Constants. BUILDING_BLOCK_STATUS_WAITING_FOR_DEPENDENT_INPUT = "WAITING_FOR_DEPENDENT_INPUT" BUILDING_BLOCK_STATUS_WAITING_FOR_OPERATOR_INPUT = "WAITING_FOR_OPERATOR_INPUT" @@ -67,38 +65,35 @@ type MeshBuildingBlockV2Status struct { ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` } -func (c *MeshStackProviderClient) ReadBuildingBlockV2(uuid string) (*MeshBuildingBlockV2, error) { - return unmarshalBodyIfPresent[MeshBuildingBlockV2](c.doAuthenticatedRequest("GET", c.urlForBuildingBlock(uuid), - withAccept(CONTENT_TYPE_BUILDING_BLOCK_V2), - )) +type MeshBuildingBlockV2Client struct { + meshObjectClient[MeshBuildingBlockV2] +} + +func (c MeshBuildingBlockV2Client) Read(uuid string) (*MeshBuildingBlockV2, error) { + return c.get(uuid) } -func (c *MeshStackProviderClient) CreateBuildingBlockV2(bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { - return unmarshalBody[MeshBuildingBlockV2](c.doAuthenticatedRequest("POST", c.endpoints.BuildingBlocks, - withPayload(bb, CONTENT_TYPE_BUILDING_BLOCK_V2), - )) +func (c MeshBuildingBlockV2Client) Create(bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { + return c.post(bb) } -func (c *MeshStackProviderClient) DeleteBuildingBlockV2(uuid string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForBuildingBlock(uuid), - withAccept(CONTENT_TYPE_BUILDING_BLOCK_V2), - ) - return err +func (c MeshBuildingBlockV2Client) Delete(uuid string) error { + return c.delete(uuid) } -// PollBuildingBlockV2UntilCompletion polls a building block until it reaches a terminal state (SUCCEEDED or FAILED) +// PollUntilCompletion polls a building block until it reaches a terminal state (SUCCEEDED or FAILED) // Returns the final building block state or an error if polling fails or times out. -func (c *MeshStackProviderClient) PollBuildingBlockV2UntilCompletion(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { +func (c MeshBuildingBlockV2Client) PollUntilCompletion(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { var result *MeshBuildingBlockV2 - err := retry.RetryContext(ctx, 30*time.Minute, c.waitForBuildingBlockV2CompletionFunc(uuid, &result)) + err := retry.RetryContext(ctx, 30*time.Minute, c.waitForCompletionFunc(uuid, &result)) return result, err } -// waitForBuildingBlockV2CompletionFunc returns a RetryFunc that checks building block completion status. -func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(uuid string, result **MeshBuildingBlockV2) retry.RetryFunc { +// waitForCompletionFunc returns a RetryFunc that checks building block completion status. +func (c MeshBuildingBlockV2Client) waitForCompletionFunc(uuid string, result **MeshBuildingBlockV2) retry.RetryFunc { return func() *retry.RetryError { - current, err := c.ReadBuildingBlockV2(uuid) + current, err := c.Read(uuid) if err != nil { return retry.NonRetryableError(fmt.Errorf("could not read building block status while waiting for completion: %w", err)) } @@ -122,16 +117,16 @@ func (c *MeshStackProviderClient) waitForBuildingBlockV2CompletionFunc(uuid stri } } -// PollBuildingBlockV2UntilDeletion polls a building block until it is deleted (not found) +// PollUntilDeletion polls a building block until it is deleted (not found) // Returns nil on successful deletion or an error if polling fails or times out. -func (c *MeshStackProviderClient) PollBuildingBlockV2UntilDeletion(ctx context.Context, uuid string) error { - return retry.RetryContext(ctx, 30*time.Minute, c.waitForBuildingBlockV2DeletionFunc(uuid)) +func (c MeshBuildingBlockV2Client) PollUntilDeletion(ctx context.Context, uuid string) error { + return retry.RetryContext(ctx, 30*time.Minute, c.waitForDeletionFunc(uuid)) } -// waitForBuildingBlockV2DeletionFunc returns a RetryFunc that checks building block deletion status. -func (c *MeshStackProviderClient) waitForBuildingBlockV2DeletionFunc(uuid string) retry.RetryFunc { +// waitForDeletionFunc returns a RetryFunc that checks building block deletion status. +func (c MeshBuildingBlockV2Client) waitForDeletionFunc(uuid string) retry.RetryFunc { return func() *retry.RetryError { - current, err := c.ReadBuildingBlockV2(uuid) + current, err := c.Read(uuid) if err != nil { return retry.NonRetryableError(fmt.Errorf("could not read building block status while waiting for deletion: %w", err)) } diff --git a/client.go b/client.go index 3a56804d..84cd4233 100644 --- a/client.go +++ b/client.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "slices" + "strings" "time" ) @@ -19,69 +20,110 @@ var ( ) type MeshStackProviderClient struct { - url *url.URL - httpClient *http.Client - apiKey string - apiSecret string - token string - tokenExpiry time.Time - endpoints endpoints + BuildingBlock MeshBuildingBlockClient + BuildingBlockV2 MeshBuildingBlockV2Client + Integration MeshIntegrationClient + LandingZone MeshLandingZoneClient + Location MeshLocationClient + PaymentMethod MeshPaymentMethodClient + Platform MeshPlatformClient + Project MeshProjectClient + ProjectGroupBinding MeshProjectGroupBindingClient + ProjectUserBinding MeshProjectUserBindingClient + TagDefinition MeshTagDefinitionClient + Tenant MeshTenantClient + TenantV4 MeshTenantV4Client + Workspace MeshWorkspaceClient + WorkspaceGroupBinding MeshWorkspaceGroupBindingClient + WorkspaceUserBinding MeshWorkspaceUserBindingClient } -type endpoints struct { - BuildingBlocks *url.URL `json:"meshbuildingblocks"` - Projects *url.URL `json:"meshprojects"` - ProjectUserBindings *url.URL `json:"meshprojectuserbindings"` - ProjectGroupBindings *url.URL `json:"meshprojectgroupbindings"` - Workspaces *url.URL `json:"meshworkspaces"` - WorkspaceUserBindings *url.URL `json:"meshworkspaceuserbindings"` - WorkspaceGroupBindings *url.URL `json:"meshworkspacegroupbindings"` - Tenants *url.URL `json:"meshtenants"` - TagDefinitions *url.URL `json:"meshtagdefinitions"` - LandingZones *url.URL `json:"meshlandingzones"` - Platforms *url.URL `json:"meshplatforms"` - PaymentMethods *url.URL `json:"meshpaymentmethods"` - Integrations *url.URL `json:"meshintegrations"` - Locations *url.URL `json:"meshlocations"` +func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) MeshStackProviderClient { + // Initialize httpClient for typed clients + c := &httpClient{ + Client: http.Client{Timeout: 5 * time.Minute}, + RootUrl: rootUrl, + ApiKey: apiKey, + ApiSecret: apiSecret, + } + return MeshStackProviderClient{ + MeshBuildingBlockClient{newMeshObjectClient[MeshBuildingBlock](c, "meshBuildingBlock", "v1")}, + MeshBuildingBlockV2Client{newMeshObjectClient[MeshBuildingBlockV2](c, "meshBuildingBlock", "v2-preview")}, + MeshIntegrationClient{newMeshObjectClient[MeshIntegration](c, "meshIntegration", "v1-preview")}, + MeshLandingZoneClient{newMeshObjectClient[MeshLandingZone](c, "meshLandingZone", "v1-preview")}, + MeshLocationClient{newMeshObjectClient[MeshLocation](c, "meshLocation", "v1-preview")}, + MeshPaymentMethodClient{newMeshObjectClient[MeshPaymentMethod](c, "meshPaymentMethod", "v2")}, + MeshPlatformClient{newMeshObjectClient[MeshPlatform](c, "meshPlatform", "v2-preview")}, + MeshProjectClient{newMeshObjectClient[MeshProject](c, "meshProject", "v2")}, + MeshProjectGroupBindingClient{newMeshObjectClient[MeshProjectBinding](c, "meshProjectGroupBinding", "v3", "meshprojectbindings", "groupbindings")}, + MeshProjectUserBindingClient{newMeshObjectClient[MeshProjectBinding](c, "meshProjectUserBinding", "v3", "meshprojectbindings", "userbindings")}, + MeshTagDefinitionClient{newMeshObjectClient[MeshTagDefinition](c, "meshTagDefinition", "v1")}, + MeshTenantClient{newMeshObjectClient[MeshTenant](c, "meshTenant", "v3")}, + MeshTenantV4Client{newMeshObjectClient[MeshTenantV4](c, "meshTenant", "v4-preview")}, + MeshWorkspaceClient{newMeshObjectClient[MeshWorkspace](c, "meshWorkspace", "v2")}, + MeshWorkspaceGroupBindingClient{newMeshObjectClient[MeshWorkspaceBinding](c, "meshWorkspaceGroupBinding", "v2", "meshworkspacebindings", "groupbindings")}, + MeshWorkspaceUserBindingClient{newMeshObjectClient[MeshWorkspaceBinding](c, "meshWorkspaceUserBinding", "v2", "meshworkspacebindings", "userbindings")}, + } } -func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) (*MeshStackProviderClient, error) { - client := &MeshStackProviderClient{ - url: rootUrl, - httpClient: &http.Client{ - Timeout: time.Minute * 5, - }, - apiKey: apiKey, - apiSecret: apiSecret, - token: "", - } +type httpClient struct { + http.Client + RootUrl *url.URL + ApiKey string + ApiSecret string + Token string + TokenExpiry time.Time +} - // TODO: lookup endpoints - const ( - apiMeshObjectsRoot = "/api/meshobjects" - ) - client.endpoints = endpoints{ - BuildingBlocks: rootUrl.JoinPath(apiMeshObjectsRoot, "meshbuildingblocks"), - Projects: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojects"), - ProjectUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "userbindings"), - ProjectGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshprojectbindings", "groupbindings"), - Workspaces: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspaces"), - WorkspaceUserBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "userbindings"), - WorkspaceGroupBindings: rootUrl.JoinPath(apiMeshObjectsRoot, "meshworkspacebindings", "groupbindings"), - Tenants: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtenants"), - TagDefinitions: rootUrl.JoinPath(apiMeshObjectsRoot, "meshtagdefinitions"), - LandingZones: rootUrl.JoinPath(apiMeshObjectsRoot, "meshlandingzones"), - Platforms: rootUrl.JoinPath(apiMeshObjectsRoot, "meshplatforms"), - PaymentMethods: rootUrl.JoinPath(apiMeshObjectsRoot, "meshpaymentmethods"), - Integrations: rootUrl.JoinPath(apiMeshObjectsRoot, "meshintegrations"), - Locations: rootUrl.JoinPath(apiMeshObjectsRoot, "meshlocations"), +type meshObjectClient[M any] struct { + *httpClient + Name, ApiVersion string + ApiUrl *url.URL +} + +func newMeshObjectClient[M any](client *httpClient, name, apiVersion string, explicitApiPaths ...string) meshObjectClient[M] { + if len(explicitApiPaths) == 0 { + // infer API path from meshObject name by default (if nothing explicit is given) + explicitApiPaths = []string{strings.ToLower(pluralizeName(name))} } + // also prepend the root path for all meshObjects + explicitApiPaths = slices.Insert(explicitApiPaths, 0, "/api/meshobjects") + apiUrl := client.RootUrl.JoinPath(explicitApiPaths...) + log.Printf("Using API at '%s' for meshObject '%s', version '%s'", apiUrl, name, apiVersion) + return meshObjectClient[M]{client, name, apiVersion, apiUrl} +} - return client, nil +func pluralizeName(name string) string { + return fmt.Sprintf("%ss", name) } -func (c *MeshStackProviderClient) login() error { - loginUrl := c.url.JoinPath("/api/login") +func (o meshObjectClient[M]) mediaType() string { + return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", o.Name, o.ApiVersion) +} + +func (c meshObjectClient[M]) get(id string) (*M, error) { + return unmarshalBodyIfPresent[M](c.doAuthenticatedRequest(http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.mediaType()))) +} + +func (c meshObjectClient[M]) list(options ...doRequestOption) ([]M, error) { + return unmarshalBodyPages[M](pluralizeName(c.Name), c.doPaginatedRequest(c.ApiUrl, append(options, withAccept(c.mediaType()))...)) +} + +func (c meshObjectClient[M]) post(payload any) (*M, error) { + return unmarshalBody[M](c.doAuthenticatedRequest(http.MethodPost, c.ApiUrl, withPayload(payload, c.mediaType()))) +} + +func (c meshObjectClient[M]) put(id string, payload any) (*M, error) { + return unmarshalBody[M](c.doAuthenticatedRequest(http.MethodPut, c.ApiUrl.JoinPath(id), withPayload(payload, c.mediaType()))) +} + +func (c meshObjectClient[M]) delete(id string) (err error) { + _, err = c.doAuthenticatedRequest(http.MethodDelete, c.ApiUrl.JoinPath(id), withAccept(c.mediaType())) + return +} + +func (c *httpClient) login() error { + loginApiUrl := c.RootUrl.JoinPath("/api/login") type loginRequest struct { ClientId string `json:"clientId"` @@ -93,20 +135,20 @@ func (c *MeshStackProviderClient) login() error { ExpireSec int `json:"expires_in"` } - loginResult, err := unmarshalBody[loginResponse](c.doRequest("POST", loginUrl, - withPayload(loginRequest{ClientId: c.apiKey, ClientSecret: c.apiSecret}, "application/json")), + loginResult, err := unmarshalBody[loginResponse](c.doRequest("POST", loginApiUrl, + withPayload(loginRequest{ClientId: c.ApiKey, ClientSecret: c.ApiSecret}, "application/json")), ) if err != nil { - return fmt.Errorf("login request to %s with API Key '%s' failed: %w", loginUrl, c.apiKey, err) + return fmt.Errorf("login request to %s with API Key '%s' failed: %w", loginApiUrl, c.ApiKey, err) } - c.token = fmt.Sprintf("Bearer %s", loginResult.Token) - c.tokenExpiry = time.Now().Add(time.Second * time.Duration(loginResult.ExpireSec)) + c.Token = fmt.Sprintf("Bearer %s", loginResult.Token) + c.TokenExpiry = time.Now().Add(time.Second * time.Duration(loginResult.ExpireSec)) return nil } -func (c *MeshStackProviderClient) ensureValidToken() error { - if c.token == "" || time.Now().Add(time.Second*30).After(c.tokenExpiry) { +func (c *httpClient) ensureValidToken() error { + if c.Token == "" || time.Now().Add(30*time.Second).After(c.TokenExpiry) { return c.login() } return nil @@ -171,7 +213,7 @@ func withPayload(payload any, contentType string) doRequestOption { } } -func (c *MeshStackProviderClient) doRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { +func (c *httpClient) doRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { // prepend (aka insert at 0) some default options such that given options may be overridden by caller options = slices.Insert(options, 0, withHeader("User-Agent", "meshStack Terraform Provider"), @@ -183,11 +225,8 @@ func (c *MeshStackProviderClient) doRequest(method string, url *url.URL, options if len(opts.urlModifiers) > 0 { // clone url to prevent modifiers edit the given URL (it's sad that this is a pointer actually) - var err error - url, err = url.Parse(url.String()) - if err != nil { - panic("cloning URL failed: " + err.Error()) - } + // ignoring the error is fine as this always succeeds parsing from String() + url, _ = url.Parse(url.String()) for _, modifier := range opts.urlModifiers { modifier(url) } @@ -209,7 +248,7 @@ func (c *MeshStackProviderClient) doRequest(method string, url *url.URL, options requestModifier(req) } - res, err := c.httpClient.Do(req) + res, err := c.Do(req) if err != nil { return nil, err } @@ -240,7 +279,7 @@ func (c *MeshStackProviderClient) doRequest(method string, url *url.URL, options return responseBody, errors.Join(errs...) } -func (c *MeshStackProviderClient) doAuthenticatedRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { +func (c *httpClient) doAuthenticatedRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { if err := c.ensureValidToken(); err != nil { return nil, err } @@ -249,15 +288,15 @@ func (c *MeshStackProviderClient) doAuthenticatedRequest(method string, url *url // log request before adding Authorization header below log.Println(req) }), - withHeader("Authorization", c.token), + withHeader("Authorization", c.Token), )...) } -func (c *MeshStackProviderClient) doPaginatedRequest(url *url.URL, options ...doRequestOption) iter.Seq2[[]byte, error] { +func (c *httpClient) doPaginatedRequest(url *url.URL, options ...doRequestOption) iter.Seq2[[]byte, error] { return func(yield func([]byte, error) bool) { pageNumber := 0 for { - body, err := c.doAuthenticatedRequest("GET", url, append(options, withUrlQuery("page", pageNumber))...) + body, err := c.doAuthenticatedRequest(http.MethodGet, url, append(options, withUrlQuery("page", pageNumber))...) if err != nil { yield(body, fmt.Errorf("cannot fetch page %d: %w", pageNumber, err)) return @@ -304,12 +343,13 @@ func unmarshalBodyIfPresent[T any](body []byte, err error) (*T, error) { return unmarshalBody[T](body, err) } -func unmarshalBodyPages[T any](embeddedKey string, bodyPages iter.Seq2[[]byte, error]) (result []T, err error) { - for bodyPage, err := range bodyPages { +func unmarshalBodyPages[T any](embeddedKey string, bodyPages iter.Seq2[[]byte, error]) ([]T, error) { + var result []T + for bodyPage, pageErr := range bodyPages { type embeddedResponse[T any] struct { Embedded map[string][]T `json:"_embedded"` } - if response, err := unmarshalBody[embeddedResponse[T]](bodyPage, err); err != nil { + if response, err := unmarshalBody[embeddedResponse[T]](bodyPage, pageErr); err != nil { return result, err } else if items, ok := response.Embedded[embeddedKey]; !ok { return result, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey) diff --git a/integrations.go b/integrations.go index 99f351da..9aa514ad 100644 --- a/integrations.go +++ b/integrations.go @@ -1,11 +1,5 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_INTEGRATION = "application/vnd.meshcloud.api.meshintegration.v1-preview.hal+json" - type MeshIntegration struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -85,16 +79,18 @@ type MeshAwsWifProvider struct { Thumbprint string `json:"thumbprint" tfsdk:"thumbprint"` } -func (c *MeshStackProviderClient) urlForIntegration(workspace string, uuid string) *url.URL { - return c.endpoints.Integrations.JoinPath(workspace, uuid) +type MeshIntegrationClient struct { + meshObjectClient[MeshIntegration] +} + +func (c MeshIntegrationClient) integrationId(workspace string, uuid string) string { + return workspace + "/" + uuid } -func (c *MeshStackProviderClient) ReadIntegration(workspace string, uuid string) (*MeshIntegration, error) { - return unmarshalBodyIfPresent[MeshIntegration](c.doAuthenticatedRequest("GET", c.urlForIntegration(workspace, uuid), - withAccept(CONTENT_TYPE_INTEGRATION), - )) +func (c MeshIntegrationClient) Read(workspace string, uuid string) (*MeshIntegration, error) { + return c.get(c.integrationId(workspace, uuid)) } -func (c *MeshStackProviderClient) ReadIntegrations() ([]MeshIntegration, error) { - return unmarshalBodyPages[MeshIntegration]("meshIntegrations", c.doPaginatedRequest(c.endpoints.Integrations, withAccept(CONTENT_TYPE_INTEGRATION))) +func (c MeshIntegrationClient) List() ([]MeshIntegration, error) { + return c.list() } diff --git a/landingzone.go b/landingzone.go index c5f3ca9e..0967a8ca 100644 --- a/landingzone.go +++ b/landingzone.go @@ -1,11 +1,5 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_LANDINGZONE = "application/vnd.meshcloud.api.meshlandingzone.v1-preview.hal+json" - type MeshLandingZone struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -65,31 +59,22 @@ type MeshLandingZoneCreate struct { Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` } -func (c *MeshStackProviderClient) urlForLandingZone(name string) *url.URL { - return c.endpoints.LandingZones.JoinPath(name) +type MeshLandingZoneClient struct { + meshObjectClient[MeshLandingZone] } -func (c *MeshStackProviderClient) ReadLandingZone(name string) (*MeshLandingZone, error) { - return unmarshalBodyIfPresent[MeshLandingZone](c.doAuthenticatedRequest("GET", c.urlForLandingZone(name), - withAccept(CONTENT_TYPE_LANDINGZONE), - )) +func (c MeshLandingZoneClient) Read(name string) (*MeshLandingZone, error) { + return c.get(name) } -func (c *MeshStackProviderClient) CreateLandingZone(landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { - return unmarshalBody[MeshLandingZone](c.doAuthenticatedRequest("POST", c.endpoints.LandingZones, - withPayload(landingZone, CONTENT_TYPE_LANDINGZONE), - )) +func (c MeshLandingZoneClient) Create(landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { + return c.post(landingZone) } -func (c *MeshStackProviderClient) UpdateLandingZone(name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { - return unmarshalBody[MeshLandingZone](c.doAuthenticatedRequest("PUT", c.urlForLandingZone(name), - withPayload(landingZone, CONTENT_TYPE_LANDINGZONE), - )) +func (c MeshLandingZoneClient) Update(name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { + return c.put(name, landingZone) } -func (c *MeshStackProviderClient) DeleteLandingZone(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForLandingZone(name), - withAccept(CONTENT_TYPE_LANDINGZONE), - ) - return err +func (c MeshLandingZoneClient) Delete(name string) error { + return c.delete(name) } diff --git a/location.go b/location.go index e0a6474e..81824162 100644 --- a/location.go +++ b/location.go @@ -1,11 +1,5 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_LOCATION = "application/vnd.meshcloud.api.meshlocation.v1-preview.hal+json" - type MeshLocation struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Metadata MeshLocationMetadata `json:"metadata" tfsdk:"metadata"` @@ -37,31 +31,22 @@ type MeshLocationCreateMetadata struct { Name string `json:"name" tfsdk:"name"` } -func (c *MeshStackProviderClient) urlForLocation(name string) *url.URL { - return c.endpoints.Locations.JoinPath(name) +type MeshLocationClient struct { + meshObjectClient[MeshLocation] } -func (c *MeshStackProviderClient) ReadLocation(name string) (*MeshLocation, error) { - return unmarshalBodyIfPresent[MeshLocation](c.doAuthenticatedRequest("GET", c.urlForLocation(name), - withAccept(CONTENT_TYPE_LOCATION), - )) +func (c MeshLocationClient) Read(name string) (*MeshLocation, error) { + return c.get(name) } -func (c *MeshStackProviderClient) CreateLocation(location *MeshLocationCreate) (*MeshLocation, error) { - return unmarshalBody[MeshLocation](c.doAuthenticatedRequest("POST", c.endpoints.Locations, - withPayload(location, CONTENT_TYPE_LOCATION), - )) +func (c MeshLocationClient) Create(location *MeshLocationCreate) (*MeshLocation, error) { + return c.post(location) } -func (c *MeshStackProviderClient) UpdateLocation(name string, location *MeshLocationCreate) (*MeshLocation, error) { - return unmarshalBody[MeshLocation](c.doAuthenticatedRequest("PUT", c.urlForLocation(name), - withPayload(location, CONTENT_TYPE_LOCATION), - )) +func (c MeshLocationClient) Update(name string, location *MeshLocationCreate) (*MeshLocation, error) { + return c.put(name, location) } -func (c *MeshStackProviderClient) DeleteLocation(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForLocation(name), - withAccept(CONTENT_TYPE_LOCATION), - ) - return err +func (c MeshLocationClient) Delete(name string) error { + return c.delete(name) } diff --git a/payment_method.go b/payment_method.go index 06498361..84f73f2b 100644 --- a/payment_method.go +++ b/payment_method.go @@ -1,11 +1,5 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_PAYMENT_METHOD = "application/vnd.meshcloud.api.meshpaymentmethod.v2.hal+json" - type MeshPaymentMethod struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -38,31 +32,22 @@ type MeshPaymentMethodCreateMetadata struct { OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } -func (c *MeshStackProviderClient) urlForPaymentMethod(identifier string) *url.URL { - return c.endpoints.PaymentMethods.JoinPath(identifier) +type MeshPaymentMethodClient struct { + meshObjectClient[MeshPaymentMethod] } -func (c *MeshStackProviderClient) ReadPaymentMethod(workspace string, identifier string) (*MeshPaymentMethod, error) { - return unmarshalBodyIfPresent[MeshPaymentMethod](c.doAuthenticatedRequest("GET", c.urlForPaymentMethod(identifier), - withAccept(CONTENT_TYPE_PAYMENT_METHOD), - )) +func (c MeshPaymentMethodClient) Read(workspace string, identifier string) (*MeshPaymentMethod, error) { + return c.get(identifier) } -func (c *MeshStackProviderClient) CreatePaymentMethod(paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { - return unmarshalBody[MeshPaymentMethod](c.doAuthenticatedRequest("POST", c.endpoints.PaymentMethods, - withPayload(paymentMethod, CONTENT_TYPE_PAYMENT_METHOD), - )) +func (c MeshPaymentMethodClient) Create(paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { + return c.post(paymentMethod) } -func (c *MeshStackProviderClient) UpdatePaymentMethod(identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { - return unmarshalBody[MeshPaymentMethod](c.doAuthenticatedRequest("PUT", c.urlForPaymentMethod(identifier), - withPayload(paymentMethod, CONTENT_TYPE_PAYMENT_METHOD), - )) +func (c MeshPaymentMethodClient) Update(identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { + return c.put(identifier, paymentMethod) } -func (c *MeshStackProviderClient) DeletePaymentMethod(identifier string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForPaymentMethod(identifier), - withAccept(CONTENT_TYPE_PAYMENT_METHOD), - ) - return err +func (c MeshPaymentMethodClient) Delete(identifier string) error { + return c.delete(identifier) } diff --git a/platform.go b/platform.go index e4d0081d..cc325251 100644 --- a/platform.go +++ b/platform.go @@ -1,11 +1,5 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_PLATFORM = "application/vnd.meshcloud.api.meshplatform.v2-preview.hal+json" - type MeshPlatform struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -109,31 +103,22 @@ type TagMapper struct { ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` } -func (c *MeshStackProviderClient) urlForPlatform(uuid string) *url.URL { - return c.endpoints.Platforms.JoinPath(uuid) +type MeshPlatformClient struct { + meshObjectClient[MeshPlatform] } -func (c *MeshStackProviderClient) ReadPlatform(uuid string) (*MeshPlatform, error) { - return unmarshalBodyIfPresent[MeshPlatform](c.doAuthenticatedRequest("GET", c.urlForPlatform(uuid), - withAccept(CONTENT_TYPE_PLATFORM), - )) +func (c MeshPlatformClient) Read(uuid string) (*MeshPlatform, error) { + return c.get(uuid) } -func (c *MeshStackProviderClient) CreatePlatform(platform *MeshPlatformCreate) (*MeshPlatform, error) { - return unmarshalBody[MeshPlatform](c.doAuthenticatedRequest("POST", c.endpoints.Platforms, - withPayload(platform, CONTENT_TYPE_PLATFORM), - )) +func (c MeshPlatformClient) Create(platform *MeshPlatformCreate) (*MeshPlatform, error) { + return c.post(platform) } -func (c *MeshStackProviderClient) DeletePlatform(uuid string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForPlatform(uuid), - withAccept(CONTENT_TYPE_PLATFORM), - ) - return err +func (c MeshPlatformClient) Update(uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { + return c.put(uuid, platform) } -func (c *MeshStackProviderClient) UpdatePlatform(uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { - return unmarshalBody[MeshPlatform](c.doAuthenticatedRequest("PUT", c.urlForPlatform(uuid), - withPayload(platform, CONTENT_TYPE_PLATFORM), - )) +func (c MeshPlatformClient) Delete(uuid string) error { + return c.delete(uuid) } diff --git a/project.go b/project.go index 8a39499b..cb509b2c 100644 --- a/project.go +++ b/project.go @@ -1,11 +1,5 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_PROJECT = "application/vnd.meshcloud.api.meshproject.v2.hal+json" - type MeshProject struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -37,43 +31,36 @@ type MeshProjectCreateMetadata struct { OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } -func (c *MeshStackProviderClient) urlForProject(workspace string, name string) *url.URL { - identifier := workspace + "." + name - return c.endpoints.Projects.JoinPath(identifier) +type MeshProjectClient struct { + meshObjectClient[MeshProject] +} + +func (c *MeshProjectClient) projectId(workspace string, name string) string { + return workspace + "." + name } -func (c *MeshStackProviderClient) ReadProject(workspace string, name string) (*MeshProject, error) { - return unmarshalBodyIfPresent[MeshProject](c.doAuthenticatedRequest("GET", c.urlForProject(workspace, name), - withAccept(CONTENT_TYPE_PROJECT), - )) +func (c *MeshProjectClient) Read(workspace string, name string) (*MeshProject, error) { + return c.get(c.projectId(workspace, name)) } -func (c *MeshStackProviderClient) ReadProjects(workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { +func (c *MeshProjectClient) List(workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { options := []doRequestOption{ - withAccept(CONTENT_TYPE_PROJECT), withUrlQuery("workspaceIdentifier", workspaceIdentifier), } if paymentMethodIdentifier != nil { options = append(options, withUrlQuery("paymentIdentifier", *paymentMethodIdentifier)) } - return unmarshalBodyPages[MeshProject]("meshProjects", c.doPaginatedRequest(c.endpoints.Projects, options...)) + return c.list(options...) } -func (c *MeshStackProviderClient) CreateProject(project *MeshProjectCreate) (*MeshProject, error) { - return unmarshalBody[MeshProject](c.doAuthenticatedRequest("POST", c.endpoints.Projects, - withPayload(project, CONTENT_TYPE_PROJECT), - )) +func (c *MeshProjectClient) Create(project *MeshProjectCreate) (*MeshProject, error) { + return c.post(project) } -func (c *MeshStackProviderClient) UpdateProject(project *MeshProjectCreate) (*MeshProject, error) { - return unmarshalBody[MeshProject](c.doAuthenticatedRequest("PUT", c.urlForProject(project.Metadata.OwnedByWorkspace, project.Metadata.Name), - withPayload(project, CONTENT_TYPE_PROJECT), - )) +func (c *MeshProjectClient) Update(project *MeshProjectCreate) (*MeshProject, error) { + return c.put(c.projectId(project.Metadata.OwnedByWorkspace, project.Metadata.Name), project) } -func (c *MeshStackProviderClient) DeleteProject(workspace string, name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForProject(workspace, name), - withAccept(CONTENT_TYPE_PROJECT), - ) - return err +func (c *MeshProjectClient) Delete(workspace string, name string) error { + return c.delete(c.projectId(workspace, name)) } diff --git a/project_group_binding.go b/project_group_binding.go index 47378242..96d6640e 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -1,32 +1,19 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_PROJECT_GROUP_BINDING = "application/vnd.meshcloud.api.meshprojectgroupbinding.v3.hal+json" - type MeshProjectGroupBinding = MeshProjectBinding -func (c *MeshStackProviderClient) urlForPojectGroupBinding(name string) *url.URL { - return c.endpoints.ProjectGroupBindings.JoinPath(name) +type MeshProjectGroupBindingClient struct { + meshObjectClient[MeshProjectBinding] } -func (c *MeshStackProviderClient) ReadProjectGroupBinding(name string) (*MeshProjectGroupBinding, error) { - return unmarshalBodyIfPresent[MeshProjectBinding](c.doAuthenticatedRequest("GET", c.urlForPojectGroupBinding(name), - withAccept(CONTENT_TYPE_PROJECT_GROUP_BINDING), - )) +func (c MeshProjectGroupBindingClient) Read(name string) (*MeshProjectGroupBinding, error) { + return c.get(name) } -func (c *MeshStackProviderClient) CreateProjectGroupBinding(binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { - return unmarshalBody[MeshProjectBinding](c.doAuthenticatedRequest("POST", c.endpoints.ProjectGroupBindings, - withPayload(binding, CONTENT_TYPE_PROJECT_GROUP_BINDING), - )) +func (c MeshProjectGroupBindingClient) Create(binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { + return c.post(binding) } -func (c *MeshStackProviderClient) DeleteProjecGroupBinding(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForPojectGroupBinding(name), - withAccept(CONTENT_TYPE_PROJECT_GROUP_BINDING), - ) - return err +func (c MeshProjectGroupBindingClient) Delete(name string) error { + return c.delete(name) } diff --git a/project_user_binding.go b/project_user_binding.go index 9ade8d58..ee619d3b 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -1,32 +1,19 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_PROJECT_USER_BINDING = "application/vnd.meshcloud.api.meshprojectuserbinding.v3.hal+json" - type MeshProjectUserBinding = MeshProjectBinding -func (c *MeshStackProviderClient) urlForPojectUserBinding(name string) *url.URL { - return c.endpoints.ProjectUserBindings.JoinPath(name) +type MeshProjectUserBindingClient struct { + meshObjectClient[MeshProjectBinding] } -func (c *MeshStackProviderClient) ReadProjectUserBinding(name string) (*MeshProjectUserBinding, error) { - return unmarshalBodyIfPresent[MeshProjectBinding](c.doAuthenticatedRequest("GET", c.urlForPojectUserBinding(name), - withAccept(CONTENT_TYPE_PROJECT_USER_BINDING), - )) +func (c MeshProjectUserBindingClient) Read(name string) (*MeshProjectUserBinding, error) { + return c.get(name) } -func (c *MeshStackProviderClient) CreateProjectUserBinding(binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { - return unmarshalBody[MeshProjectBinding](c.doAuthenticatedRequest("POST", c.endpoints.ProjectUserBindings, - withPayload(binding, CONTENT_TYPE_PROJECT_USER_BINDING), - )) +func (c MeshProjectUserBindingClient) Create(binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { + return c.post(binding) } -func (c *MeshStackProviderClient) DeleteProjecUserBinding(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForPojectUserBinding(name), - withAccept(CONTENT_TYPE_PROJECT_USER_BINDING), - ) - return err +func (c MeshProjectUserBindingClient) Delete(name string) error { + return c.delete(name) } diff --git a/tag_definition.go b/tag_definition.go index c5112abe..884b3240 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -1,11 +1,6 @@ package client -import ( - "net/url" -) - const API_VERSION_TAG_DEFINITION = "v1" -const CONTENT_TYPE_TAG_DEFINITION = "application/vnd.meshcloud.api.meshtagdefinition.v1.hal+json" type MeshTagDefinition struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` @@ -68,35 +63,26 @@ type TagValueMultiSelect struct { DefaultValue *[]string `json:"defaultValue,omitempty" tfsdk:"default_value"` } -func (c *MeshStackProviderClient) urlForTagDefinition(name string) *url.URL { - return c.endpoints.TagDefinitions.JoinPath(name) +type MeshTagDefinitionClient struct { + meshObjectClient[MeshTagDefinition] } -func (c *MeshStackProviderClient) ReadTagDefinitions() ([]MeshTagDefinition, error) { - return unmarshalBodyPages[MeshTagDefinition]("meshTagDefinitions", c.doPaginatedRequest(c.endpoints.TagDefinitions, withAccept(CONTENT_TYPE_TAG_DEFINITION))) +func (c MeshTagDefinitionClient) List() ([]MeshTagDefinition, error) { + return c.list() } -func (c *MeshStackProviderClient) ReadTagDefinition(name string) (*MeshTagDefinition, error) { - return unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("GET", c.urlForTagDefinition(name), - withAccept(CONTENT_TYPE_TAG_DEFINITION), - )) +func (c MeshTagDefinitionClient) Read(name string) (*MeshTagDefinition, error) { + return c.get(name) } -func (c *MeshStackProviderClient) CreateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - return unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("POST", c.endpoints.TagDefinitions, - withPayload(tagDefinition, CONTENT_TYPE_TAG_DEFINITION), - )) +func (c MeshTagDefinitionClient) Create(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { + return c.post(tagDefinition) } -func (c *MeshStackProviderClient) UpdateTagDefinition(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - return unmarshalBody[MeshTagDefinition](c.doAuthenticatedRequest("PUT", c.urlForTagDefinition(tagDefinition.Metadata.Name), - withPayload(tagDefinition, CONTENT_TYPE_TAG_DEFINITION), - )) +func (c MeshTagDefinitionClient) Update(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { + return c.put(tagDefinition.Metadata.Name, tagDefinition) } -func (c *MeshStackProviderClient) DeleteTagDefinition(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForTagDefinition(name), - withAccept(CONTENT_TYPE_TAG_DEFINITION), - ) - return err +func (c MeshTagDefinitionClient) Delete(name string) error { + return c.delete(name) } diff --git a/tenant.go b/tenant.go index b187f83d..d1b5a32d 100644 --- a/tenant.go +++ b/tenant.go @@ -1,11 +1,5 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_TENANT = "application/vnd.meshcloud.api.meshtenant.v3.hal+json" - type MeshTenant struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -49,26 +43,22 @@ type MeshTenantCreateSpec struct { Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` } -func (c *MeshStackProviderClient) urlForTenant(workspace string, project string, platform string) *url.URL { - identifier := workspace + "." + project + "." + platform - return c.endpoints.Tenants.JoinPath(identifier) +type MeshTenantClient struct { + meshObjectClient[MeshTenant] +} + +func (c *MeshTenantClient) tenantId(workspace string, project string, platform string) string { + return workspace + "." + project + "." + platform } -func (c *MeshStackProviderClient) ReadTenant(workspace string, project string, platform string) (*MeshTenant, error) { - return unmarshalBodyIfPresent[MeshTenant](c.doAuthenticatedRequest("GET", c.urlForTenant(workspace, project, platform), - withAccept(CONTENT_TYPE_TENANT), - )) +func (c *MeshTenantClient) Read(workspace string, project string, platform string) (*MeshTenant, error) { + return c.get(c.tenantId(workspace, project, platform)) } -func (c *MeshStackProviderClient) CreateTenant(tenant *MeshTenantCreate) (*MeshTenant, error) { - return unmarshalBody[MeshTenant](c.doAuthenticatedRequest("POST", c.endpoints.Tenants, - withPayload(tenant, CONTENT_TYPE_TENANT), - )) +func (c *MeshTenantClient) Create(tenant *MeshTenantCreate) (*MeshTenant, error) { + return c.post(tenant) } -func (c *MeshStackProviderClient) DeleteTenant(workspace string, project string, platform string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForTenant(workspace, project, platform), - withAccept(CONTENT_TYPE_TENANT), - ) - return err +func (c *MeshTenantClient) Delete(workspace string, project string, platform string) error { + return c.delete(c.tenantId(workspace, project, platform)) } diff --git a/tenant_v4.go b/tenant_v4.go index 6243a957..226ba419 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -3,14 +3,11 @@ package client import ( "context" "fmt" - "net/url" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" ) -const CONTENT_TYPE_TENANT_V4 = "application/vnd.meshcloud.api.meshtenant.v4-preview.hal+json" - type MeshTenantV4 struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -59,42 +56,35 @@ type MeshTenantV4CreateSpec struct { Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` } -func (c *MeshStackProviderClient) urlForTenantV4(uuid string) *url.URL { - return c.endpoints.Tenants.JoinPath(uuid) +type MeshTenantV4Client struct { + meshObjectClient[MeshTenantV4] } -func (c *MeshStackProviderClient) ReadTenantV4(uuid string) (*MeshTenantV4, error) { - return unmarshalBodyIfPresent[MeshTenantV4](c.doAuthenticatedRequest("GET", c.urlForTenantV4(uuid), - withAccept(CONTENT_TYPE_TENANT_V4), - )) +func (c MeshTenantV4Client) Read(uuid string) (*MeshTenantV4, error) { + return c.get(uuid) } -func (c *MeshStackProviderClient) CreateTenantV4(tenant *MeshTenantV4Create) (*MeshTenantV4, error) { - return unmarshalBody[MeshTenantV4](c.doAuthenticatedRequest("POST", c.endpoints.Tenants, - withPayload(tenant, CONTENT_TYPE_TENANT_V4), - )) +func (c MeshTenantV4Client) Create(tenant *MeshTenantV4Create) (*MeshTenantV4, error) { + return c.post(tenant) } -func (c *MeshStackProviderClient) DeleteTenantV4(uuid string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForTenantV4(uuid), - withAccept(CONTENT_TYPE_TENANT_V4), - ) - return err +func (c MeshTenantV4Client) Delete(uuid string) error { + return c.delete(uuid) } -// PollTenantV4UntilCreation polls a tenant until creation completes (platformTenantId is set) +// PollUntilCreation polls a tenant until creation completes (platformTenantId is set) // Returns the final tenant state or an error if polling fails or times out. -func (c *MeshStackProviderClient) PollTenantV4UntilCreation(ctx context.Context, uuid string) (*MeshTenantV4, error) { +func (c MeshTenantV4Client) PollUntilCreation(ctx context.Context, uuid string) (*MeshTenantV4, error) { var result *MeshTenantV4 - err := retry.RetryContext(ctx, 30*time.Minute, c.waitForTenantV4CreationFunc(uuid, &result)) + err := retry.RetryContext(ctx, 30*time.Minute, c.waitForCreationFunc(uuid, &result)) return result, err } -// waitForTenantV4CreationFunc returns a RetryFunc that checks tenant creation status. -func (c *MeshStackProviderClient) waitForTenantV4CreationFunc(uuid string, result **MeshTenantV4) retry.RetryFunc { +// waitForCreationFunc returns a RetryFunc that checks tenant creation status. +func (c MeshTenantV4Client) waitForCreationFunc(uuid string, result **MeshTenantV4) retry.RetryFunc { return func() *retry.RetryError { - current, err := c.ReadTenantV4(uuid) + current, err := c.Read(uuid) if err != nil { return retry.NonRetryableError(fmt.Errorf("could not read tenant status while waiting for creation: %w", err)) } @@ -114,16 +104,16 @@ func (c *MeshStackProviderClient) waitForTenantV4CreationFunc(uuid string, resul } } -// PollTenantV4UntilDeletion polls a tenant until it is deleted (not found) +// PollUntilDeletion polls a tenant until it is deleted (not found) // Returns nil on successful deletion or an error if polling fails or times out. -func (c *MeshStackProviderClient) PollTenantV4UntilDeletion(ctx context.Context, uuid string) error { - return retry.RetryContext(ctx, 30*time.Minute, c.waitForTenantV4DeletionFunc(uuid)) +func (c MeshTenantV4Client) PollUntilDeletion(ctx context.Context, uuid string) error { + return retry.RetryContext(ctx, 30*time.Minute, c.waitForDeletionFunc(uuid)) } -// waitForTenantV4DeletionFunc returns a RetryFunc that checks tenant deletion status. -func (c *MeshStackProviderClient) waitForTenantV4DeletionFunc(uuid string) retry.RetryFunc { +// waitForDeletionFunc returns a RetryFunc that checks tenant deletion status. +func (c MeshTenantV4Client) waitForDeletionFunc(uuid string) retry.RetryFunc { return func() *retry.RetryError { - current, err := c.ReadTenantV4(uuid) + current, err := c.Read(uuid) if err != nil { return retry.NonRetryableError(fmt.Errorf("could not read tenant status while waiting for deletion: %w", err)) } diff --git a/workspace.go b/workspace.go index 309ec385..695895e4 100644 --- a/workspace.go +++ b/workspace.go @@ -1,11 +1,5 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_WORKSPACE = "application/vnd.meshcloud.api.meshworkspace.v2.hal+json" - type MeshWorkspace struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -35,31 +29,22 @@ type MeshWorkspaceCreateMetadata struct { Tags map[string][]string `json:"tags" tfsdk:"tags"` } -func (c *MeshStackProviderClient) urlForWorkspace(name string) *url.URL { - return c.endpoints.Workspaces.JoinPath(name) +type MeshWorkspaceClient struct { + meshObjectClient[MeshWorkspace] } -func (c *MeshStackProviderClient) ReadWorkspace(name string) (*MeshWorkspace, error) { - return unmarshalBodyIfPresent[MeshWorkspace](c.doAuthenticatedRequest("GET", c.urlForWorkspace(name), - withAccept(CONTENT_TYPE_WORKSPACE), - )) +func (c MeshWorkspaceClient) Read(name string) (*MeshWorkspace, error) { + return c.get(name) } -func (c *MeshStackProviderClient) CreateWorkspace(workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { - return unmarshalBody[MeshWorkspace](c.doAuthenticatedRequest("POST", c.endpoints.Workspaces, - withPayload(workspace, CONTENT_TYPE_WORKSPACE), - )) +func (c MeshWorkspaceClient) Create(workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { + return c.post(workspace) } -func (c *MeshStackProviderClient) UpdateWorkspace(name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { - return unmarshalBody[MeshWorkspace](c.doAuthenticatedRequest("PUT", c.urlForWorkspace(name), - withPayload(workspace, CONTENT_TYPE_WORKSPACE), - )) +func (c MeshWorkspaceClient) Update(name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { + return c.put(name, workspace) } -func (c *MeshStackProviderClient) DeleteWorkspace(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspace(name), - withAccept(CONTENT_TYPE_WORKSPACE), - ) - return err +func (c MeshWorkspaceClient) Delete(name string) error { + return c.delete(name) } diff --git a/workspace_group_binding.go b/workspace_group_binding.go index 2fddb5c3..787edba6 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -1,32 +1,19 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_WORKSPACE_GROUP_BINDING = "application/vnd.meshcloud.api.meshworkspacegroupbinding.v2.hal+json" - type MeshWorkspaceGroupBinding = MeshWorkspaceBinding -func (c *MeshStackProviderClient) urlForWorkspaceGroupBinding(name string) *url.URL { - return c.endpoints.WorkspaceGroupBindings.JoinPath(name) +type MeshWorkspaceGroupBindingClient struct { + meshObjectClient[MeshWorkspaceBinding] } -func (c *MeshStackProviderClient) ReadWorkspaceGroupBinding(name string) (*MeshWorkspaceGroupBinding, error) { - return unmarshalBodyIfPresent[MeshWorkspaceBinding](c.doAuthenticatedRequest("GET", c.urlForWorkspaceGroupBinding(name), - withAccept(CONTENT_TYPE_WORKSPACE_GROUP_BINDING), - )) +func (c MeshWorkspaceGroupBindingClient) Read(name string) (*MeshWorkspaceGroupBinding, error) { + return c.get(name) } -func (c *MeshStackProviderClient) CreateWorkspaceGroupBinding(binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { - return unmarshalBody[MeshWorkspaceBinding](c.doAuthenticatedRequest("POST", c.endpoints.WorkspaceGroupBindings, - withPayload(binding, CONTENT_TYPE_WORKSPACE_GROUP_BINDING), - )) +func (c MeshWorkspaceGroupBindingClient) Create(binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { + return c.post(binding) } -func (c *MeshStackProviderClient) DeleteWorkspaceGroupBinding(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspaceGroupBinding(name), - withAccept(CONTENT_TYPE_WORKSPACE_GROUP_BINDING), - ) - return err +func (c MeshWorkspaceGroupBindingClient) Delete(name string) error { + return c.delete(name) } diff --git a/workspace_user_binding.go b/workspace_user_binding.go index c10d77ca..0ae752a7 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -1,32 +1,19 @@ package client -import ( - "net/url" -) - -const CONTENT_TYPE_WORKSPACE_USER_BINDING = "application/vnd.meshcloud.api.meshworkspaceuserbinding.v2.hal+json" - type MeshWorkspaceUserBinding = MeshWorkspaceBinding -func (c *MeshStackProviderClient) urlForWorkspaceUserBinding(name string) *url.URL { - return c.endpoints.WorkspaceUserBindings.JoinPath(name) +type MeshWorkspaceUserBindingClient struct { + meshObjectClient[MeshWorkspaceBinding] } -func (c *MeshStackProviderClient) ReadWorkspaceUserBinding(name string) (*MeshWorkspaceUserBinding, error) { - return unmarshalBodyIfPresent[MeshWorkspaceBinding](c.doAuthenticatedRequest("GET", c.urlForWorkspaceUserBinding(name), - withAccept(CONTENT_TYPE_WORKSPACE_USER_BINDING), - )) +func (c MeshWorkspaceUserBindingClient) Read(name string) (*MeshWorkspaceUserBinding, error) { + return c.get(name) } -func (c *MeshStackProviderClient) CreateWorkspaceUserBinding(binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { - return unmarshalBody[MeshWorkspaceBinding](c.doAuthenticatedRequest("POST", c.endpoints.WorkspaceUserBindings, - withPayload(binding, CONTENT_TYPE_WORKSPACE_USER_BINDING), - )) +func (c MeshWorkspaceUserBindingClient) Create(binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { + return c.post(binding) } -func (c *MeshStackProviderClient) DeleteWorkspaceUserBinding(name string) error { - _, err := c.doAuthenticatedRequest("DELETE", c.urlForWorkspaceUserBinding(name), - withAccept(CONTENT_TYPE_WORKSPACE_USER_BINDING), - ) - return err +func (c MeshWorkspaceUserBindingClient) Delete(name string) error { + return c.delete(name) } From bff3a79d71210a9b09834fb2cdb2c6e49db58b2e Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 22:42:57 +0100 Subject: [PATCH 073/215] refactor: simplify httpClient methods and url query handling --- client.go | 193 ++++++++++++++++++++++++----------------------------- project.go | 12 ++-- tenant.go | 8 +-- 3 files changed, 97 insertions(+), 116 deletions(-) diff --git a/client.go b/client.go index 84cd4233..88cf054e 100644 --- a/client.go +++ b/client.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "iter" "log" "net/http" "net/url" @@ -97,31 +96,66 @@ func pluralizeName(name string) string { return fmt.Sprintf("%ss", name) } -func (o meshObjectClient[M]) mediaType() string { - return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", o.Name, o.ApiVersion) +func (c meshObjectClient[M]) meshObjectMimeType() string { + return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", c.Name, c.ApiVersion) } func (c meshObjectClient[M]) get(id string) (*M, error) { - return unmarshalBodyIfPresent[M](c.doAuthenticatedRequest(http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.mediaType()))) -} - -func (c meshObjectClient[M]) list(options ...doRequestOption) ([]M, error) { - return unmarshalBodyPages[M](pluralizeName(c.Name), c.doPaginatedRequest(c.ApiUrl, append(options, withAccept(c.mediaType()))...)) + body, err := c.doAuthenticatedRequest(http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) + if errors.Is(err, errNotFound) { + return nil, nil + } + return unmarshalBody[M](body, err) } func (c meshObjectClient[M]) post(payload any) (*M, error) { - return unmarshalBody[M](c.doAuthenticatedRequest(http.MethodPost, c.ApiUrl, withPayload(payload, c.mediaType()))) + return unmarshalBody[M](c.doAuthenticatedRequest(http.MethodPost, c.ApiUrl, withPayload(payload, c.meshObjectMimeType()))) } func (c meshObjectClient[M]) put(id string, payload any) (*M, error) { - return unmarshalBody[M](c.doAuthenticatedRequest(http.MethodPut, c.ApiUrl.JoinPath(id), withPayload(payload, c.mediaType()))) + return unmarshalBody[M](c.doAuthenticatedRequest(http.MethodPut, c.ApiUrl.JoinPath(id), withPayload(payload, c.meshObjectMimeType()))) } func (c meshObjectClient[M]) delete(id string) (err error) { - _, err = c.doAuthenticatedRequest(http.MethodDelete, c.ApiUrl.JoinPath(id), withAccept(c.mediaType())) + _, err = c.doAuthenticatedRequest(http.MethodDelete, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) return } +func (c meshObjectClient[M]) list(options ...doRequestOption) ([]M, error) { + var result []M + embeddedKey := pluralizeName(c.Name) + pageNumber := 0 + for { + body, err := c.doAuthenticatedRequest(http.MethodGet, c.ApiUrl, append(options, + withAccept(c.meshObjectMimeType()), + withUrlQuery("page", pageNumber), + )...) + if err != nil { + return result, fmt.Errorf("cannot fetch page %d: %w", pageNumber, err) + } + type paginatedResponse struct { + Embedded map[string][]M `json:"_embedded"` + Page struct { + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` + } + response, err := unmarshalBody[paginatedResponse](body, err) + if err != nil { + return result, fmt.Errorf("cannot unmarshal paginated response, page %d: %w", pageNumber, err) + } else if items, ok := response.Embedded[embeddedKey]; !ok { + return result, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey) + } else { + result = append(result, items...) + } + // check if we've reached the end of pagination + if response.Page.Number >= response.Page.TotalPages-1 { + return result, nil + } + pageNumber++ + } +} + func (c *httpClient) login() error { loginApiUrl := c.RootUrl.JoinPath("/api/login") @@ -156,22 +190,14 @@ func (c *httpClient) ensureValidToken() error { type doRequestOption func(opts *doRequestOptions) -type urlModifier func(url *url.URL) - type requestModifier func(req *http.Request) type doRequestOptions struct { - urlModifiers []urlModifier + urlQueryParams map[string]string requestPayload any requestModifiers []requestModifier } -func appendUrlModifier(modifier urlModifier) doRequestOption { - return func(opts *doRequestOptions) { - opts.urlModifiers = append(opts.urlModifiers, modifier) - } -} - func appendRequestModifier(modifier requestModifier) doRequestOption { return func(opts *doRequestOptions) { opts.requestModifiers = append(opts.requestModifiers, modifier) @@ -179,17 +205,19 @@ func appendRequestModifier(modifier requestModifier) doRequestOption { } func withUrlQuery(key string, value any) doRequestOption { - return appendUrlModifier(func(url *url.URL) { + return func(opts *doRequestOptions) { var valueStr string if stringerValue, ok := value.(fmt.Stringer); ok { valueStr = stringerValue.String() } else { valueStr = fmt.Sprintf("%v", value) } - query := url.Query() - query.Set(key, valueStr) - url.RawQuery = query.Encode() - }) + if opts.urlQueryParams == nil { + opts.urlQueryParams = map[string]string{key: valueStr} + } else { + opts.urlQueryParams[key] = valueStr + } + } } func withAccept(accept string) doRequestOption { @@ -222,32 +250,10 @@ func (c *httpClient) doRequest(method string, url *url.URL, options ...doRequest for _, option := range options { option(&opts) } - - if len(opts.urlModifiers) > 0 { - // clone url to prevent modifiers edit the given URL (it's sad that this is a pointer actually) - // ignoring the error is fine as this always succeeds parsing from String() - url, _ = url.Parse(url.String()) - for _, modifier := range opts.urlModifiers { - modifier(url) - } - } - - var requestBody io.ReadWriter - if opts.requestPayload != nil { - requestBody = new(bytes.Buffer) - if err := json.NewEncoder(requestBody).Encode(opts.requestPayload); err != nil { - return nil, fmt.Errorf("failed to encode request body payload: %w", err) - } - } - - req, err := http.NewRequest(method, url.String(), requestBody) + req, err := c.buildRequest(method, *url, opts) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - for _, requestModifier := range opts.requestModifiers { - requestModifier(req) + return nil, err } - res, err := c.Do(req) if err != nil { return nil, err @@ -256,7 +262,10 @@ func (c *httpClient) doRequest(method string, url *url.URL, options ...doRequest _ = res.Body.Close() }() log.Println(res) + return c.readBodyAndCheckSuccess(res) +} +func (c *httpClient) readBodyAndCheckSuccess(res *http.Response) ([]byte, error) { responseBody, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) @@ -279,6 +288,35 @@ func (c *httpClient) doRequest(method string, url *url.URL, options ...doRequest return responseBody, errors.Join(errs...) } +func (c *httpClient) buildRequest(method string, url url.URL, opts doRequestOptions) (*http.Request, error) { + if len(opts.urlQueryParams) > 0 { + query := url.Query() + for k, v := range opts.urlQueryParams { + query.Set(k, v) + } + // Note: url is not a pointer here, + // so we can safely update that struct field without propagating such change to the caller! + url.RawQuery = query.Encode() + } + + var requestBody io.ReadWriter + if opts.requestPayload != nil { + requestBody = new(bytes.Buffer) + if err := json.NewEncoder(requestBody).Encode(opts.requestPayload); err != nil { + return nil, fmt.Errorf("failed to encode request body payload: %w", err) + } + } + + req, err := http.NewRequest(method, url.String(), requestBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + for _, requestModifier := range opts.requestModifiers { + requestModifier(req) + } + return req, err +} + func (c *httpClient) doAuthenticatedRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { if err := c.ensureValidToken(); err != nil { return nil, err @@ -292,39 +330,6 @@ func (c *httpClient) doAuthenticatedRequest(method string, url *url.URL, options )...) } -func (c *httpClient) doPaginatedRequest(url *url.URL, options ...doRequestOption) iter.Seq2[[]byte, error] { - return func(yield func([]byte, error) bool) { - pageNumber := 0 - for { - body, err := c.doAuthenticatedRequest(http.MethodGet, url, append(options, withUrlQuery("page", pageNumber))...) - if err != nil { - yield(body, fmt.Errorf("cannot fetch page %d: %w", pageNumber, err)) - return - } - if !yield(body, nil) { - // consumer wants to stop - return - } - // Check if there are more pages to fetch - type paginatedResponse struct { - Page struct { - TotalPages int `json:"totalPages"` - Number int `json:"number"` - } `json:"page"` - } - response, err := unmarshalBody[paginatedResponse](body, err) - if err != nil { - yield(body, fmt.Errorf("cannot unmarshal paginated response, page %d: %w", pageNumber, err)) - return - } - if response.Page.Number >= response.Page.TotalPages-1 { - return - } - pageNumber++ - } - } -} - func unmarshalBody[T any](body []byte, err error) (*T, error) { if err != nil { return nil, err @@ -335,27 +340,3 @@ func unmarshalBody[T any](body []byte, err error) (*T, error) { } return &target, nil } - -func unmarshalBodyIfPresent[T any](body []byte, err error) (*T, error) { - if errors.Is(err, errNotFound) { - return nil, nil - } - return unmarshalBody[T](body, err) -} - -func unmarshalBodyPages[T any](embeddedKey string, bodyPages iter.Seq2[[]byte, error]) ([]T, error) { - var result []T - for bodyPage, pageErr := range bodyPages { - type embeddedResponse[T any] struct { - Embedded map[string][]T `json:"_embedded"` - } - if response, err := unmarshalBody[embeddedResponse[T]](bodyPage, pageErr); err != nil { - return result, err - } else if items, ok := response.Embedded[embeddedKey]; !ok { - return result, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey) - } else { - result = append(result, items...) - } - } - return result, nil -} diff --git a/project.go b/project.go index cb509b2c..5aca292b 100644 --- a/project.go +++ b/project.go @@ -35,15 +35,15 @@ type MeshProjectClient struct { meshObjectClient[MeshProject] } -func (c *MeshProjectClient) projectId(workspace string, name string) string { +func (c MeshProjectClient) projectId(workspace string, name string) string { return workspace + "." + name } -func (c *MeshProjectClient) Read(workspace string, name string) (*MeshProject, error) { +func (c MeshProjectClient) Read(workspace string, name string) (*MeshProject, error) { return c.get(c.projectId(workspace, name)) } -func (c *MeshProjectClient) List(workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { +func (c MeshProjectClient) List(workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { options := []doRequestOption{ withUrlQuery("workspaceIdentifier", workspaceIdentifier), } @@ -53,14 +53,14 @@ func (c *MeshProjectClient) List(workspaceIdentifier string, paymentMethodIdenti return c.list(options...) } -func (c *MeshProjectClient) Create(project *MeshProjectCreate) (*MeshProject, error) { +func (c MeshProjectClient) Create(project *MeshProjectCreate) (*MeshProject, error) { return c.post(project) } -func (c *MeshProjectClient) Update(project *MeshProjectCreate) (*MeshProject, error) { +func (c MeshProjectClient) Update(project *MeshProjectCreate) (*MeshProject, error) { return c.put(c.projectId(project.Metadata.OwnedByWorkspace, project.Metadata.Name), project) } -func (c *MeshProjectClient) Delete(workspace string, name string) error { +func (c MeshProjectClient) Delete(workspace string, name string) error { return c.delete(c.projectId(workspace, name)) } diff --git a/tenant.go b/tenant.go index d1b5a32d..6acaa5f2 100644 --- a/tenant.go +++ b/tenant.go @@ -47,18 +47,18 @@ type MeshTenantClient struct { meshObjectClient[MeshTenant] } -func (c *MeshTenantClient) tenantId(workspace string, project string, platform string) string { +func (c MeshTenantClient) tenantId(workspace string, project string, platform string) string { return workspace + "." + project + "." + platform } -func (c *MeshTenantClient) Read(workspace string, project string, platform string) (*MeshTenant, error) { +func (c MeshTenantClient) Read(workspace string, project string, platform string) (*MeshTenant, error) { return c.get(c.tenantId(workspace, project, platform)) } -func (c *MeshTenantClient) Create(tenant *MeshTenantCreate) (*MeshTenant, error) { +func (c MeshTenantClient) Create(tenant *MeshTenantCreate) (*MeshTenant, error) { return c.post(tenant) } -func (c *MeshTenantClient) Delete(workspace string, project string, platform string) error { +func (c MeshTenantClient) Delete(workspace string, project string, platform string) error { return c.delete(c.tenantId(workspace, project, platform)) } From 3c6c8473829530c13a96f514a524fc5b1d32705b Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 22:48:31 +0100 Subject: [PATCH 074/215] feat: change user agent such that the provider version is included --- client.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/client.go b/client.go index 88cf054e..d15a0cfd 100644 --- a/client.go +++ b/client.go @@ -37,13 +37,14 @@ type MeshStackProviderClient struct { WorkspaceUserBinding MeshWorkspaceUserBindingClient } -func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) MeshStackProviderClient { +func NewClient(rootUrl *url.URL, providerVersion, apiKey, apiSecret string) MeshStackProviderClient { // Initialize httpClient for typed clients c := &httpClient{ - Client: http.Client{Timeout: 5 * time.Minute}, - RootUrl: rootUrl, - ApiKey: apiKey, - ApiSecret: apiSecret, + Client: http.Client{Timeout: 5 * time.Minute}, + RootUrl: rootUrl, + ProviderVersion: providerVersion, + ApiKey: apiKey, + ApiSecret: apiSecret, } return MeshStackProviderClient{ MeshBuildingBlockClient{newMeshObjectClient[MeshBuildingBlock](c, "meshBuildingBlock", "v1")}, @@ -67,7 +68,9 @@ func NewClient(rootUrl *url.URL, apiKey string, apiSecret string) MeshStackProvi type httpClient struct { http.Client - RootUrl *url.URL + RootUrl *url.URL + ProviderVersion string + ApiKey string ApiSecret string Token string @@ -244,7 +247,7 @@ func withPayload(payload any, contentType string) doRequestOption { func (c *httpClient) doRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { // prepend (aka insert at 0) some default options such that given options may be overridden by caller options = slices.Insert(options, 0, - withHeader("User-Agent", "meshStack Terraform Provider"), + withHeader("User-Agent", fmt.Sprintf("terraform-provider-meshstack/%s", c.ProviderVersion)), ) opts := doRequestOptions{} for _, option := range options { From 788b5d192bf88c849eb2be123782297243ad0865 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 9 Jan 2026 23:54:38 +0100 Subject: [PATCH 075/215] refactor: clean up client.NewClient() with factory methods and reflection --- buildingblock.go | 4 +++ buildingblock_v2.go | 4 +++ client.go | 55 ++++++++++++++++++++---------- client_test.go | 68 ++++++++++++++++++++++++++++++++++++++ integrations.go | 4 +++ landingzone.go | 4 +++ location.go | 4 +++ payment_method.go | 4 +++ platform.go | 4 +++ project.go | 4 +++ project_group_binding.go | 10 ++++-- project_user_binding.go | 10 ++++-- tag_definition.go | 4 +++ tenant.go | 4 +++ tenant_v4.go | 4 +++ workspace.go | 4 +++ workspace_group_binding.go | 10 ++++-- workspace_user_binding.go | 10 ++++-- 18 files changed, 186 insertions(+), 25 deletions(-) create mode 100644 client_test.go diff --git a/buildingblock.go b/buildingblock.go index 19f4f440..3798b34a 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -74,6 +74,10 @@ type MeshBuildingBlockClient struct { meshObjectClient[MeshBuildingBlock] } +func newBuildingBlockClient(c *httpClient) MeshBuildingBlockClient { + return MeshBuildingBlockClient{newMeshObjectClient[MeshBuildingBlock](c, "v1")} +} + func (c MeshBuildingBlockClient) Read(uuid string) (*MeshBuildingBlock, error) { return c.get(uuid) } diff --git a/buildingblock_v2.go b/buildingblock_v2.go index b767b762..b69cf739 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -69,6 +69,10 @@ type MeshBuildingBlockV2Client struct { meshObjectClient[MeshBuildingBlockV2] } +func newBuildingBlockV2Client(c *httpClient) MeshBuildingBlockV2Client { + return MeshBuildingBlockV2Client{newMeshObjectClient[MeshBuildingBlockV2](c, "v2-preview")} +} + func (c MeshBuildingBlockV2Client) Read(uuid string) (*MeshBuildingBlockV2, error) { return c.get(uuid) } diff --git a/client.go b/client.go index d15a0cfd..7b938c57 100644 --- a/client.go +++ b/client.go @@ -9,9 +9,11 @@ import ( "log" "net/http" "net/url" + "reflect" "slices" "strings" "time" + "unicode" ) var ( @@ -47,22 +49,22 @@ func NewClient(rootUrl *url.URL, providerVersion, apiKey, apiSecret string) Mesh ApiSecret: apiSecret, } return MeshStackProviderClient{ - MeshBuildingBlockClient{newMeshObjectClient[MeshBuildingBlock](c, "meshBuildingBlock", "v1")}, - MeshBuildingBlockV2Client{newMeshObjectClient[MeshBuildingBlockV2](c, "meshBuildingBlock", "v2-preview")}, - MeshIntegrationClient{newMeshObjectClient[MeshIntegration](c, "meshIntegration", "v1-preview")}, - MeshLandingZoneClient{newMeshObjectClient[MeshLandingZone](c, "meshLandingZone", "v1-preview")}, - MeshLocationClient{newMeshObjectClient[MeshLocation](c, "meshLocation", "v1-preview")}, - MeshPaymentMethodClient{newMeshObjectClient[MeshPaymentMethod](c, "meshPaymentMethod", "v2")}, - MeshPlatformClient{newMeshObjectClient[MeshPlatform](c, "meshPlatform", "v2-preview")}, - MeshProjectClient{newMeshObjectClient[MeshProject](c, "meshProject", "v2")}, - MeshProjectGroupBindingClient{newMeshObjectClient[MeshProjectBinding](c, "meshProjectGroupBinding", "v3", "meshprojectbindings", "groupbindings")}, - MeshProjectUserBindingClient{newMeshObjectClient[MeshProjectBinding](c, "meshProjectUserBinding", "v3", "meshprojectbindings", "userbindings")}, - MeshTagDefinitionClient{newMeshObjectClient[MeshTagDefinition](c, "meshTagDefinition", "v1")}, - MeshTenantClient{newMeshObjectClient[MeshTenant](c, "meshTenant", "v3")}, - MeshTenantV4Client{newMeshObjectClient[MeshTenantV4](c, "meshTenant", "v4-preview")}, - MeshWorkspaceClient{newMeshObjectClient[MeshWorkspace](c, "meshWorkspace", "v2")}, - MeshWorkspaceGroupBindingClient{newMeshObjectClient[MeshWorkspaceBinding](c, "meshWorkspaceGroupBinding", "v2", "meshworkspacebindings", "groupbindings")}, - MeshWorkspaceUserBindingClient{newMeshObjectClient[MeshWorkspaceBinding](c, "meshWorkspaceUserBinding", "v2", "meshworkspacebindings", "userbindings")}, + newBuildingBlockClient(c), + newBuildingBlockV2Client(c), + newIntegrationClient(c), + newLandingZoneClient(c), + newLocationClient(c), + newPaymentMethodClient(c), + newPlatformClient(c), + newProjectClient(c), + newProjectGroupBindingClient(c), + newProjectUserBindingClient(c), + newTagDefinitionClient(c), + newTenantClient(c), + newTenantV4Client(c), + newWorkspaceClient(c), + newWorkspaceGroupBindingClient(c), + newWorkspaceUserBindingClient(c), } } @@ -83,7 +85,9 @@ type meshObjectClient[M any] struct { ApiUrl *url.URL } -func newMeshObjectClient[M any](client *httpClient, name, apiVersion string, explicitApiPaths ...string) meshObjectClient[M] { +func newMeshObjectClient[M any](client *httpClient, apiVersion string, explicitApiPaths ...string) meshObjectClient[M] { + name := inferMeshObjectName[M]() + if len(explicitApiPaths) == 0 { // infer API path from meshObject name by default (if nothing explicit is given) explicitApiPaths = []string{strings.ToLower(pluralizeName(name))} @@ -95,6 +99,23 @@ func newMeshObjectClient[M any](client *httpClient, name, apiVersion string, exp return meshObjectClient[M]{client, name, apiVersion, apiUrl} } +// inferMeshObjectName uses reflection to infer the meshObject name from the type parameter M. +// It converts the type name to camelCase (e.g., "MeshBuildingBlock" -> "meshBuildingBlock"). +func inferMeshObjectName[M any]() string { + var zero M + typeName := reflect.TypeOf(zero).Name() + return lowercaseFirst(typeName) +} + +func lowercaseFirst(s string) string { + if s == "" { + return s + } + runes := []rune(s) + runes[0] = unicode.ToLower(runes[0]) + return string(runes) +} + func pluralizeName(name string) string { return fmt.Sprintf("%ss", name) } diff --git a/client_test.go b/client_test.go new file mode 100644 index 00000000..8b5f6f3b --- /dev/null +++ b/client_test.go @@ -0,0 +1,68 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInferMeshObjectName(t *testing.T) { + tests := []struct { + name string + testFunc func() string + expected string + }{ + { + name: "MeshBuildingBlock", + testFunc: inferMeshObjectName[MeshBuildingBlock], + expected: "meshBuildingBlock", + }, + { + name: "MeshBuildingBlockV2", + testFunc: inferMeshObjectName[MeshBuildingBlockV2], + expected: "meshBuildingBlockV2", + }, + { + name: "MeshProject", + testFunc: inferMeshObjectName[MeshProject], + expected: "meshProject", + }, + { + name: "MeshWorkspace", + testFunc: inferMeshObjectName[MeshWorkspace], + expected: "meshWorkspace", + }, + { + name: "MeshProjectBinding", + testFunc: inferMeshObjectName[MeshProjectBinding], + expected: "meshProjectBinding", + }, + { + name: "MeshProjectGroupBinding (embedded struct)", + testFunc: inferMeshObjectName[MeshProjectGroupBinding], + expected: "meshProjectGroupBinding", + }, + { + name: "MeshProjectUserBinding (embedded struct)", + testFunc: inferMeshObjectName[MeshProjectUserBinding], + expected: "meshProjectUserBinding", + }, + { + name: "MeshWorkspaceGroupBinding (embedded struct)", + testFunc: inferMeshObjectName[MeshWorkspaceGroupBinding], + expected: "meshWorkspaceGroupBinding", + }, + { + name: "MeshWorkspaceUserBinding (embedded struct)", + testFunc: inferMeshObjectName[MeshWorkspaceUserBinding], + expected: "meshWorkspaceUserBinding", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tt.testFunc() + assert.Equal(t, tt.expected, actual) + }) + } +} diff --git a/integrations.go b/integrations.go index 9aa514ad..d9d192d9 100644 --- a/integrations.go +++ b/integrations.go @@ -83,6 +83,10 @@ type MeshIntegrationClient struct { meshObjectClient[MeshIntegration] } +func newIntegrationClient(c *httpClient) MeshIntegrationClient { + return MeshIntegrationClient{newMeshObjectClient[MeshIntegration](c, "v1-preview")} +} + func (c MeshIntegrationClient) integrationId(workspace string, uuid string) string { return workspace + "/" + uuid } diff --git a/landingzone.go b/landingzone.go index 0967a8ca..9521d849 100644 --- a/landingzone.go +++ b/landingzone.go @@ -63,6 +63,10 @@ type MeshLandingZoneClient struct { meshObjectClient[MeshLandingZone] } +func newLandingZoneClient(c *httpClient) MeshLandingZoneClient { + return MeshLandingZoneClient{newMeshObjectClient[MeshLandingZone](c, "v1-preview")} +} + func (c MeshLandingZoneClient) Read(name string) (*MeshLandingZone, error) { return c.get(name) } diff --git a/location.go b/location.go index 81824162..3e8ced9c 100644 --- a/location.go +++ b/location.go @@ -35,6 +35,10 @@ type MeshLocationClient struct { meshObjectClient[MeshLocation] } +func newLocationClient(c *httpClient) MeshLocationClient { + return MeshLocationClient{newMeshObjectClient[MeshLocation](c, "v1-preview")} +} + func (c MeshLocationClient) Read(name string) (*MeshLocation, error) { return c.get(name) } diff --git a/payment_method.go b/payment_method.go index 84f73f2b..6ce53787 100644 --- a/payment_method.go +++ b/payment_method.go @@ -36,6 +36,10 @@ type MeshPaymentMethodClient struct { meshObjectClient[MeshPaymentMethod] } +func newPaymentMethodClient(c *httpClient) MeshPaymentMethodClient { + return MeshPaymentMethodClient{newMeshObjectClient[MeshPaymentMethod](c, "v2")} +} + func (c MeshPaymentMethodClient) Read(workspace string, identifier string) (*MeshPaymentMethod, error) { return c.get(identifier) } diff --git a/platform.go b/platform.go index cc325251..7d6a88f2 100644 --- a/platform.go +++ b/platform.go @@ -107,6 +107,10 @@ type MeshPlatformClient struct { meshObjectClient[MeshPlatform] } +func newPlatformClient(c *httpClient) MeshPlatformClient { + return MeshPlatformClient{newMeshObjectClient[MeshPlatform](c, "v2-preview")} +} + func (c MeshPlatformClient) Read(uuid string) (*MeshPlatform, error) { return c.get(uuid) } diff --git a/project.go b/project.go index 5aca292b..d4b5f7b5 100644 --- a/project.go +++ b/project.go @@ -35,6 +35,10 @@ type MeshProjectClient struct { meshObjectClient[MeshProject] } +func newProjectClient(c *httpClient) MeshProjectClient { + return MeshProjectClient{newMeshObjectClient[MeshProject](c, "v2")} +} + func (c MeshProjectClient) projectId(workspace string, name string) string { return workspace + "." + name } diff --git a/project_group_binding.go b/project_group_binding.go index 96d6640e..d3e7764b 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -1,9 +1,15 @@ package client -type MeshProjectGroupBinding = MeshProjectBinding +type MeshProjectGroupBinding struct { + MeshProjectBinding +} type MeshProjectGroupBindingClient struct { - meshObjectClient[MeshProjectBinding] + meshObjectClient[MeshProjectGroupBinding] +} + +func newProjectGroupBindingClient(c *httpClient) MeshProjectGroupBindingClient { + return MeshProjectGroupBindingClient{newMeshObjectClient[MeshProjectGroupBinding](c, "v3", "meshprojectbindings", "groupbindings")} } func (c MeshProjectGroupBindingClient) Read(name string) (*MeshProjectGroupBinding, error) { diff --git a/project_user_binding.go b/project_user_binding.go index ee619d3b..51df0ba7 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -1,9 +1,15 @@ package client -type MeshProjectUserBinding = MeshProjectBinding +type MeshProjectUserBinding struct { + MeshProjectBinding +} type MeshProjectUserBindingClient struct { - meshObjectClient[MeshProjectBinding] + meshObjectClient[MeshProjectUserBinding] +} + +func newProjectUserBindingClient(c *httpClient) MeshProjectUserBindingClient { + return MeshProjectUserBindingClient{newMeshObjectClient[MeshProjectUserBinding](c, "v3", "meshprojectbindings", "userbindings")} } func (c MeshProjectUserBindingClient) Read(name string) (*MeshProjectUserBinding, error) { diff --git a/tag_definition.go b/tag_definition.go index 884b3240..dfba7a29 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -67,6 +67,10 @@ type MeshTagDefinitionClient struct { meshObjectClient[MeshTagDefinition] } +func newTagDefinitionClient(c *httpClient) MeshTagDefinitionClient { + return MeshTagDefinitionClient{newMeshObjectClient[MeshTagDefinition](c, "v1")} +} + func (c MeshTagDefinitionClient) List() ([]MeshTagDefinition, error) { return c.list() } diff --git a/tenant.go b/tenant.go index 6acaa5f2..73571138 100644 --- a/tenant.go +++ b/tenant.go @@ -47,6 +47,10 @@ type MeshTenantClient struct { meshObjectClient[MeshTenant] } +func newTenantClient(c *httpClient) MeshTenantClient { + return MeshTenantClient{newMeshObjectClient[MeshTenant](c, "v3")} +} + func (c MeshTenantClient) tenantId(workspace string, project string, platform string) string { return workspace + "." + project + "." + platform } diff --git a/tenant_v4.go b/tenant_v4.go index 226ba419..6ba3f0cf 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -60,6 +60,10 @@ type MeshTenantV4Client struct { meshObjectClient[MeshTenantV4] } +func newTenantV4Client(c *httpClient) MeshTenantV4Client { + return MeshTenantV4Client{newMeshObjectClient[MeshTenantV4](c, "v4-preview")} +} + func (c MeshTenantV4Client) Read(uuid string) (*MeshTenantV4, error) { return c.get(uuid) } diff --git a/workspace.go b/workspace.go index 695895e4..56f0f658 100644 --- a/workspace.go +++ b/workspace.go @@ -33,6 +33,10 @@ type MeshWorkspaceClient struct { meshObjectClient[MeshWorkspace] } +func newWorkspaceClient(c *httpClient) MeshWorkspaceClient { + return MeshWorkspaceClient{newMeshObjectClient[MeshWorkspace](c, "v2")} +} + func (c MeshWorkspaceClient) Read(name string) (*MeshWorkspace, error) { return c.get(name) } diff --git a/workspace_group_binding.go b/workspace_group_binding.go index 787edba6..5d718f03 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -1,9 +1,15 @@ package client -type MeshWorkspaceGroupBinding = MeshWorkspaceBinding +type MeshWorkspaceGroupBinding struct { + MeshWorkspaceBinding +} type MeshWorkspaceGroupBindingClient struct { - meshObjectClient[MeshWorkspaceBinding] + meshObjectClient[MeshWorkspaceGroupBinding] +} + +func newWorkspaceGroupBindingClient(c *httpClient) MeshWorkspaceGroupBindingClient { + return MeshWorkspaceGroupBindingClient{newMeshObjectClient[MeshWorkspaceGroupBinding](c, "v2", "meshworkspacebindings", "groupbindings")} } func (c MeshWorkspaceGroupBindingClient) Read(name string) (*MeshWorkspaceGroupBinding, error) { diff --git a/workspace_user_binding.go b/workspace_user_binding.go index 0ae752a7..c9c94ec2 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -1,9 +1,15 @@ package client -type MeshWorkspaceUserBinding = MeshWorkspaceBinding +type MeshWorkspaceUserBinding struct { + MeshWorkspaceBinding +} type MeshWorkspaceUserBindingClient struct { - meshObjectClient[MeshWorkspaceBinding] + meshObjectClient[MeshWorkspaceUserBinding] +} + +func newWorkspaceUserBindingClient(c *httpClient) MeshWorkspaceUserBindingClient { + return MeshWorkspaceUserBindingClient{newMeshObjectClient[MeshWorkspaceUserBinding](c, "v2", "meshworkspacebindings", "userbindings")} } func (c MeshWorkspaceUserBindingClient) Read(name string) (*MeshWorkspaceUserBinding, error) { From 69482caf79d19f4ec90dfe603b05b00aab15a5e6 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Sat, 10 Jan 2026 13:06:39 +0100 Subject: [PATCH 076/215] refactor: move MeshObjectClient into client/internal --- buildingblock.go | 18 +- buildingblock_v2.go | 18 +- client.go | 358 ++---------------- integrations.go | 16 +- internal/http_client.go | 112 ++++++ internal/mesh_object_client.go | 170 +++++++++ .../mesh_object_client_test.go | 16 +- internal/options.go | 59 +++ landingzone.go | 20 +- location.go | 20 +- payment_method.go | 20 +- platform.go | 20 +- project.go | 28 +- project_group_binding.go | 18 +- project_user_binding.go | 18 +- tag_definition.go | 22 +- tenant.go | 18 +- tenant_v4.go | 21 +- workspace.go | 20 +- workspace_group_binding.go | 18 +- workspace_user_binding.go | 18 +- 21 files changed, 584 insertions(+), 444 deletions(-) create mode 100644 internal/http_client.go create mode 100644 internal/mesh_object_client.go rename client_test.go => internal/mesh_object_client_test.go (81%) create mode 100644 internal/options.go diff --git a/buildingblock.go b/buildingblock.go index 3798b34a..96528581 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + const ( MESH_BUILDING_BLOCK_IO_TYPE_STRING = "STRING" MESH_BUILDING_BLOCK_IO_TYPE_INTEGER = "INTEGER" @@ -71,21 +75,23 @@ type MeshBuildingBlockDefinitionRef struct { } type MeshBuildingBlockClient struct { - meshObjectClient[MeshBuildingBlock] + meshObject internal.MeshObjectClient[MeshBuildingBlock] } -func newBuildingBlockClient(c *httpClient) MeshBuildingBlockClient { - return MeshBuildingBlockClient{newMeshObjectClient[MeshBuildingBlock](c, "v1")} +func newBuildingBlockClient(httpClient *internal.HttpClient) MeshBuildingBlockClient { + return MeshBuildingBlockClient{ + meshObject: internal.NewMeshObjectClient[MeshBuildingBlock](httpClient, "v1"), + } } func (c MeshBuildingBlockClient) Read(uuid string) (*MeshBuildingBlock, error) { - return c.get(uuid) + return c.meshObject.Get(uuid) } func (c MeshBuildingBlockClient) Create(bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { - return c.post(bb) + return c.meshObject.Post(bb) } func (c MeshBuildingBlockClient) Delete(uuid string) error { - return c.delete(uuid) + return c.meshObject.Delete(uuid) } diff --git a/buildingblock_v2.go b/buildingblock_v2.go index b69cf739..456328df 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -3,9 +3,9 @@ package client import ( "context" "fmt" - "time" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "time" ) const ( @@ -66,23 +66,25 @@ type MeshBuildingBlockV2Status struct { } type MeshBuildingBlockV2Client struct { - meshObjectClient[MeshBuildingBlockV2] + meshObject internal.MeshObjectClient[MeshBuildingBlockV2] } -func newBuildingBlockV2Client(c *httpClient) MeshBuildingBlockV2Client { - return MeshBuildingBlockV2Client{newMeshObjectClient[MeshBuildingBlockV2](c, "v2-preview")} +func newBuildingBlockV2Client(httpClient *internal.HttpClient) MeshBuildingBlockV2Client { + return MeshBuildingBlockV2Client{ + meshObject: internal.NewMeshObjectClient[MeshBuildingBlockV2](httpClient, "v2-preview"), + } } func (c MeshBuildingBlockV2Client) Read(uuid string) (*MeshBuildingBlockV2, error) { - return c.get(uuid) + return c.meshObject.Get(uuid) } func (c MeshBuildingBlockV2Client) Create(bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { - return c.post(bb) + return c.meshObject.Post(bb) } func (c MeshBuildingBlockV2Client) Delete(uuid string) error { - return c.delete(uuid) + return c.meshObject.Delete(uuid) } // PollUntilCompletion polls a building block until it reaches a terminal state (SUCCEEDED or FAILED) diff --git a/client.go b/client.go index 7b938c57..e7425464 100644 --- a/client.go +++ b/client.go @@ -1,23 +1,12 @@ package client import ( - "bytes" - "encoding/json" - "errors" "fmt" - "io" - "log" "net/http" "net/url" - "reflect" - "slices" - "strings" "time" - "unicode" -) -var ( - errNotFound = errors.New("request failed with status Not Found (404)") + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) type MeshStackProviderClient struct { @@ -40,327 +29,32 @@ type MeshStackProviderClient struct { } func NewClient(rootUrl *url.URL, providerVersion, apiKey, apiSecret string) MeshStackProviderClient { - // Initialize httpClient for typed clients - c := &httpClient{ - Client: http.Client{Timeout: 5 * time.Minute}, - RootUrl: rootUrl, - ProviderVersion: providerVersion, - ApiKey: apiKey, - ApiSecret: apiSecret, - } - return MeshStackProviderClient{ - newBuildingBlockClient(c), - newBuildingBlockV2Client(c), - newIntegrationClient(c), - newLandingZoneClient(c), - newLocationClient(c), - newPaymentMethodClient(c), - newPlatformClient(c), - newProjectClient(c), - newProjectGroupBindingClient(c), - newProjectUserBindingClient(c), - newTagDefinitionClient(c), - newTenantClient(c), - newTenantV4Client(c), - newWorkspaceClient(c), - newWorkspaceGroupBindingClient(c), - newWorkspaceUserBindingClient(c), - } -} - -type httpClient struct { - http.Client - RootUrl *url.URL - ProviderVersion string - - ApiKey string - ApiSecret string - Token string - TokenExpiry time.Time -} - -type meshObjectClient[M any] struct { - *httpClient - Name, ApiVersion string - ApiUrl *url.URL -} - -func newMeshObjectClient[M any](client *httpClient, apiVersion string, explicitApiPaths ...string) meshObjectClient[M] { - name := inferMeshObjectName[M]() - - if len(explicitApiPaths) == 0 { - // infer API path from meshObject name by default (if nothing explicit is given) - explicitApiPaths = []string{strings.ToLower(pluralizeName(name))} - } - // also prepend the root path for all meshObjects - explicitApiPaths = slices.Insert(explicitApiPaths, 0, "/api/meshobjects") - apiUrl := client.RootUrl.JoinPath(explicitApiPaths...) - log.Printf("Using API at '%s' for meshObject '%s', version '%s'", apiUrl, name, apiVersion) - return meshObjectClient[M]{client, name, apiVersion, apiUrl} -} - -// inferMeshObjectName uses reflection to infer the meshObject name from the type parameter M. -// It converts the type name to camelCase (e.g., "MeshBuildingBlock" -> "meshBuildingBlock"). -func inferMeshObjectName[M any]() string { - var zero M - typeName := reflect.TypeOf(zero).Name() - return lowercaseFirst(typeName) -} - -func lowercaseFirst(s string) string { - if s == "" { - return s - } - runes := []rune(s) - runes[0] = unicode.ToLower(runes[0]) - return string(runes) -} - -func pluralizeName(name string) string { - return fmt.Sprintf("%ss", name) -} - -func (c meshObjectClient[M]) meshObjectMimeType() string { - return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", c.Name, c.ApiVersion) -} - -func (c meshObjectClient[M]) get(id string) (*M, error) { - body, err := c.doAuthenticatedRequest(http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) - if errors.Is(err, errNotFound) { - return nil, nil - } - return unmarshalBody[M](body, err) -} - -func (c meshObjectClient[M]) post(payload any) (*M, error) { - return unmarshalBody[M](c.doAuthenticatedRequest(http.MethodPost, c.ApiUrl, withPayload(payload, c.meshObjectMimeType()))) -} - -func (c meshObjectClient[M]) put(id string, payload any) (*M, error) { - return unmarshalBody[M](c.doAuthenticatedRequest(http.MethodPut, c.ApiUrl.JoinPath(id), withPayload(payload, c.meshObjectMimeType()))) -} - -func (c meshObjectClient[M]) delete(id string) (err error) { - _, err = c.doAuthenticatedRequest(http.MethodDelete, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) - return -} - -func (c meshObjectClient[M]) list(options ...doRequestOption) ([]M, error) { - var result []M - embeddedKey := pluralizeName(c.Name) - pageNumber := 0 - for { - body, err := c.doAuthenticatedRequest(http.MethodGet, c.ApiUrl, append(options, - withAccept(c.meshObjectMimeType()), - withUrlQuery("page", pageNumber), - )...) - if err != nil { - return result, fmt.Errorf("cannot fetch page %d: %w", pageNumber, err) - } - type paginatedResponse struct { - Embedded map[string][]M `json:"_embedded"` - Page struct { - TotalPages int `json:"totalPages"` - Number int `json:"number"` - } `json:"page"` - } - response, err := unmarshalBody[paginatedResponse](body, err) - if err != nil { - return result, fmt.Errorf("cannot unmarshal paginated response, page %d: %w", pageNumber, err) - } else if items, ok := response.Embedded[embeddedKey]; !ok { - return result, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey) - } else { - result = append(result, items...) - } - // check if we've reached the end of pagination - if response.Page.Number >= response.Page.TotalPages-1 { - return result, nil - } - pageNumber++ - } -} - -func (c *httpClient) login() error { - loginApiUrl := c.RootUrl.JoinPath("/api/login") - - type loginRequest struct { - ClientId string `json:"clientId"` - ClientSecret string `json:"clientSecret"` - } - - type loginResponse struct { - Token string `json:"access_token"` - ExpireSec int `json:"expires_in"` - } - - loginResult, err := unmarshalBody[loginResponse](c.doRequest("POST", loginApiUrl, - withPayload(loginRequest{ClientId: c.ApiKey, ClientSecret: c.ApiSecret}, "application/json")), - ) - if err != nil { - return fmt.Errorf("login request to %s with API Key '%s' failed: %w", loginApiUrl, c.ApiKey, err) - } - - c.Token = fmt.Sprintf("Bearer %s", loginResult.Token) - c.TokenExpiry = time.Now().Add(time.Second * time.Duration(loginResult.ExpireSec)) - return nil -} + httpClient := &internal.HttpClient{ + Client: http.Client{Timeout: 5 * time.Minute}, + RootUrl: rootUrl, + UserAgent: fmt.Sprintf("terraform-provider-meshstack/%s", providerVersion), -func (c *httpClient) ensureValidToken() error { - if c.Token == "" || time.Now().Add(30*time.Second).After(c.TokenExpiry) { - return c.login() + // Putting authentication with meshStack API into HttpClient + // saves use from passing ApiKey/ApiSecret down to client factory methods below. + ApiKey: apiKey, + ApiSecret: apiSecret, } - return nil -} - -type doRequestOption func(opts *doRequestOptions) - -type requestModifier func(req *http.Request) - -type doRequestOptions struct { - urlQueryParams map[string]string - requestPayload any - requestModifiers []requestModifier -} - -func appendRequestModifier(modifier requestModifier) doRequestOption { - return func(opts *doRequestOptions) { - opts.requestModifiers = append(opts.requestModifiers, modifier) - } -} - -func withUrlQuery(key string, value any) doRequestOption { - return func(opts *doRequestOptions) { - var valueStr string - if stringerValue, ok := value.(fmt.Stringer); ok { - valueStr = stringerValue.String() - } else { - valueStr = fmt.Sprintf("%v", value) - } - if opts.urlQueryParams == nil { - opts.urlQueryParams = map[string]string{key: valueStr} - } else { - opts.urlQueryParams[key] = valueStr - } - } -} - -func withAccept(accept string) doRequestOption { - return withHeader("Accept", accept) -} - -func withHeader(key, value string) doRequestOption { - return appendRequestModifier(func(req *http.Request) { - req.Header.Set(key, value) - }) -} - -func withPayload(payload any, contentType string) doRequestOption { - return func(opts *doRequestOptions) { - // always provide Accept header with the same value as content-type, - // as meshObject API currently does not version that differently. - // that convention can still be overridden/broken by a later withAccept option - withAccept(contentType)(opts) - withHeader("Content-Type", contentType)(opts) - opts.requestPayload = payload - } -} - -func (c *httpClient) doRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { - // prepend (aka insert at 0) some default options such that given options may be overridden by caller - options = slices.Insert(options, 0, - withHeader("User-Agent", fmt.Sprintf("terraform-provider-meshstack/%s", c.ProviderVersion)), - ) - opts := doRequestOptions{} - for _, option := range options { - option(&opts) - } - req, err := c.buildRequest(method, *url, opts) - if err != nil { - return nil, err - } - res, err := c.Do(req) - if err != nil { - return nil, err - } - defer func() { - _ = res.Body.Close() - }() - log.Println(res) - return c.readBodyAndCheckSuccess(res) -} - -func (c *httpClient) readBodyAndCheckSuccess(res *http.Response) ([]byte, error) { - responseBody, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) - } - log.Printf("Got response body with %d bytes", len(responseBody)) - - if res.StatusCode >= 200 && res.StatusCode <= 299 { - return responseBody, nil - } - var errs []error - if res.StatusCode == http.StatusNotFound { - errs = append(errs, errNotFound) - } - errs = append(errs, - fmt.Errorf("request failed with status %d (not 2XX successful)", res.StatusCode), - fmt.Errorf("error response: %s", string(responseBody)), - ) - // always return responseBody, even if the response is not successfully verified - // this allows clients to investigate the responseBody even further if desirable. - return responseBody, errors.Join(errs...) -} - -func (c *httpClient) buildRequest(method string, url url.URL, opts doRequestOptions) (*http.Request, error) { - if len(opts.urlQueryParams) > 0 { - query := url.Query() - for k, v := range opts.urlQueryParams { - query.Set(k, v) - } - // Note: url is not a pointer here, - // so we can safely update that struct field without propagating such change to the caller! - url.RawQuery = query.Encode() - } - - var requestBody io.ReadWriter - if opts.requestPayload != nil { - requestBody = new(bytes.Buffer) - if err := json.NewEncoder(requestBody).Encode(opts.requestPayload); err != nil { - return nil, fmt.Errorf("failed to encode request body payload: %w", err) - } - } - - req, err := http.NewRequest(method, url.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - for _, requestModifier := range opts.requestModifiers { - requestModifier(req) - } - return req, err -} - -func (c *httpClient) doAuthenticatedRequest(method string, url *url.URL, options ...doRequestOption) ([]byte, error) { - if err := c.ensureValidToken(); err != nil { - return nil, err - } - return c.doRequest(method, url, append(options, - appendRequestModifier(func(req *http.Request) { - // log request before adding Authorization header below - log.Println(req) - }), - withHeader("Authorization", c.Token), - )...) -} - -func unmarshalBody[T any](body []byte, err error) (*T, error) { - if err != nil { - return nil, err - } - var target T - if err := json.Unmarshal(body, &target); err != nil { - return nil, err + return MeshStackProviderClient{ + newBuildingBlockClient(httpClient), + newBuildingBlockV2Client(httpClient), + newIntegrationClient(httpClient), + newLandingZoneClient(httpClient), + newLocationClient(httpClient), + newPaymentMethodClient(httpClient), + newPlatformClient(httpClient), + newProjectClient(httpClient), + newProjectGroupBindingClient(httpClient), + newProjectUserBindingClient(httpClient), + newTagDefinitionClient(httpClient), + newTenantClient(httpClient), + newTenantV4Client(httpClient), + newWorkspaceClient(httpClient), + newWorkspaceGroupBindingClient(httpClient), + newWorkspaceUserBindingClient(httpClient), } - return &target, nil } diff --git a/integrations.go b/integrations.go index d9d192d9..4cee539f 100644 --- a/integrations.go +++ b/integrations.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshIntegration struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -80,11 +84,13 @@ type MeshAwsWifProvider struct { } type MeshIntegrationClient struct { - meshObjectClient[MeshIntegration] + meshObject internal.MeshObjectClient[MeshIntegration] } -func newIntegrationClient(c *httpClient) MeshIntegrationClient { - return MeshIntegrationClient{newMeshObjectClient[MeshIntegration](c, "v1-preview")} +func newIntegrationClient(httpClient *internal.HttpClient) MeshIntegrationClient { + return MeshIntegrationClient{ + meshObject: internal.NewMeshObjectClient[MeshIntegration](httpClient, "v1-preview"), + } } func (c MeshIntegrationClient) integrationId(workspace string, uuid string) string { @@ -92,9 +98,9 @@ func (c MeshIntegrationClient) integrationId(workspace string, uuid string) stri } func (c MeshIntegrationClient) Read(workspace string, uuid string) (*MeshIntegration, error) { - return c.get(c.integrationId(workspace, uuid)) + return c.meshObject.Get(c.integrationId(workspace, uuid)) } func (c MeshIntegrationClient) List() ([]MeshIntegration, error) { - return c.list() + return c.meshObject.List() } diff --git a/internal/http_client.go b/internal/http_client.go new file mode 100644 index 00000000..bb3580a3 --- /dev/null +++ b/internal/http_client.go @@ -0,0 +1,112 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "slices" + "time" +) + +var ( + errNotFound = errors.New("request failed with status Not Found (404)") +) + +// HttpClient wraps [http.Client] with convenient request handling thanks to RequestOption. +type HttpClient struct { + http.Client + RootUrl *url.URL + UserAgent string + + ApiKey string + ApiSecret string + Authorization string + AuthorizationExpiresAt time.Time +} + +func (c *HttpClient) doRequest(method string, url *url.URL, options ...RequestOption) ([]byte, error) { + options = slices.Insert(options, 0, + withHeader("User-Agent", c.UserAgent), + ) + opts := requestOptions{} + for _, option := range options { + option(&opts) + } + req, err := c.buildRequest(method, *url, opts) + if err != nil { + return nil, err + } + res, err := c.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + log.Println(res) + return c.readBodyAndCheckSuccess(res) +} + +func (c *HttpClient) readBodyAndCheckSuccess(res *http.Response) ([]byte, error) { + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) + } + log.Printf("Got response body with %d bytes", len(responseBody)) + + if res.StatusCode >= 200 && res.StatusCode <= 299 { + return responseBody, nil + } + var errs []error + if res.StatusCode == http.StatusNotFound { + errs = append(errs, errNotFound) + } + errs = append(errs, + fmt.Errorf("request failed with status %d (not 2XX successful)", res.StatusCode), + fmt.Errorf("error response: %s", string(responseBody)), + ) + return responseBody, errors.Join(errs...) +} + +func (c *HttpClient) buildRequest(method string, url url.URL, opts requestOptions) (*http.Request, error) { + if len(opts.urlQueryParams) > 0 { + query := url.Query() + for k, v := range opts.urlQueryParams { + query.Set(k, v) + } + url.RawQuery = query.Encode() + } + + var requestBody io.ReadWriter + if opts.requestPayload != nil { + requestBody = new(bytes.Buffer) + if err := json.NewEncoder(requestBody).Encode(opts.requestPayload); err != nil { + return nil, fmt.Errorf("failed to encode request body payload: %w", err) + } + } + + req, err := http.NewRequest(method, url.String(), requestBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + for _, requestModifier := range opts.requestModifiers { + requestModifier(req) + } + return req, err +} + +func unmarshalBody[T any](body []byte, err error) (*T, error) { + if err != nil { + return nil, err + } + var target T + if err := json.Unmarshal(body, &target); err != nil { + return nil, err + } + return &target, nil +} diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go new file mode 100644 index 00000000..b56a63f6 --- /dev/null +++ b/internal/mesh_object_client.go @@ -0,0 +1,170 @@ +package internal + +import ( + "errors" + "fmt" + "log" + "net/http" + "net/url" + "reflect" + "slices" + "strings" + "time" + "unicode" +) + +// MeshObjectClient provides typed CRUD operations for meshStack API objects. +// It embeds [HttpClient] and adds meshObject-specific functionality including automatic +// MIME type handling and pagination. +// Also handles authentication in doAuthorizedRequest using the ApiKey/ApiSecret values, +// which are embedded in HttpClient for convenient construction with NewMeshObjectClient. +type MeshObjectClient[M any] struct { + *HttpClient + Name string + ApiVersion string + ApiUrl *url.URL +} + +// NewMeshObjectClient creates a new [MeshObjectClient] for a specific meshObject type with automatic URL path inference. +// The meshObject name is inferred from type M, and the API URL is constructed from explicitApiPaths or the pluralized type name. +func NewMeshObjectClient[M any](httpClient *HttpClient, apiVersion string, explicitApiPaths ...string) MeshObjectClient[M] { + name := inferMeshObjectName[M]() + + if len(explicitApiPaths) == 0 { + explicitApiPaths = []string{strings.ToLower(pluralizeName(name))} + } + explicitApiPaths = slices.Insert(explicitApiPaths, 0, "/api/meshobjects") + apiUrl := httpClient.RootUrl.JoinPath(explicitApiPaths...) + log.Printf("Using API at '%s' for meshObject '%s', version '%s'", apiUrl, name, apiVersion) + return MeshObjectClient[M]{httpClient, name, apiVersion, apiUrl} +} + +func inferMeshObjectName[M any]() string { + var zero M + typeName := reflect.TypeOf(zero).Name() + return lowercaseFirst(typeName) +} + +func lowercaseFirst(s string) string { + if s == "" { + return s + } + runes := []rune(s) + runes[0] = unicode.ToLower(runes[0]) + return string(runes) +} + +func pluralizeName(name string) string { + if strings.HasSuffix(name, "y") { + // this is ok, as we don't have meshObjects ending in 'y' yet, so take this shortcut + panic(fmt.Sprintf("Correctly pluralizing '%s' is not supported yet", name)) + } + return fmt.Sprintf("%ss", name) +} + +func (c MeshObjectClient[M]) meshObjectMimeType() string { + return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", c.Name, c.ApiVersion) +} + +// Get retrieves a meshObject by ID. Returns nil if not found. +func (c MeshObjectClient[M]) Get(id string) (*M, error) { + body, err := c.doAuthorizedRequest(http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) + if errors.Is(err, errNotFound) { + return nil, nil + } + return unmarshalBody[M](body, err) +} + +// Post creates a new meshObject with the given payload. +func (c MeshObjectClient[M]) Post(payload any) (*M, error) { + return unmarshalBody[M](c.doAuthorizedRequest(http.MethodPost, c.ApiUrl, withPayload(payload, c.meshObjectMimeType()))) +} + +// Put updates an existing meshObject by ID with the given payload. +func (c MeshObjectClient[M]) Put(id string, payload any) (*M, error) { + return unmarshalBody[M](c.doAuthorizedRequest(http.MethodPut, c.ApiUrl.JoinPath(id), withPayload(payload, c.meshObjectMimeType()))) +} + +// Delete removes a meshObject by ID. +func (c MeshObjectClient[M]) Delete(id string) (err error) { + _, err = c.doAuthorizedRequest(http.MethodDelete, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) + return +} + +// List retrieves all meshObjects with automatic pagination handling. +// Accepts optional [RequestOption] parameters for filtering and querying. +func (c MeshObjectClient[M]) List(options ...RequestOption) ([]M, error) { + var result []M + embeddedKey := pluralizeName(c.Name) + pageNumber := 0 + + for { + body, err := c.doAuthorizedRequest(http.MethodGet, c.ApiUrl, append(options, + withAccept(c.meshObjectMimeType()), + WithUrlQuery("page", pageNumber), + )...) + if err != nil { + return result, fmt.Errorf("cannot fetch page %d: %w", pageNumber, err) + } + type paginatedResponse struct { + Embedded map[string][]M `json:"_embedded"` + Page struct { + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` + } + response, err := unmarshalBody[paginatedResponse](body, err) + if err != nil { + return result, fmt.Errorf("cannot unmarshal paginated response, page %d: %w", pageNumber, err) + } else if items, ok := response.Embedded[embeddedKey]; !ok { + return result, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey) + } else { + result = append(result, items...) + } + if response.Page.Number >= response.Page.TotalPages-1 { + return result, nil + } + pageNumber++ + } +} + +func (c MeshObjectClient[M]) doAuthorizedRequest(method string, url *url.URL, options ...RequestOption) ([]byte, error) { + if err := c.ensureAuthorization(); err != nil { + return nil, err + } + return c.doRequest(method, url, append(options, + appendRequestModifier(func(req *http.Request) { + log.Println(req) + }), + withHeader("Authorization", c.Authorization), + )...) +} + +func (c MeshObjectClient[M]) ensureAuthorization() error { + if c.Authorization != "" && time.Until(c.AuthorizationExpiresAt) > 30*time.Second { + return nil + } + + loginApiUrl := c.RootUrl.JoinPath("/api/login") + + type loginRequest struct { + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + } + + type loginResponse struct { + Token string `json:"access_token"` + ExpireSec int `json:"expires_in"` + } + + loginResult, err := unmarshalBody[loginResponse](c.doRequest("POST", loginApiUrl, + withPayload(loginRequest{ClientId: c.ApiKey, ClientSecret: c.ApiSecret}, "application/json")), + ) + if err != nil { + return fmt.Errorf("login request to %s with API Key '%s' failed: %w", loginApiUrl, c.ApiKey, err) + } + + c.Authorization = fmt.Sprintf("Bearer %s", loginResult.Token) + c.AuthorizationExpiresAt = time.Now().Add(time.Duration(loginResult.ExpireSec) * time.Second) + return nil +} diff --git a/client_test.go b/internal/mesh_object_client_test.go similarity index 81% rename from client_test.go rename to internal/mesh_object_client_test.go index 8b5f6f3b..3afcdaab 100644 --- a/client_test.go +++ b/internal/mesh_object_client_test.go @@ -1,4 +1,4 @@ -package client +package internal import ( "testing" @@ -6,6 +6,20 @@ import ( "github.com/stretchr/testify/assert" ) +type MeshBuildingBlock struct{} +type MeshBuildingBlockV2 struct{} +type MeshProject struct{} +type MeshWorkspace struct{} +type MeshProjectBinding struct{} +type MeshProjectGroupBinding struct { + MeshProjectBinding +} +type MeshProjectUserBinding struct { + MeshProjectBinding +} +type MeshWorkspaceGroupBinding struct{} +type MeshWorkspaceUserBinding struct{} + func TestInferMeshObjectName(t *testing.T) { tests := []struct { name string diff --git a/internal/options.go b/internal/options.go new file mode 100644 index 00000000..4726dde0 --- /dev/null +++ b/internal/options.go @@ -0,0 +1,59 @@ +package internal + +import ( + "fmt" + "net/http" +) + +type ( + // RequestOption is a functional option for configuring HTTP requests. + RequestOption func(opts *requestOptions) + + requestOptions struct { + urlQueryParams map[string]string + requestPayload any + requestModifiers []requestModifier + } + requestModifier func(req *http.Request) +) + +// WithUrlQuery adds a URL query parameter to the request. +// The value is stringified using fmt.Stringer.String() if implemented, otherwise fmt.Sprintf("%v", value). +func WithUrlQuery(key string, value any) RequestOption { + return func(opts *requestOptions) { + var valueStr string + if stringerValue, ok := value.(fmt.Stringer); ok { + valueStr = stringerValue.String() + } else { + valueStr = fmt.Sprintf("%v", value) + } + if opts.urlQueryParams == nil { + opts.urlQueryParams = map[string]string{} + } + opts.urlQueryParams[key] = valueStr + } +} + +func appendRequestModifier(modifier requestModifier) RequestOption { + return func(opts *requestOptions) { + opts.requestModifiers = append(opts.requestModifiers, modifier) + } +} + +func withAccept(accept string) RequestOption { + return withHeader("Accept", accept) +} + +func withHeader(key, value string) RequestOption { + return appendRequestModifier(func(req *http.Request) { + req.Header.Set(key, value) + }) +} + +func withPayload(payload any, contentType string) RequestOption { + return func(opts *requestOptions) { + withAccept(contentType)(opts) + withHeader("Content-Type", contentType)(opts) + opts.requestPayload = payload + } +} diff --git a/landingzone.go b/landingzone.go index 9521d849..b1ee0b4a 100644 --- a/landingzone.go +++ b/landingzone.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshLandingZone struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -60,25 +64,27 @@ type MeshLandingZoneCreate struct { } type MeshLandingZoneClient struct { - meshObjectClient[MeshLandingZone] + meshObject internal.MeshObjectClient[MeshLandingZone] } -func newLandingZoneClient(c *httpClient) MeshLandingZoneClient { - return MeshLandingZoneClient{newMeshObjectClient[MeshLandingZone](c, "v1-preview")} +func newLandingZoneClient(httpClient *internal.HttpClient) MeshLandingZoneClient { + return MeshLandingZoneClient{ + meshObject: internal.NewMeshObjectClient[MeshLandingZone](httpClient, "v1-preview"), + } } func (c MeshLandingZoneClient) Read(name string) (*MeshLandingZone, error) { - return c.get(name) + return c.meshObject.Get(name) } func (c MeshLandingZoneClient) Create(landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { - return c.post(landingZone) + return c.meshObject.Post(landingZone) } func (c MeshLandingZoneClient) Update(name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { - return c.put(name, landingZone) + return c.meshObject.Put(name, landingZone) } func (c MeshLandingZoneClient) Delete(name string) error { - return c.delete(name) + return c.meshObject.Delete(name) } diff --git a/location.go b/location.go index 3e8ced9c..d37cc52d 100644 --- a/location.go +++ b/location.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshLocation struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Metadata MeshLocationMetadata `json:"metadata" tfsdk:"metadata"` @@ -32,25 +36,27 @@ type MeshLocationCreateMetadata struct { } type MeshLocationClient struct { - meshObjectClient[MeshLocation] + meshObject internal.MeshObjectClient[MeshLocation] } -func newLocationClient(c *httpClient) MeshLocationClient { - return MeshLocationClient{newMeshObjectClient[MeshLocation](c, "v1-preview")} +func newLocationClient(httpClient *internal.HttpClient) MeshLocationClient { + return MeshLocationClient{ + meshObject: internal.NewMeshObjectClient[MeshLocation](httpClient, "v1-preview"), + } } func (c MeshLocationClient) Read(name string) (*MeshLocation, error) { - return c.get(name) + return c.meshObject.Get(name) } func (c MeshLocationClient) Create(location *MeshLocationCreate) (*MeshLocation, error) { - return c.post(location) + return c.meshObject.Post(location) } func (c MeshLocationClient) Update(name string, location *MeshLocationCreate) (*MeshLocation, error) { - return c.put(name, location) + return c.meshObject.Put(name, location) } func (c MeshLocationClient) Delete(name string) error { - return c.delete(name) + return c.meshObject.Delete(name) } diff --git a/payment_method.go b/payment_method.go index 6ce53787..d3ceefa5 100644 --- a/payment_method.go +++ b/payment_method.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshPaymentMethod struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -33,25 +37,27 @@ type MeshPaymentMethodCreateMetadata struct { } type MeshPaymentMethodClient struct { - meshObjectClient[MeshPaymentMethod] + meshObject internal.MeshObjectClient[MeshPaymentMethod] } -func newPaymentMethodClient(c *httpClient) MeshPaymentMethodClient { - return MeshPaymentMethodClient{newMeshObjectClient[MeshPaymentMethod](c, "v2")} +func newPaymentMethodClient(httpClient *internal.HttpClient) MeshPaymentMethodClient { + return MeshPaymentMethodClient{ + meshObject: internal.NewMeshObjectClient[MeshPaymentMethod](httpClient, "v2"), + } } func (c MeshPaymentMethodClient) Read(workspace string, identifier string) (*MeshPaymentMethod, error) { - return c.get(identifier) + return c.meshObject.Get(identifier) } func (c MeshPaymentMethodClient) Create(paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { - return c.post(paymentMethod) + return c.meshObject.Post(paymentMethod) } func (c MeshPaymentMethodClient) Update(identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { - return c.put(identifier, paymentMethod) + return c.meshObject.Put(identifier, paymentMethod) } func (c MeshPaymentMethodClient) Delete(identifier string) error { - return c.delete(identifier) + return c.meshObject.Delete(identifier) } diff --git a/platform.go b/platform.go index 7d6a88f2..0d33f125 100644 --- a/platform.go +++ b/platform.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshPlatform struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -104,25 +108,27 @@ type TagMapper struct { } type MeshPlatformClient struct { - meshObjectClient[MeshPlatform] + meshObject internal.MeshObjectClient[MeshPlatform] } -func newPlatformClient(c *httpClient) MeshPlatformClient { - return MeshPlatformClient{newMeshObjectClient[MeshPlatform](c, "v2-preview")} +func newPlatformClient(httpClient *internal.HttpClient) MeshPlatformClient { + return MeshPlatformClient{ + meshObject: internal.NewMeshObjectClient[MeshPlatform](httpClient, "v2-preview"), + } } func (c MeshPlatformClient) Read(uuid string) (*MeshPlatform, error) { - return c.get(uuid) + return c.meshObject.Get(uuid) } func (c MeshPlatformClient) Create(platform *MeshPlatformCreate) (*MeshPlatform, error) { - return c.post(platform) + return c.meshObject.Post(platform) } func (c MeshPlatformClient) Update(uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { - return c.put(uuid, platform) + return c.meshObject.Put(uuid, platform) } func (c MeshPlatformClient) Delete(uuid string) error { - return c.delete(uuid) + return c.meshObject.Delete(uuid) } diff --git a/project.go b/project.go index d4b5f7b5..6402b6a9 100644 --- a/project.go +++ b/project.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshProject struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -32,11 +36,13 @@ type MeshProjectCreateMetadata struct { } type MeshProjectClient struct { - meshObjectClient[MeshProject] + meshObject internal.MeshObjectClient[MeshProject] } -func newProjectClient(c *httpClient) MeshProjectClient { - return MeshProjectClient{newMeshObjectClient[MeshProject](c, "v2")} +func newProjectClient(httpClient *internal.HttpClient) MeshProjectClient { + return MeshProjectClient{ + meshObject: internal.NewMeshObjectClient[MeshProject](httpClient, "v2"), + } } func (c MeshProjectClient) projectId(workspace string, name string) string { @@ -44,27 +50,27 @@ func (c MeshProjectClient) projectId(workspace string, name string) string { } func (c MeshProjectClient) Read(workspace string, name string) (*MeshProject, error) { - return c.get(c.projectId(workspace, name)) + return c.meshObject.Get(c.projectId(workspace, name)) } func (c MeshProjectClient) List(workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { - options := []doRequestOption{ - withUrlQuery("workspaceIdentifier", workspaceIdentifier), + options := []internal.RequestOption{ + internal.WithUrlQuery("workspaceIdentifier", workspaceIdentifier), } if paymentMethodIdentifier != nil { - options = append(options, withUrlQuery("paymentIdentifier", *paymentMethodIdentifier)) + options = append(options, internal.WithUrlQuery("paymentIdentifier", *paymentMethodIdentifier)) } - return c.list(options...) + return c.meshObject.List(options...) } func (c MeshProjectClient) Create(project *MeshProjectCreate) (*MeshProject, error) { - return c.post(project) + return c.meshObject.Post(project) } func (c MeshProjectClient) Update(project *MeshProjectCreate) (*MeshProject, error) { - return c.put(c.projectId(project.Metadata.OwnedByWorkspace, project.Metadata.Name), project) + return c.meshObject.Put(c.projectId(project.Metadata.OwnedByWorkspace, project.Metadata.Name), project) } func (c MeshProjectClient) Delete(workspace string, name string) error { - return c.delete(c.projectId(workspace, name)) + return c.meshObject.Delete(c.projectId(workspace, name)) } diff --git a/project_group_binding.go b/project_group_binding.go index d3e7764b..4d19df3c 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -1,25 +1,31 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshProjectGroupBinding struct { MeshProjectBinding } type MeshProjectGroupBindingClient struct { - meshObjectClient[MeshProjectGroupBinding] + meshObject internal.MeshObjectClient[MeshProjectGroupBinding] } -func newProjectGroupBindingClient(c *httpClient) MeshProjectGroupBindingClient { - return MeshProjectGroupBindingClient{newMeshObjectClient[MeshProjectGroupBinding](c, "v3", "meshprojectbindings", "groupbindings")} +func newProjectGroupBindingClient(httpClient *internal.HttpClient) MeshProjectGroupBindingClient { + return MeshProjectGroupBindingClient{ + meshObject: internal.NewMeshObjectClient[MeshProjectGroupBinding](httpClient, "v3", "meshprojectbindings", "groupbindings"), + } } func (c MeshProjectGroupBindingClient) Read(name string) (*MeshProjectGroupBinding, error) { - return c.get(name) + return c.meshObject.Get(name) } func (c MeshProjectGroupBindingClient) Create(binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { - return c.post(binding) + return c.meshObject.Post(binding) } func (c MeshProjectGroupBindingClient) Delete(name string) error { - return c.delete(name) + return c.meshObject.Delete(name) } diff --git a/project_user_binding.go b/project_user_binding.go index 51df0ba7..334343de 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -1,25 +1,31 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshProjectUserBinding struct { MeshProjectBinding } type MeshProjectUserBindingClient struct { - meshObjectClient[MeshProjectUserBinding] + meshObject internal.MeshObjectClient[MeshProjectUserBinding] } -func newProjectUserBindingClient(c *httpClient) MeshProjectUserBindingClient { - return MeshProjectUserBindingClient{newMeshObjectClient[MeshProjectUserBinding](c, "v3", "meshprojectbindings", "userbindings")} +func newProjectUserBindingClient(httpClient *internal.HttpClient) MeshProjectUserBindingClient { + return MeshProjectUserBindingClient{ + meshObject: internal.NewMeshObjectClient[MeshProjectUserBinding](httpClient, "v3", "meshprojectbindings", "userbindings"), + } } func (c MeshProjectUserBindingClient) Read(name string) (*MeshProjectUserBinding, error) { - return c.get(name) + return c.meshObject.Get(name) } func (c MeshProjectUserBindingClient) Create(binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { - return c.post(binding) + return c.meshObject.Post(binding) } func (c MeshProjectUserBindingClient) Delete(name string) error { - return c.delete(name) + return c.meshObject.Delete(name) } diff --git a/tag_definition.go b/tag_definition.go index dfba7a29..18f32e1c 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + const API_VERSION_TAG_DEFINITION = "v1" type MeshTagDefinition struct { @@ -64,29 +68,31 @@ type TagValueMultiSelect struct { } type MeshTagDefinitionClient struct { - meshObjectClient[MeshTagDefinition] + meshObject internal.MeshObjectClient[MeshTagDefinition] } -func newTagDefinitionClient(c *httpClient) MeshTagDefinitionClient { - return MeshTagDefinitionClient{newMeshObjectClient[MeshTagDefinition](c, "v1")} +func newTagDefinitionClient(httpClient *internal.HttpClient) MeshTagDefinitionClient { + return MeshTagDefinitionClient{ + meshObject: internal.NewMeshObjectClient[MeshTagDefinition](httpClient, "v1"), + } } func (c MeshTagDefinitionClient) List() ([]MeshTagDefinition, error) { - return c.list() + return c.meshObject.List() } func (c MeshTagDefinitionClient) Read(name string) (*MeshTagDefinition, error) { - return c.get(name) + return c.meshObject.Get(name) } func (c MeshTagDefinitionClient) Create(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - return c.post(tagDefinition) + return c.meshObject.Post(tagDefinition) } func (c MeshTagDefinitionClient) Update(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - return c.put(tagDefinition.Metadata.Name, tagDefinition) + return c.meshObject.Put(tagDefinition.Metadata.Name, tagDefinition) } func (c MeshTagDefinitionClient) Delete(name string) error { - return c.delete(name) + return c.meshObject.Delete(name) } diff --git a/tenant.go b/tenant.go index 73571138..ccd998d7 100644 --- a/tenant.go +++ b/tenant.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshTenant struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -44,11 +48,13 @@ type MeshTenantCreateSpec struct { } type MeshTenantClient struct { - meshObjectClient[MeshTenant] + meshObject internal.MeshObjectClient[MeshTenant] } -func newTenantClient(c *httpClient) MeshTenantClient { - return MeshTenantClient{newMeshObjectClient[MeshTenant](c, "v3")} +func newTenantClient(httpClient *internal.HttpClient) MeshTenantClient { + return MeshTenantClient{ + meshObject: internal.NewMeshObjectClient[MeshTenant](httpClient, "v3"), + } } func (c MeshTenantClient) tenantId(workspace string, project string, platform string) string { @@ -56,13 +62,13 @@ func (c MeshTenantClient) tenantId(workspace string, project string, platform st } func (c MeshTenantClient) Read(workspace string, project string, platform string) (*MeshTenant, error) { - return c.get(c.tenantId(workspace, project, platform)) + return c.meshObject.Get(c.tenantId(workspace, project, platform)) } func (c MeshTenantClient) Create(tenant *MeshTenantCreate) (*MeshTenant, error) { - return c.post(tenant) + return c.meshObject.Post(tenant) } func (c MeshTenantClient) Delete(workspace string, project string, platform string) error { - return c.delete(c.tenantId(workspace, project, platform)) + return c.meshObject.Delete(c.tenantId(workspace, project, platform)) } diff --git a/tenant_v4.go b/tenant_v4.go index 6ba3f0cf..65089fb0 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -1,11 +1,14 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + import ( "context" "fmt" - "time" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "time" ) type MeshTenantV4 struct { @@ -57,23 +60,25 @@ type MeshTenantV4CreateSpec struct { } type MeshTenantV4Client struct { - meshObjectClient[MeshTenantV4] + meshObject internal.MeshObjectClient[MeshTenantV4] } -func newTenantV4Client(c *httpClient) MeshTenantV4Client { - return MeshTenantV4Client{newMeshObjectClient[MeshTenantV4](c, "v4-preview")} +func newTenantV4Client(httpClient *internal.HttpClient) MeshTenantV4Client { + return MeshTenantV4Client{ + meshObject: internal.NewMeshObjectClient[MeshTenantV4](httpClient, "v4-preview"), + } } func (c MeshTenantV4Client) Read(uuid string) (*MeshTenantV4, error) { - return c.get(uuid) + return c.meshObject.Get(uuid) } func (c MeshTenantV4Client) Create(tenant *MeshTenantV4Create) (*MeshTenantV4, error) { - return c.post(tenant) + return c.meshObject.Post(tenant) } func (c MeshTenantV4Client) Delete(uuid string) error { - return c.delete(uuid) + return c.meshObject.Delete(uuid) } // PollUntilCreation polls a tenant until creation completes (platformTenantId is set) diff --git a/workspace.go b/workspace.go index 56f0f658..578643f7 100644 --- a/workspace.go +++ b/workspace.go @@ -1,5 +1,9 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshWorkspace struct { ApiVersion string `json:"apiVersion" tfsdk:"api_version"` Kind string `json:"kind" tfsdk:"kind"` @@ -30,25 +34,27 @@ type MeshWorkspaceCreateMetadata struct { } type MeshWorkspaceClient struct { - meshObjectClient[MeshWorkspace] + meshObject internal.MeshObjectClient[MeshWorkspace] } -func newWorkspaceClient(c *httpClient) MeshWorkspaceClient { - return MeshWorkspaceClient{newMeshObjectClient[MeshWorkspace](c, "v2")} +func newWorkspaceClient(httpClient *internal.HttpClient) MeshWorkspaceClient { + return MeshWorkspaceClient{ + meshObject: internal.NewMeshObjectClient[MeshWorkspace](httpClient, "v2"), + } } func (c MeshWorkspaceClient) Read(name string) (*MeshWorkspace, error) { - return c.get(name) + return c.meshObject.Get(name) } func (c MeshWorkspaceClient) Create(workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { - return c.post(workspace) + return c.meshObject.Post(workspace) } func (c MeshWorkspaceClient) Update(name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { - return c.put(name, workspace) + return c.meshObject.Put(name, workspace) } func (c MeshWorkspaceClient) Delete(name string) error { - return c.delete(name) + return c.meshObject.Delete(name) } diff --git a/workspace_group_binding.go b/workspace_group_binding.go index 5d718f03..c947fb26 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -1,25 +1,31 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshWorkspaceGroupBinding struct { MeshWorkspaceBinding } type MeshWorkspaceGroupBindingClient struct { - meshObjectClient[MeshWorkspaceGroupBinding] + meshObject internal.MeshObjectClient[MeshWorkspaceGroupBinding] } -func newWorkspaceGroupBindingClient(c *httpClient) MeshWorkspaceGroupBindingClient { - return MeshWorkspaceGroupBindingClient{newMeshObjectClient[MeshWorkspaceGroupBinding](c, "v2", "meshworkspacebindings", "groupbindings")} +func newWorkspaceGroupBindingClient(httpClient *internal.HttpClient) MeshWorkspaceGroupBindingClient { + return MeshWorkspaceGroupBindingClient{ + meshObject: internal.NewMeshObjectClient[MeshWorkspaceGroupBinding](httpClient, "v2", "meshworkspacebindings", "groupbindings"), + } } func (c MeshWorkspaceGroupBindingClient) Read(name string) (*MeshWorkspaceGroupBinding, error) { - return c.get(name) + return c.meshObject.Get(name) } func (c MeshWorkspaceGroupBindingClient) Create(binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { - return c.post(binding) + return c.meshObject.Post(binding) } func (c MeshWorkspaceGroupBindingClient) Delete(name string) error { - return c.delete(name) + return c.meshObject.Delete(name) } diff --git a/workspace_user_binding.go b/workspace_user_binding.go index c9c94ec2..5e917967 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -1,25 +1,31 @@ package client +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + type MeshWorkspaceUserBinding struct { MeshWorkspaceBinding } type MeshWorkspaceUserBindingClient struct { - meshObjectClient[MeshWorkspaceUserBinding] + meshObject internal.MeshObjectClient[MeshWorkspaceUserBinding] } -func newWorkspaceUserBindingClient(c *httpClient) MeshWorkspaceUserBindingClient { - return MeshWorkspaceUserBindingClient{newMeshObjectClient[MeshWorkspaceUserBinding](c, "v2", "meshworkspacebindings", "userbindings")} +func newWorkspaceUserBindingClient(httpClient *internal.HttpClient) MeshWorkspaceUserBindingClient { + return MeshWorkspaceUserBindingClient{ + meshObject: internal.NewMeshObjectClient[MeshWorkspaceUserBinding](httpClient, "v2", "meshworkspacebindings", "userbindings"), + } } func (c MeshWorkspaceUserBindingClient) Read(name string) (*MeshWorkspaceUserBinding, error) { - return c.get(name) + return c.meshObject.Get(name) } func (c MeshWorkspaceUserBindingClient) Create(binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { - return c.post(binding) + return c.meshObject.Post(binding) } func (c MeshWorkspaceUserBindingClient) Delete(name string) error { - return c.delete(name) + return c.meshObject.Delete(name) } From 88c6d7161e54f612f9f9db162af28b8a3ce0f2c6 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Sat, 10 Jan 2026 14:13:29 +0100 Subject: [PATCH 077/215] refactor: use sub-clients in resources/data sources and helper configureProviderClient --- client.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/client.go b/client.go index e7425464..97cae04b 100644 --- a/client.go +++ b/client.go @@ -1,7 +1,6 @@ package client import ( - "fmt" "net/http" "net/url" "time" @@ -9,7 +8,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) -type MeshStackProviderClient struct { +type Client struct { BuildingBlock MeshBuildingBlockClient BuildingBlockV2 MeshBuildingBlockV2Client Integration MeshIntegrationClient @@ -28,18 +27,18 @@ type MeshStackProviderClient struct { WorkspaceUserBinding MeshWorkspaceUserBindingClient } -func NewClient(rootUrl *url.URL, providerVersion, apiKey, apiSecret string) MeshStackProviderClient { +func New(rootUrl *url.URL, userAgent, apiKey, apiSecret string) Client { httpClient := &internal.HttpClient{ Client: http.Client{Timeout: 5 * time.Minute}, RootUrl: rootUrl, - UserAgent: fmt.Sprintf("terraform-provider-meshstack/%s", providerVersion), + UserAgent: userAgent, // Putting authentication with meshStack API into HttpClient // saves use from passing ApiKey/ApiSecret down to client factory methods below. ApiKey: apiKey, ApiSecret: apiSecret, } - return MeshStackProviderClient{ + return Client{ newBuildingBlockClient(httpClient), newBuildingBlockV2Client(httpClient), newIntegrationClient(httpClient), From c30d0925a2930bdb241f456c353de14e287c5d2d Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 12 Jan 2026 09:09:48 +0100 Subject: [PATCH 078/215] feat: use gci to consistently format imports, improve Taskfile args handling --- buildingblock_v2.go | 4 +++- tenant_v4.go | 9 ++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 456328df..4b0956e8 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -3,9 +3,11 @@ package client import ( "context" "fmt" + "time" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "time" ) const ( diff --git a/tenant_v4.go b/tenant_v4.go index 65089fb0..eccc4c54 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -1,14 +1,13 @@ package client -import ( - "github.com/meshcloud/terraform-provider-meshstack/client/internal" -) - import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) type MeshTenantV4 struct { From 86cdac2c4afd53777d0e6164134a2173345ad8e2 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 12 Jan 2026 21:02:45 +0100 Subject: [PATCH 079/215] feat: properly implement logging using tflog from SDK, remove versioned type suffix when inferring endpoint this does not spoil the client package with hashicorp dependencies and thanks to interfaces. The ctx must be passed around though, leading to some changes. also, we can use the context when running the request! --- buildingblock.go | 18 ++++--- buildingblock_v2.go | 28 +++++------ client.go | 35 ++++++------- client_logging.go | 11 ++++ integrations.go | 14 +++--- internal/http_client.go | 18 +++---- internal/logging.go | 78 +++++++++++++++++++++++++++++ internal/mesh_object_client.go | 51 +++++++++---------- internal/mesh_object_client_test.go | 48 +++--------------- landingzone.go | 22 ++++---- location.go | 22 ++++---- payment_method.go | 22 ++++---- platform.go | 22 ++++---- project.go | 26 +++++----- project_group_binding.go | 18 ++++--- project_user_binding.go | 18 ++++--- tag_definition.go | 26 +++++----- tenant.go | 18 ++++--- tenant_v4.go | 28 +++++------ workspace.go | 22 ++++---- workspace_group_binding.go | 18 ++++--- workspace_user_binding.go | 18 ++++--- 22 files changed, 331 insertions(+), 250 deletions(-) create mode 100644 client_logging.go create mode 100644 internal/logging.go diff --git a/buildingblock.go b/buildingblock.go index 96528581..f7c266ea 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -78,20 +80,20 @@ type MeshBuildingBlockClient struct { meshObject internal.MeshObjectClient[MeshBuildingBlock] } -func newBuildingBlockClient(httpClient *internal.HttpClient) MeshBuildingBlockClient { +func newBuildingBlockClient(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockClient { return MeshBuildingBlockClient{ - meshObject: internal.NewMeshObjectClient[MeshBuildingBlock](httpClient, "v1"), + meshObject: internal.NewMeshObjectClient[MeshBuildingBlock](ctx, httpClient, "v1"), } } -func (c MeshBuildingBlockClient) Read(uuid string) (*MeshBuildingBlock, error) { - return c.meshObject.Get(uuid) +func (c MeshBuildingBlockClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) { + return c.meshObject.Get(ctx, uuid) } -func (c MeshBuildingBlockClient) Create(bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { - return c.meshObject.Post(bb) +func (c MeshBuildingBlockClient) Create(ctx context.Context, bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { + return c.meshObject.Post(ctx, bb) } -func (c MeshBuildingBlockClient) Delete(uuid string) error { - return c.meshObject.Delete(uuid) +func (c MeshBuildingBlockClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) } diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 4b0956e8..c06409e1 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -71,22 +71,22 @@ type MeshBuildingBlockV2Client struct { meshObject internal.MeshObjectClient[MeshBuildingBlockV2] } -func newBuildingBlockV2Client(httpClient *internal.HttpClient) MeshBuildingBlockV2Client { +func newBuildingBlockV2Client(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockV2Client { return MeshBuildingBlockV2Client{ - meshObject: internal.NewMeshObjectClient[MeshBuildingBlockV2](httpClient, "v2-preview"), + meshObject: internal.NewMeshObjectClient[MeshBuildingBlockV2](ctx, httpClient, "v2-preview"), } } -func (c MeshBuildingBlockV2Client) Read(uuid string) (*MeshBuildingBlockV2, error) { - return c.meshObject.Get(uuid) +func (c MeshBuildingBlockV2Client) Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { + return c.meshObject.Get(ctx, uuid) } -func (c MeshBuildingBlockV2Client) Create(bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { - return c.meshObject.Post(bb) +func (c MeshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { + return c.meshObject.Post(ctx, bb) } -func (c MeshBuildingBlockV2Client) Delete(uuid string) error { - return c.meshObject.Delete(uuid) +func (c MeshBuildingBlockV2Client) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) } // PollUntilCompletion polls a building block until it reaches a terminal state (SUCCEEDED or FAILED) @@ -94,14 +94,14 @@ func (c MeshBuildingBlockV2Client) Delete(uuid string) error { func (c MeshBuildingBlockV2Client) PollUntilCompletion(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { var result *MeshBuildingBlockV2 - err := retry.RetryContext(ctx, 30*time.Minute, c.waitForCompletionFunc(uuid, &result)) + err := retry.RetryContext(ctx, 30*time.Minute, c.waitForCompletionFunc(ctx, uuid, &result)) return result, err } // waitForCompletionFunc returns a RetryFunc that checks building block completion status. -func (c MeshBuildingBlockV2Client) waitForCompletionFunc(uuid string, result **MeshBuildingBlockV2) retry.RetryFunc { +func (c MeshBuildingBlockV2Client) waitForCompletionFunc(ctx context.Context, uuid string, result **MeshBuildingBlockV2) retry.RetryFunc { return func() *retry.RetryError { - current, err := c.Read(uuid) + current, err := c.Read(ctx, uuid) if err != nil { return retry.NonRetryableError(fmt.Errorf("could not read building block status while waiting for completion: %w", err)) } @@ -128,13 +128,13 @@ func (c MeshBuildingBlockV2Client) waitForCompletionFunc(uuid string, result **M // PollUntilDeletion polls a building block until it is deleted (not found) // Returns nil on successful deletion or an error if polling fails or times out. func (c MeshBuildingBlockV2Client) PollUntilDeletion(ctx context.Context, uuid string) error { - return retry.RetryContext(ctx, 30*time.Minute, c.waitForDeletionFunc(uuid)) + return retry.RetryContext(ctx, 30*time.Minute, c.waitForDeletionFunc(ctx, uuid)) } // waitForDeletionFunc returns a RetryFunc that checks building block deletion status. -func (c MeshBuildingBlockV2Client) waitForDeletionFunc(uuid string) retry.RetryFunc { +func (c MeshBuildingBlockV2Client) waitForDeletionFunc(ctx context.Context, uuid string) retry.RetryFunc { return func() *retry.RetryError { - current, err := c.Read(uuid) + current, err := c.Read(ctx, uuid) if err != nil { return retry.NonRetryableError(fmt.Errorf("could not read building block status while waiting for deletion: %w", err)) } diff --git a/client.go b/client.go index 97cae04b..3b1dee22 100644 --- a/client.go +++ b/client.go @@ -1,6 +1,7 @@ package client import ( + "context" "net/http" "net/url" "time" @@ -27,7 +28,7 @@ type Client struct { WorkspaceUserBinding MeshWorkspaceUserBindingClient } -func New(rootUrl *url.URL, userAgent, apiKey, apiSecret string) Client { +func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret string) Client { httpClient := &internal.HttpClient{ Client: http.Client{Timeout: 5 * time.Minute}, RootUrl: rootUrl, @@ -39,21 +40,21 @@ func New(rootUrl *url.URL, userAgent, apiKey, apiSecret string) Client { ApiSecret: apiSecret, } return Client{ - newBuildingBlockClient(httpClient), - newBuildingBlockV2Client(httpClient), - newIntegrationClient(httpClient), - newLandingZoneClient(httpClient), - newLocationClient(httpClient), - newPaymentMethodClient(httpClient), - newPlatformClient(httpClient), - newProjectClient(httpClient), - newProjectGroupBindingClient(httpClient), - newProjectUserBindingClient(httpClient), - newTagDefinitionClient(httpClient), - newTenantClient(httpClient), - newTenantV4Client(httpClient), - newWorkspaceClient(httpClient), - newWorkspaceGroupBindingClient(httpClient), - newWorkspaceUserBindingClient(httpClient), + newBuildingBlockClient(ctx, httpClient), + newBuildingBlockV2Client(ctx, httpClient), + newIntegrationClient(ctx, httpClient), + newLandingZoneClient(ctx, httpClient), + newLocationClient(ctx, httpClient), + newPaymentMethodClient(ctx, httpClient), + newPlatformClient(ctx, httpClient), + newProjectClient(ctx, httpClient), + newProjectGroupBindingClient(ctx, httpClient), + newProjectUserBindingClient(ctx, httpClient), + newTagDefinitionClient(ctx, httpClient), + newTenantClient(ctx, httpClient), + newTenantV4Client(ctx, httpClient), + newWorkspaceClient(ctx, httpClient), + newWorkspaceGroupBindingClient(ctx, httpClient), + newWorkspaceUserBindingClient(ctx, httpClient), } } diff --git a/client_logging.go b/client_logging.go new file mode 100644 index 00000000..ed6979a7 --- /dev/null +++ b/client_logging.go @@ -0,0 +1,11 @@ +package client + +import "github.com/meshcloud/terraform-provider-meshstack/client/internal" + +// Logger exposes logging for client operations within this package (including internal). +type Logger = internal.Logger + +// SetLogger allows setting the client logger. By default, no logging happens. +func SetLogger(logger Logger) { + internal.Log = logger +} diff --git a/integrations.go b/integrations.go index 4cee539f..3f7e2cff 100644 --- a/integrations.go +++ b/integrations.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -87,9 +89,9 @@ type MeshIntegrationClient struct { meshObject internal.MeshObjectClient[MeshIntegration] } -func newIntegrationClient(httpClient *internal.HttpClient) MeshIntegrationClient { +func newIntegrationClient(ctx context.Context, httpClient *internal.HttpClient) MeshIntegrationClient { return MeshIntegrationClient{ - meshObject: internal.NewMeshObjectClient[MeshIntegration](httpClient, "v1-preview"), + meshObject: internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1-preview"), } } @@ -97,10 +99,10 @@ func (c MeshIntegrationClient) integrationId(workspace string, uuid string) stri return workspace + "/" + uuid } -func (c MeshIntegrationClient) Read(workspace string, uuid string) (*MeshIntegration, error) { - return c.meshObject.Get(c.integrationId(workspace, uuid)) +func (c MeshIntegrationClient) Read(ctx context.Context, workspace string, uuid string) (*MeshIntegration, error) { + return c.meshObject.Get(ctx, c.integrationId(workspace, uuid)) } -func (c MeshIntegrationClient) List() ([]MeshIntegration, error) { - return c.meshObject.List() +func (c MeshIntegrationClient) List(ctx context.Context) ([]MeshIntegration, error) { + return c.meshObject.List(ctx) } diff --git a/internal/http_client.go b/internal/http_client.go index bb3580a3..80a3d72d 100644 --- a/internal/http_client.go +++ b/internal/http_client.go @@ -2,11 +2,11 @@ package internal import ( "bytes" + "context" "encoding/json" "errors" "fmt" "io" - "log" "net/http" "net/url" "slices" @@ -29,7 +29,7 @@ type HttpClient struct { AuthorizationExpiresAt time.Time } -func (c *HttpClient) doRequest(method string, url *url.URL, options ...RequestOption) ([]byte, error) { +func (c *HttpClient) doRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) ([]byte, error) { options = slices.Insert(options, 0, withHeader("User-Agent", c.UserAgent), ) @@ -37,7 +37,7 @@ func (c *HttpClient) doRequest(method string, url *url.URL, options ...RequestOp for _, option := range options { option(&opts) } - req, err := c.buildRequest(method, *url, opts) + req, err := c.buildRequest(ctx, method, *url, opts) if err != nil { return nil, err } @@ -48,16 +48,15 @@ func (c *HttpClient) doRequest(method string, url *url.URL, options ...RequestOp defer func() { _ = res.Body.Close() }() - log.Println(res) - return c.readBodyAndCheckSuccess(res) + return c.readBodyAndCheckSuccess(ctx, res) } -func (c *HttpClient) readBodyAndCheckSuccess(res *http.Response) ([]byte, error) { +func (c *HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Response) ([]byte, error) { responseBody, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) } - log.Printf("Got response body with %d bytes", len(responseBody)) + Log.Debug(ctx, "response", "status", res.StatusCode, "body", loggedBody{bytes.NewBuffer(responseBody)}) if res.StatusCode >= 200 && res.StatusCode <= 299 { return responseBody, nil @@ -73,7 +72,7 @@ func (c *HttpClient) readBodyAndCheckSuccess(res *http.Response) ([]byte, error) return responseBody, errors.Join(errs...) } -func (c *HttpClient) buildRequest(method string, url url.URL, opts requestOptions) (*http.Request, error) { +func (c *HttpClient) buildRequest(ctx context.Context, method string, url url.URL, opts requestOptions) (*http.Request, error) { if len(opts.urlQueryParams) > 0 { query := url.Query() for k, v := range opts.urlQueryParams { @@ -90,13 +89,14 @@ func (c *HttpClient) buildRequest(method string, url url.URL, opts requestOption } } - req, err := http.NewRequest(method, url.String(), requestBody) + req, err := http.NewRequestWithContext(ctx, method, url.String(), requestBody) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } for _, requestModifier := range opts.requestModifiers { requestModifier(req) } + Log.Debug(ctx, "request", "url", req.URL.String(), "method", req.Method, "headers", loggedHeaders(req.Header), "body", loggedBody{requestBody}) return req, err } diff --git a/internal/logging.go b/internal/logging.go new file mode 100644 index 00000000..b45315e7 --- /dev/null +++ b/internal/logging.go @@ -0,0 +1,78 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "slices" + "strings" +) + +var Log Logger = noopLogger{} + +// Logger only supports Debug and Info log levels. +type Logger interface { + Info(ctx context.Context, msg string, args ...any) + Debug(ctx context.Context, msg string, args ...any) +} + +type noopLogger struct{} + +func (n noopLogger) Info(context.Context, string, ...any) { + // do nothing +} + +func (n noopLogger) Debug(context.Context, string, ...any) { + // do nothing +} + +type loggedHeaders http.Header + +var _ fmt.Stringer = loggedHeaders(nil) + +func (l loggedHeaders) String() string { + var lines []string + for _, k := range slices.Sorted(maps.Keys(l)) { + for _, v := range l[k] { + // Avoid printing that longish JWT Bearer token (which is also a secret) + if k == "Authorization" { + v = "[REDACTED]" + } + lines = append(lines, fmt.Sprintf("%s=%s", k, v)) + } + } + return strings.Join(lines, "\n") +} + +type loggedBody struct { + io.Reader +} + +var _ fmt.Stringer = loggedBody{} + +func (l loggedBody) String() string { + if buffer, ok := l.Reader.(*bytes.Buffer); ok { + return bytesToPrettyJson(buffer.Bytes()) + } else if buffer == nil { + return "" + } + return fmt.Sprintf(" %v", l.Reader) +} + +func bytesToPrettyJson(data []byte) string { + if len(data) == 0 { + return "" + } + var decoded any + if err := json.Unmarshal(data, &decoded); err == nil { + if indented, err := json.MarshalIndent(decoded, "", " "); err == nil { + return string(indented) + } + } + // should never happen as we should only transfer JSON in request/responses + return fmt.Sprintf(" %s", len(data), string(data)) +} diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index b56a63f6..96f4312c 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -1,12 +1,13 @@ package internal import ( + "context" "errors" "fmt" - "log" "net/http" "net/url" "reflect" + "regexp" "slices" "strings" "time" @@ -27,22 +28,23 @@ type MeshObjectClient[M any] struct { // NewMeshObjectClient creates a new [MeshObjectClient] for a specific meshObject type with automatic URL path inference. // The meshObject name is inferred from type M, and the API URL is constructed from explicitApiPaths or the pluralized type name. -func NewMeshObjectClient[M any](httpClient *HttpClient, apiVersion string, explicitApiPaths ...string) MeshObjectClient[M] { - name := inferMeshObjectName[M]() +func NewMeshObjectClient[M any](ctx context.Context, httpClient *HttpClient, apiVersion string, explicitApiPaths ...string) MeshObjectClient[M] { + name, typeName := inferMeshObjectName[M]() if len(explicitApiPaths) == 0 { explicitApiPaths = []string{strings.ToLower(pluralizeName(name))} } explicitApiPaths = slices.Insert(explicitApiPaths, 0, "/api/meshobjects") apiUrl := httpClient.RootUrl.JoinPath(explicitApiPaths...) - log.Printf("Using API at '%s' for meshObject '%s', version '%s'", apiUrl, name, apiVersion) + Log.Info(ctx, fmt.Sprintf("initialized %s", typeName), "url", apiUrl.String(), "name", name, "version", apiVersion) return MeshObjectClient[M]{httpClient, name, apiVersion, apiUrl} } -func inferMeshObjectName[M any]() string { +func inferMeshObjectName[M any]() (name, typeName string) { var zero M - typeName := reflect.TypeOf(zero).Name() - return lowercaseFirst(typeName) + typeName = reflect.TypeOf(zero).Name() + name = lowercaseFirst(typeName) + return regexp.MustCompile(`V\d+$`).ReplaceAllString(name, ""), typeName } func lowercaseFirst(s string) string { @@ -67,8 +69,8 @@ func (c MeshObjectClient[M]) meshObjectMimeType() string { } // Get retrieves a meshObject by ID. Returns nil if not found. -func (c MeshObjectClient[M]) Get(id string) (*M, error) { - body, err := c.doAuthorizedRequest(http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) +func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (*M, error) { + body, err := c.doAuthorizedRequest(ctx, http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) if errors.Is(err, errNotFound) { return nil, nil } @@ -76,30 +78,30 @@ func (c MeshObjectClient[M]) Get(id string) (*M, error) { } // Post creates a new meshObject with the given payload. -func (c MeshObjectClient[M]) Post(payload any) (*M, error) { - return unmarshalBody[M](c.doAuthorizedRequest(http.MethodPost, c.ApiUrl, withPayload(payload, c.meshObjectMimeType()))) +func (c MeshObjectClient[M]) Post(ctx context.Context, payload any) (*M, error) { + return unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodPost, c.ApiUrl, withPayload(payload, c.meshObjectMimeType()))) } // Put updates an existing meshObject by ID with the given payload. -func (c MeshObjectClient[M]) Put(id string, payload any) (*M, error) { - return unmarshalBody[M](c.doAuthorizedRequest(http.MethodPut, c.ApiUrl.JoinPath(id), withPayload(payload, c.meshObjectMimeType()))) +func (c MeshObjectClient[M]) Put(ctx context.Context, id string, payload any) (*M, error) { + return unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodPut, c.ApiUrl.JoinPath(id), withPayload(payload, c.meshObjectMimeType()))) } // Delete removes a meshObject by ID. -func (c MeshObjectClient[M]) Delete(id string) (err error) { - _, err = c.doAuthorizedRequest(http.MethodDelete, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) +func (c MeshObjectClient[M]) Delete(ctx context.Context, id string) (err error) { + _, err = c.doAuthorizedRequest(ctx, http.MethodDelete, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) return } // List retrieves all meshObjects with automatic pagination handling. // Accepts optional [RequestOption] parameters for filtering and querying. -func (c MeshObjectClient[M]) List(options ...RequestOption) ([]M, error) { +func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) ([]M, error) { var result []M embeddedKey := pluralizeName(c.Name) pageNumber := 0 for { - body, err := c.doAuthorizedRequest(http.MethodGet, c.ApiUrl, append(options, + body, err := c.doAuthorizedRequest(ctx, http.MethodGet, c.ApiUrl, append(options, withAccept(c.meshObjectMimeType()), WithUrlQuery("page", pageNumber), )...) @@ -128,19 +130,14 @@ func (c MeshObjectClient[M]) List(options ...RequestOption) ([]M, error) { } } -func (c MeshObjectClient[M]) doAuthorizedRequest(method string, url *url.URL, options ...RequestOption) ([]byte, error) { - if err := c.ensureAuthorization(); err != nil { +func (c MeshObjectClient[M]) doAuthorizedRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) ([]byte, error) { + if err := c.ensureAuthorization(ctx); err != nil { return nil, err } - return c.doRequest(method, url, append(options, - appendRequestModifier(func(req *http.Request) { - log.Println(req) - }), - withHeader("Authorization", c.Authorization), - )...) + return c.doRequest(ctx, method, url, append(options, withHeader("Authorization", c.Authorization))...) } -func (c MeshObjectClient[M]) ensureAuthorization() error { +func (c MeshObjectClient[M]) ensureAuthorization(ctx context.Context) error { if c.Authorization != "" && time.Until(c.AuthorizationExpiresAt) > 30*time.Second { return nil } @@ -157,7 +154,7 @@ func (c MeshObjectClient[M]) ensureAuthorization() error { ExpireSec int `json:"expires_in"` } - loginResult, err := unmarshalBody[loginResponse](c.doRequest("POST", loginApiUrl, + loginResult, err := unmarshalBody[loginResponse](c.doRequest(ctx, "POST", loginApiUrl, withPayload(loginRequest{ClientId: c.ApiKey, ClientSecret: c.ApiSecret}, "application/json")), ) if err != nil { diff --git a/internal/mesh_object_client_test.go b/internal/mesh_object_client_test.go index 3afcdaab..4c9219c3 100644 --- a/internal/mesh_object_client_test.go +++ b/internal/mesh_object_client_test.go @@ -8,22 +8,13 @@ import ( type MeshBuildingBlock struct{} type MeshBuildingBlockV2 struct{} -type MeshProject struct{} +type MeshTenantV4 struct{} type MeshWorkspace struct{} -type MeshProjectBinding struct{} -type MeshProjectGroupBinding struct { - MeshProjectBinding -} -type MeshProjectUserBinding struct { - MeshProjectBinding -} -type MeshWorkspaceGroupBinding struct{} -type MeshWorkspaceUserBinding struct{} func TestInferMeshObjectName(t *testing.T) { tests := []struct { name string - testFunc func() string + testFunc func() (string, string) expected string }{ { @@ -34,12 +25,7 @@ func TestInferMeshObjectName(t *testing.T) { { name: "MeshBuildingBlockV2", testFunc: inferMeshObjectName[MeshBuildingBlockV2], - expected: "meshBuildingBlockV2", - }, - { - name: "MeshProject", - testFunc: inferMeshObjectName[MeshProject], - expected: "meshProject", + expected: "meshBuildingBlock", }, { name: "MeshWorkspace", @@ -47,35 +33,15 @@ func TestInferMeshObjectName(t *testing.T) { expected: "meshWorkspace", }, { - name: "MeshProjectBinding", - testFunc: inferMeshObjectName[MeshProjectBinding], - expected: "meshProjectBinding", - }, - { - name: "MeshProjectGroupBinding (embedded struct)", - testFunc: inferMeshObjectName[MeshProjectGroupBinding], - expected: "meshProjectGroupBinding", - }, - { - name: "MeshProjectUserBinding (embedded struct)", - testFunc: inferMeshObjectName[MeshProjectUserBinding], - expected: "meshProjectUserBinding", - }, - { - name: "MeshWorkspaceGroupBinding (embedded struct)", - testFunc: inferMeshObjectName[MeshWorkspaceGroupBinding], - expected: "meshWorkspaceGroupBinding", - }, - { - name: "MeshWorkspaceUserBinding (embedded struct)", - testFunc: inferMeshObjectName[MeshWorkspaceUserBinding], - expected: "meshWorkspaceUserBinding", + name: "MeshTenantV4", + testFunc: inferMeshObjectName[MeshTenantV4], + expected: "meshTenant", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - actual := tt.testFunc() + actual, _ := tt.testFunc() assert.Equal(t, tt.expected, actual) }) } diff --git a/landingzone.go b/landingzone.go index b1ee0b4a..9081ee44 100644 --- a/landingzone.go +++ b/landingzone.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -67,24 +69,24 @@ type MeshLandingZoneClient struct { meshObject internal.MeshObjectClient[MeshLandingZone] } -func newLandingZoneClient(httpClient *internal.HttpClient) MeshLandingZoneClient { +func newLandingZoneClient(ctx context.Context, httpClient *internal.HttpClient) MeshLandingZoneClient { return MeshLandingZoneClient{ - meshObject: internal.NewMeshObjectClient[MeshLandingZone](httpClient, "v1-preview"), + meshObject: internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1-preview"), } } -func (c MeshLandingZoneClient) Read(name string) (*MeshLandingZone, error) { - return c.meshObject.Get(name) +func (c MeshLandingZoneClient) Read(ctx context.Context, name string) (*MeshLandingZone, error) { + return c.meshObject.Get(ctx, name) } -func (c MeshLandingZoneClient) Create(landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { - return c.meshObject.Post(landingZone) +func (c MeshLandingZoneClient) Create(ctx context.Context, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { + return c.meshObject.Post(ctx, landingZone) } -func (c MeshLandingZoneClient) Update(name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { - return c.meshObject.Put(name, landingZone) +func (c MeshLandingZoneClient) Update(ctx context.Context, name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { + return c.meshObject.Put(ctx, name, landingZone) } -func (c MeshLandingZoneClient) Delete(name string) error { - return c.meshObject.Delete(name) +func (c MeshLandingZoneClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) } diff --git a/location.go b/location.go index d37cc52d..ee6dbd4d 100644 --- a/location.go +++ b/location.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -39,24 +41,24 @@ type MeshLocationClient struct { meshObject internal.MeshObjectClient[MeshLocation] } -func newLocationClient(httpClient *internal.HttpClient) MeshLocationClient { +func newLocationClient(ctx context.Context, httpClient *internal.HttpClient) MeshLocationClient { return MeshLocationClient{ - meshObject: internal.NewMeshObjectClient[MeshLocation](httpClient, "v1-preview"), + meshObject: internal.NewMeshObjectClient[MeshLocation](ctx, httpClient, "v1-preview"), } } -func (c MeshLocationClient) Read(name string) (*MeshLocation, error) { - return c.meshObject.Get(name) +func (c MeshLocationClient) Read(ctx context.Context, name string) (*MeshLocation, error) { + return c.meshObject.Get(ctx, name) } -func (c MeshLocationClient) Create(location *MeshLocationCreate) (*MeshLocation, error) { - return c.meshObject.Post(location) +func (c MeshLocationClient) Create(ctx context.Context, location *MeshLocationCreate) (*MeshLocation, error) { + return c.meshObject.Post(ctx, location) } -func (c MeshLocationClient) Update(name string, location *MeshLocationCreate) (*MeshLocation, error) { - return c.meshObject.Put(name, location) +func (c MeshLocationClient) Update(ctx context.Context, name string, location *MeshLocationCreate) (*MeshLocation, error) { + return c.meshObject.Put(ctx, name, location) } -func (c MeshLocationClient) Delete(name string) error { - return c.meshObject.Delete(name) +func (c MeshLocationClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) } diff --git a/payment_method.go b/payment_method.go index d3ceefa5..7f9a041c 100644 --- a/payment_method.go +++ b/payment_method.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -40,24 +42,24 @@ type MeshPaymentMethodClient struct { meshObject internal.MeshObjectClient[MeshPaymentMethod] } -func newPaymentMethodClient(httpClient *internal.HttpClient) MeshPaymentMethodClient { +func newPaymentMethodClient(ctx context.Context, httpClient *internal.HttpClient) MeshPaymentMethodClient { return MeshPaymentMethodClient{ - meshObject: internal.NewMeshObjectClient[MeshPaymentMethod](httpClient, "v2"), + meshObject: internal.NewMeshObjectClient[MeshPaymentMethod](ctx, httpClient, "v2"), } } -func (c MeshPaymentMethodClient) Read(workspace string, identifier string) (*MeshPaymentMethod, error) { - return c.meshObject.Get(identifier) +func (c MeshPaymentMethodClient) Read(ctx context.Context, workspace string, identifier string) (*MeshPaymentMethod, error) { + return c.meshObject.Get(ctx, identifier) } -func (c MeshPaymentMethodClient) Create(paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { - return c.meshObject.Post(paymentMethod) +func (c MeshPaymentMethodClient) Create(ctx context.Context, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { + return c.meshObject.Post(ctx, paymentMethod) } -func (c MeshPaymentMethodClient) Update(identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { - return c.meshObject.Put(identifier, paymentMethod) +func (c MeshPaymentMethodClient) Update(ctx context.Context, identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { + return c.meshObject.Put(ctx, identifier, paymentMethod) } -func (c MeshPaymentMethodClient) Delete(identifier string) error { - return c.meshObject.Delete(identifier) +func (c MeshPaymentMethodClient) Delete(ctx context.Context, identifier string) error { + return c.meshObject.Delete(ctx, identifier) } diff --git a/platform.go b/platform.go index 0d33f125..39005302 100644 --- a/platform.go +++ b/platform.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -111,24 +113,24 @@ type MeshPlatformClient struct { meshObject internal.MeshObjectClient[MeshPlatform] } -func newPlatformClient(httpClient *internal.HttpClient) MeshPlatformClient { +func newPlatformClient(ctx context.Context, httpClient *internal.HttpClient) MeshPlatformClient { return MeshPlatformClient{ - meshObject: internal.NewMeshObjectClient[MeshPlatform](httpClient, "v2-preview"), + meshObject: internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2-preview"), } } -func (c MeshPlatformClient) Read(uuid string) (*MeshPlatform, error) { - return c.meshObject.Get(uuid) +func (c MeshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatform, error) { + return c.meshObject.Get(ctx, uuid) } -func (c MeshPlatformClient) Create(platform *MeshPlatformCreate) (*MeshPlatform, error) { - return c.meshObject.Post(platform) +func (c MeshPlatformClient) Create(ctx context.Context, platform *MeshPlatformCreate) (*MeshPlatform, error) { + return c.meshObject.Post(ctx, platform) } -func (c MeshPlatformClient) Update(uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { - return c.meshObject.Put(uuid, platform) +func (c MeshPlatformClient) Update(ctx context.Context, uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { + return c.meshObject.Put(ctx, uuid, platform) } -func (c MeshPlatformClient) Delete(uuid string) error { - return c.meshObject.Delete(uuid) +func (c MeshPlatformClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) } diff --git a/project.go b/project.go index 6402b6a9..efdb4d91 100644 --- a/project.go +++ b/project.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -39,9 +41,9 @@ type MeshProjectClient struct { meshObject internal.MeshObjectClient[MeshProject] } -func newProjectClient(httpClient *internal.HttpClient) MeshProjectClient { +func newProjectClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectClient { return MeshProjectClient{ - meshObject: internal.NewMeshObjectClient[MeshProject](httpClient, "v2"), + meshObject: internal.NewMeshObjectClient[MeshProject](ctx, httpClient, "v2"), } } @@ -49,28 +51,28 @@ func (c MeshProjectClient) projectId(workspace string, name string) string { return workspace + "." + name } -func (c MeshProjectClient) Read(workspace string, name string) (*MeshProject, error) { - return c.meshObject.Get(c.projectId(workspace, name)) +func (c MeshProjectClient) Read(ctx context.Context, workspace string, name string) (*MeshProject, error) { + return c.meshObject.Get(ctx, c.projectId(workspace, name)) } -func (c MeshProjectClient) List(workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { +func (c MeshProjectClient) List(ctx context.Context, workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { options := []internal.RequestOption{ internal.WithUrlQuery("workspaceIdentifier", workspaceIdentifier), } if paymentMethodIdentifier != nil { options = append(options, internal.WithUrlQuery("paymentIdentifier", *paymentMethodIdentifier)) } - return c.meshObject.List(options...) + return c.meshObject.List(ctx, options...) } -func (c MeshProjectClient) Create(project *MeshProjectCreate) (*MeshProject, error) { - return c.meshObject.Post(project) +func (c MeshProjectClient) Create(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) { + return c.meshObject.Post(ctx, project) } -func (c MeshProjectClient) Update(project *MeshProjectCreate) (*MeshProject, error) { - return c.meshObject.Put(c.projectId(project.Metadata.OwnedByWorkspace, project.Metadata.Name), project) +func (c MeshProjectClient) Update(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) { + return c.meshObject.Put(ctx, c.projectId(project.Metadata.OwnedByWorkspace, project.Metadata.Name), project) } -func (c MeshProjectClient) Delete(workspace string, name string) error { - return c.meshObject.Delete(c.projectId(workspace, name)) +func (c MeshProjectClient) Delete(ctx context.Context, workspace string, name string) error { + return c.meshObject.Delete(ctx, c.projectId(workspace, name)) } diff --git a/project_group_binding.go b/project_group_binding.go index 4d19df3c..69d36b37 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -12,20 +14,20 @@ type MeshProjectGroupBindingClient struct { meshObject internal.MeshObjectClient[MeshProjectGroupBinding] } -func newProjectGroupBindingClient(httpClient *internal.HttpClient) MeshProjectGroupBindingClient { +func newProjectGroupBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectGroupBindingClient { return MeshProjectGroupBindingClient{ - meshObject: internal.NewMeshObjectClient[MeshProjectGroupBinding](httpClient, "v3", "meshprojectbindings", "groupbindings"), + meshObject: internal.NewMeshObjectClient[MeshProjectGroupBinding](ctx, httpClient, "v3", "meshprojectbindings", "groupbindings"), } } -func (c MeshProjectGroupBindingClient) Read(name string) (*MeshProjectGroupBinding, error) { - return c.meshObject.Get(name) +func (c MeshProjectGroupBindingClient) Read(ctx context.Context, name string) (*MeshProjectGroupBinding, error) { + return c.meshObject.Get(ctx, name) } -func (c MeshProjectGroupBindingClient) Create(binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { - return c.meshObject.Post(binding) +func (c MeshProjectGroupBindingClient) Create(ctx context.Context, binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { + return c.meshObject.Post(ctx, binding) } -func (c MeshProjectGroupBindingClient) Delete(name string) error { - return c.meshObject.Delete(name) +func (c MeshProjectGroupBindingClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) } diff --git a/project_user_binding.go b/project_user_binding.go index 334343de..64838ef8 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -12,20 +14,20 @@ type MeshProjectUserBindingClient struct { meshObject internal.MeshObjectClient[MeshProjectUserBinding] } -func newProjectUserBindingClient(httpClient *internal.HttpClient) MeshProjectUserBindingClient { +func newProjectUserBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectUserBindingClient { return MeshProjectUserBindingClient{ - meshObject: internal.NewMeshObjectClient[MeshProjectUserBinding](httpClient, "v3", "meshprojectbindings", "userbindings"), + meshObject: internal.NewMeshObjectClient[MeshProjectUserBinding](ctx, httpClient, "v3", "meshprojectbindings", "userbindings"), } } -func (c MeshProjectUserBindingClient) Read(name string) (*MeshProjectUserBinding, error) { - return c.meshObject.Get(name) +func (c MeshProjectUserBindingClient) Read(ctx context.Context, name string) (*MeshProjectUserBinding, error) { + return c.meshObject.Get(ctx, name) } -func (c MeshProjectUserBindingClient) Create(binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { - return c.meshObject.Post(binding) +func (c MeshProjectUserBindingClient) Create(ctx context.Context, binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { + return c.meshObject.Post(ctx, binding) } -func (c MeshProjectUserBindingClient) Delete(name string) error { - return c.meshObject.Delete(name) +func (c MeshProjectUserBindingClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) } diff --git a/tag_definition.go b/tag_definition.go index 18f32e1c..4c2edebb 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -71,28 +73,28 @@ type MeshTagDefinitionClient struct { meshObject internal.MeshObjectClient[MeshTagDefinition] } -func newTagDefinitionClient(httpClient *internal.HttpClient) MeshTagDefinitionClient { +func newTagDefinitionClient(ctx context.Context, httpClient *internal.HttpClient) MeshTagDefinitionClient { return MeshTagDefinitionClient{ - meshObject: internal.NewMeshObjectClient[MeshTagDefinition](httpClient, "v1"), + meshObject: internal.NewMeshObjectClient[MeshTagDefinition](ctx, httpClient, "v1"), } } -func (c MeshTagDefinitionClient) List() ([]MeshTagDefinition, error) { - return c.meshObject.List() +func (c MeshTagDefinitionClient) List(ctx context.Context) ([]MeshTagDefinition, error) { + return c.meshObject.List(ctx) } -func (c MeshTagDefinitionClient) Read(name string) (*MeshTagDefinition, error) { - return c.meshObject.Get(name) +func (c MeshTagDefinitionClient) Read(ctx context.Context, name string) (*MeshTagDefinition, error) { + return c.meshObject.Get(ctx, name) } -func (c MeshTagDefinitionClient) Create(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - return c.meshObject.Post(tagDefinition) +func (c MeshTagDefinitionClient) Create(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { + return c.meshObject.Post(ctx, tagDefinition) } -func (c MeshTagDefinitionClient) Update(tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { - return c.meshObject.Put(tagDefinition.Metadata.Name, tagDefinition) +func (c MeshTagDefinitionClient) Update(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { + return c.meshObject.Put(ctx, tagDefinition.Metadata.Name, tagDefinition) } -func (c MeshTagDefinitionClient) Delete(name string) error { - return c.meshObject.Delete(name) +func (c MeshTagDefinitionClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) } diff --git a/tenant.go b/tenant.go index ccd998d7..732fa22e 100644 --- a/tenant.go +++ b/tenant.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -51,9 +53,9 @@ type MeshTenantClient struct { meshObject internal.MeshObjectClient[MeshTenant] } -func newTenantClient(httpClient *internal.HttpClient) MeshTenantClient { +func newTenantClient(ctx context.Context, httpClient *internal.HttpClient) MeshTenantClient { return MeshTenantClient{ - meshObject: internal.NewMeshObjectClient[MeshTenant](httpClient, "v3"), + meshObject: internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v3"), } } @@ -61,14 +63,14 @@ func (c MeshTenantClient) tenantId(workspace string, project string, platform st return workspace + "." + project + "." + platform } -func (c MeshTenantClient) Read(workspace string, project string, platform string) (*MeshTenant, error) { - return c.meshObject.Get(c.tenantId(workspace, project, platform)) +func (c MeshTenantClient) Read(ctx context.Context, workspace string, project string, platform string) (*MeshTenant, error) { + return c.meshObject.Get(ctx, c.tenantId(workspace, project, platform)) } -func (c MeshTenantClient) Create(tenant *MeshTenantCreate) (*MeshTenant, error) { - return c.meshObject.Post(tenant) +func (c MeshTenantClient) Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) { + return c.meshObject.Post(ctx, tenant) } -func (c MeshTenantClient) Delete(workspace string, project string, platform string) error { - return c.meshObject.Delete(c.tenantId(workspace, project, platform)) +func (c MeshTenantClient) Delete(ctx context.Context, workspace string, project string, platform string) error { + return c.meshObject.Delete(ctx, c.tenantId(workspace, project, platform)) } diff --git a/tenant_v4.go b/tenant_v4.go index eccc4c54..bcb1e583 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -62,22 +62,22 @@ type MeshTenantV4Client struct { meshObject internal.MeshObjectClient[MeshTenantV4] } -func newTenantV4Client(httpClient *internal.HttpClient) MeshTenantV4Client { +func newTenantV4Client(ctx context.Context, httpClient *internal.HttpClient) MeshTenantV4Client { return MeshTenantV4Client{ - meshObject: internal.NewMeshObjectClient[MeshTenantV4](httpClient, "v4-preview"), + meshObject: internal.NewMeshObjectClient[MeshTenantV4](ctx, httpClient, "v4-preview"), } } -func (c MeshTenantV4Client) Read(uuid string) (*MeshTenantV4, error) { - return c.meshObject.Get(uuid) +func (c MeshTenantV4Client) Read(ctx context.Context, uuid string) (*MeshTenantV4, error) { + return c.meshObject.Get(ctx, uuid) } -func (c MeshTenantV4Client) Create(tenant *MeshTenantV4Create) (*MeshTenantV4, error) { - return c.meshObject.Post(tenant) +func (c MeshTenantV4Client) Create(ctx context.Context, tenant *MeshTenantV4Create) (*MeshTenantV4, error) { + return c.meshObject.Post(ctx, tenant) } -func (c MeshTenantV4Client) Delete(uuid string) error { - return c.meshObject.Delete(uuid) +func (c MeshTenantV4Client) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) } // PollUntilCreation polls a tenant until creation completes (platformTenantId is set) @@ -85,14 +85,14 @@ func (c MeshTenantV4Client) Delete(uuid string) error { func (c MeshTenantV4Client) PollUntilCreation(ctx context.Context, uuid string) (*MeshTenantV4, error) { var result *MeshTenantV4 - err := retry.RetryContext(ctx, 30*time.Minute, c.waitForCreationFunc(uuid, &result)) + err := retry.RetryContext(ctx, 30*time.Minute, c.waitForCreationFunc(ctx, uuid, &result)) return result, err } // waitForCreationFunc returns a RetryFunc that checks tenant creation status. -func (c MeshTenantV4Client) waitForCreationFunc(uuid string, result **MeshTenantV4) retry.RetryFunc { +func (c MeshTenantV4Client) waitForCreationFunc(ctx context.Context, uuid string, result **MeshTenantV4) retry.RetryFunc { return func() *retry.RetryError { - current, err := c.Read(uuid) + current, err := c.Read(ctx, uuid) if err != nil { return retry.NonRetryableError(fmt.Errorf("could not read tenant status while waiting for creation: %w", err)) } @@ -115,13 +115,13 @@ func (c MeshTenantV4Client) waitForCreationFunc(uuid string, result **MeshTenant // PollUntilDeletion polls a tenant until it is deleted (not found) // Returns nil on successful deletion or an error if polling fails or times out. func (c MeshTenantV4Client) PollUntilDeletion(ctx context.Context, uuid string) error { - return retry.RetryContext(ctx, 30*time.Minute, c.waitForDeletionFunc(uuid)) + return retry.RetryContext(ctx, 30*time.Minute, c.waitForDeletionFunc(ctx, uuid)) } // waitForDeletionFunc returns a RetryFunc that checks tenant deletion status. -func (c MeshTenantV4Client) waitForDeletionFunc(uuid string) retry.RetryFunc { +func (c MeshTenantV4Client) waitForDeletionFunc(ctx context.Context, uuid string) retry.RetryFunc { return func() *retry.RetryError { - current, err := c.Read(uuid) + current, err := c.Read(ctx, uuid) if err != nil { return retry.NonRetryableError(fmt.Errorf("could not read tenant status while waiting for deletion: %w", err)) } diff --git a/workspace.go b/workspace.go index 578643f7..ca984c88 100644 --- a/workspace.go +++ b/workspace.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -37,24 +39,24 @@ type MeshWorkspaceClient struct { meshObject internal.MeshObjectClient[MeshWorkspace] } -func newWorkspaceClient(httpClient *internal.HttpClient) MeshWorkspaceClient { +func newWorkspaceClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceClient { return MeshWorkspaceClient{ - meshObject: internal.NewMeshObjectClient[MeshWorkspace](httpClient, "v2"), + meshObject: internal.NewMeshObjectClient[MeshWorkspace](ctx, httpClient, "v2"), } } -func (c MeshWorkspaceClient) Read(name string) (*MeshWorkspace, error) { - return c.meshObject.Get(name) +func (c MeshWorkspaceClient) Read(ctx context.Context, name string) (*MeshWorkspace, error) { + return c.meshObject.Get(ctx, name) } -func (c MeshWorkspaceClient) Create(workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { - return c.meshObject.Post(workspace) +func (c MeshWorkspaceClient) Create(ctx context.Context, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { + return c.meshObject.Post(ctx, workspace) } -func (c MeshWorkspaceClient) Update(name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { - return c.meshObject.Put(name, workspace) +func (c MeshWorkspaceClient) Update(ctx context.Context, name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { + return c.meshObject.Put(ctx, name, workspace) } -func (c MeshWorkspaceClient) Delete(name string) error { - return c.meshObject.Delete(name) +func (c MeshWorkspaceClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) } diff --git a/workspace_group_binding.go b/workspace_group_binding.go index c947fb26..74d0f74c 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -12,20 +14,20 @@ type MeshWorkspaceGroupBindingClient struct { meshObject internal.MeshObjectClient[MeshWorkspaceGroupBinding] } -func newWorkspaceGroupBindingClient(httpClient *internal.HttpClient) MeshWorkspaceGroupBindingClient { +func newWorkspaceGroupBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceGroupBindingClient { return MeshWorkspaceGroupBindingClient{ - meshObject: internal.NewMeshObjectClient[MeshWorkspaceGroupBinding](httpClient, "v2", "meshworkspacebindings", "groupbindings"), + meshObject: internal.NewMeshObjectClient[MeshWorkspaceGroupBinding](ctx, httpClient, "v2", "meshworkspacebindings", "groupbindings"), } } -func (c MeshWorkspaceGroupBindingClient) Read(name string) (*MeshWorkspaceGroupBinding, error) { - return c.meshObject.Get(name) +func (c MeshWorkspaceGroupBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceGroupBinding, error) { + return c.meshObject.Get(ctx, name) } -func (c MeshWorkspaceGroupBindingClient) Create(binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { - return c.meshObject.Post(binding) +func (c MeshWorkspaceGroupBindingClient) Create(ctx context.Context, binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { + return c.meshObject.Post(ctx, binding) } -func (c MeshWorkspaceGroupBindingClient) Delete(name string) error { - return c.meshObject.Delete(name) +func (c MeshWorkspaceGroupBindingClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) } diff --git a/workspace_user_binding.go b/workspace_user_binding.go index 5e917967..d63a7bf5 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -1,6 +1,8 @@ package client import ( + "context" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -12,20 +14,20 @@ type MeshWorkspaceUserBindingClient struct { meshObject internal.MeshObjectClient[MeshWorkspaceUserBinding] } -func newWorkspaceUserBindingClient(httpClient *internal.HttpClient) MeshWorkspaceUserBindingClient { +func newWorkspaceUserBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceUserBindingClient { return MeshWorkspaceUserBindingClient{ - meshObject: internal.NewMeshObjectClient[MeshWorkspaceUserBinding](httpClient, "v2", "meshworkspacebindings", "userbindings"), + meshObject: internal.NewMeshObjectClient[MeshWorkspaceUserBinding](ctx, httpClient, "v2", "meshworkspacebindings", "userbindings"), } } -func (c MeshWorkspaceUserBindingClient) Read(name string) (*MeshWorkspaceUserBinding, error) { - return c.meshObject.Get(name) +func (c MeshWorkspaceUserBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceUserBinding, error) { + return c.meshObject.Get(ctx, name) } -func (c MeshWorkspaceUserBindingClient) Create(binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { - return c.meshObject.Post(binding) +func (c MeshWorkspaceUserBindingClient) Create(ctx context.Context, binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { + return c.meshObject.Post(ctx, binding) } -func (c MeshWorkspaceUserBindingClient) Delete(name string) error { - return c.meshObject.Delete(name) +func (c MeshWorkspaceUserBindingClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) } From 276e69ad4f212121dd61d8ce66abc4a83a641800 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 13 Jan 2026 00:11:35 +0100 Subject: [PATCH 080/215] refactor: add util.PollAtMostFor(...).Until instead of cluttering up client package, fix depguard config --- buildingblock_v2.go | 86 ++++++++++++--------------------------------- tenant_v4.go | 72 +++++++++---------------------------- 2 files changed, 40 insertions(+), 118 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index c06409e1..023c66ed 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -3,9 +3,6 @@ package client import ( "context" "fmt" - "time" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -78,7 +75,13 @@ func newBuildingBlockV2Client(ctx context.Context, httpClient *internal.HttpClie } func (c MeshBuildingBlockV2Client) Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { - return c.meshObject.Get(ctx, uuid) + return c.ReadFunc(uuid)(ctx) +} + +func (c MeshBuildingBlockV2Client) ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) { + return func(ctx context.Context) (*MeshBuildingBlockV2, error) { + return c.meshObject.Get(ctx, uuid) + } } func (c MeshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { @@ -89,67 +92,24 @@ func (c MeshBuildingBlockV2Client) Delete(ctx context.Context, uuid string) erro return c.meshObject.Delete(ctx, uuid) } -// PollUntilCompletion polls a building block until it reaches a terminal state (SUCCEEDED or FAILED) -// Returns the final building block state or an error if polling fails or times out. -func (c MeshBuildingBlockV2Client) PollUntilCompletion(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { - var result *MeshBuildingBlockV2 - - err := retry.RetryContext(ctx, 30*time.Minute, c.waitForCompletionFunc(ctx, uuid, &result)) - return result, err -} - -// waitForCompletionFunc returns a RetryFunc that checks building block completion status. -func (c MeshBuildingBlockV2Client) waitForCompletionFunc(ctx context.Context, uuid string, result **MeshBuildingBlockV2) retry.RetryFunc { - return func() *retry.RetryError { - current, err := c.Read(ctx, uuid) - if err != nil { - return retry.NonRetryableError(fmt.Errorf("could not read building block status while waiting for completion: %w", err)) - } - - if current == nil { - return retry.NonRetryableError(fmt.Errorf("building block was not found while waiting for completion")) - } - *result = current - - // Check if we've reached a terminal state - status := current.Status.Status - switch status { - case BUILDING_BLOCK_STATUS_SUCCEEDED: - return nil // Success, stop retrying - case BUILDING_BLOCK_STATUS_FAILED: - return retry.NonRetryableError(fmt.Errorf("building block %s reached FAILED state", uuid)) - } - - // Not done yet, continue polling - return retry.RetryableError(fmt.Errorf("waiting for building block %s to complete: currently in %s state", uuid, status)) +func (bb *MeshBuildingBlockV2) CreateSuccessful() (done bool, err error) { + switch { + case bb == nil: + err = fmt.Errorf("building block not found after creation") + case bb.Status.Status == BUILDING_BLOCK_STATUS_FAILED: + err = fmt.Errorf("building block %s reached FAILED state during creation, check the building block run logs in meshStack", bb.Metadata.Uuid) + case bb.Status.Status == BUILDING_BLOCK_STATUS_SUCCEEDED: + done = true } + return } -// PollUntilDeletion polls a building block until it is deleted (not found) -// Returns nil on successful deletion or an error if polling fails or times out. -func (c MeshBuildingBlockV2Client) PollUntilDeletion(ctx context.Context, uuid string) error { - return retry.RetryContext(ctx, 30*time.Minute, c.waitForDeletionFunc(ctx, uuid)) -} - -// waitForDeletionFunc returns a RetryFunc that checks building block deletion status. -func (c MeshBuildingBlockV2Client) waitForDeletionFunc(ctx context.Context, uuid string) retry.RetryFunc { - return func() *retry.RetryError { - current, err := c.Read(ctx, uuid) - if err != nil { - return retry.NonRetryableError(fmt.Errorf("could not read building block status while waiting for deletion: %w", err)) - } - - // If building block is not found, deletion is complete - if current == nil { - return nil // Success, stop retrying - } - - // If building block is in FAILED state during deletion, consider it a terminal state - if current.Status.Status == BUILDING_BLOCK_STATUS_FAILED { - return retry.NonRetryableError(fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", uuid)) - } - - // Not done yet, continue polling - return retry.RetryableError(fmt.Errorf("waiting for building block %s to be deleted: currently in %s state", uuid, current.Status.Status)) +func (bb *MeshBuildingBlockV2) DeletionSuccessful() (done bool, err error) { + switch { + case bb == nil: + done = true + case bb.Status.Status == BUILDING_BLOCK_STATUS_FAILED: + err = fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", bb.Metadata.Uuid) } + return } diff --git a/tenant_v4.go b/tenant_v4.go index bcb1e583..f60fa32f 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -3,9 +3,6 @@ package client import ( "context" "fmt" - "time" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) @@ -69,7 +66,13 @@ func newTenantV4Client(ctx context.Context, httpClient *internal.HttpClient) Mes } func (c MeshTenantV4Client) Read(ctx context.Context, uuid string) (*MeshTenantV4, error) { - return c.meshObject.Get(ctx, uuid) + return c.ReadFunc(uuid)(ctx) +} + +func (c MeshTenantV4Client) ReadFunc(uuid string) func(ctx context.Context) (*MeshTenantV4, error) { + return func(ctx context.Context) (*MeshTenantV4, error) { + return c.meshObject.Get(ctx, uuid) + } } func (c MeshTenantV4Client) Create(ctx context.Context, tenant *MeshTenantV4Create) (*MeshTenantV4, error) { @@ -80,58 +83,17 @@ func (c MeshTenantV4Client) Delete(ctx context.Context, uuid string) error { return c.meshObject.Delete(ctx, uuid) } -// PollUntilCreation polls a tenant until creation completes (platformTenantId is set) -// Returns the final tenant state or an error if polling fails or times out. -func (c MeshTenantV4Client) PollUntilCreation(ctx context.Context, uuid string) (*MeshTenantV4, error) { - var result *MeshTenantV4 - - err := retry.RetryContext(ctx, 30*time.Minute, c.waitForCreationFunc(ctx, uuid, &result)) - return result, err -} - -// waitForCreationFunc returns a RetryFunc that checks tenant creation status. -func (c MeshTenantV4Client) waitForCreationFunc(ctx context.Context, uuid string, result **MeshTenantV4) retry.RetryFunc { - return func() *retry.RetryError { - current, err := c.Read(ctx, uuid) - if err != nil { - return retry.NonRetryableError(fmt.Errorf("could not read tenant status while waiting for creation: %w", err)) - } - - if current == nil { - return retry.NonRetryableError(fmt.Errorf("tenant was not found while waiting for creation")) - } - - // Check if creation is complete (platformTenantId is set) - if current.Spec.PlatformTenantId != nil && *current.Spec.PlatformTenantId != "" { - *result = current - return nil // Success, stop retrying - } - - // Not done yet, continue polling - return retry.RetryableError(fmt.Errorf("waiting for tenant %s creation to complete: platformTenantId not yet set", uuid)) +func (tenant *MeshTenantV4) CreationSuccessful() (done bool, err error) { + switch { + case tenant == nil: + err = fmt.Errorf("tenant not found after creation") + case tenant.Spec.PlatformTenantId != nil && *tenant.Spec.PlatformTenantId != "": + // Creation is complete (platformTenantId is set and not empty) + done = true } + return } -// PollUntilDeletion polls a tenant until it is deleted (not found) -// Returns nil on successful deletion or an error if polling fails or times out. -func (c MeshTenantV4Client) PollUntilDeletion(ctx context.Context, uuid string) error { - return retry.RetryContext(ctx, 30*time.Minute, c.waitForDeletionFunc(ctx, uuid)) -} - -// waitForDeletionFunc returns a RetryFunc that checks tenant deletion status. -func (c MeshTenantV4Client) waitForDeletionFunc(ctx context.Context, uuid string) retry.RetryFunc { - return func() *retry.RetryError { - current, err := c.Read(ctx, uuid) - if err != nil { - return retry.NonRetryableError(fmt.Errorf("could not read tenant status while waiting for deletion: %w", err)) - } - - // If tenant is not found, deletion is complete - if current == nil { - return nil // Success, stop retrying - } - - // Not done yet, continue polling - return retry.RetryableError(fmt.Errorf("waiting for tenant %s to be deleted: still present", uuid)) - } +func (tenant *MeshTenantV4) DeletionSuccessful() (done bool, err error) { + return tenant == nil, nil } From 5188e2dc1bedb89cfe43232da93b927ef8547167 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 13 Jan 2026 10:43:33 +0100 Subject: [PATCH 081/215] refactor: rename resource/datasource struct client fields (resolves review comment) --- buildingblock.go | 4 +--- buildingblock_v2.go | 4 +--- integrations.go | 4 +--- landingzone.go | 4 +--- location.go | 4 +--- payment_method.go | 4 +--- platform.go | 4 +--- project.go | 4 +--- project_group_binding.go | 4 +--- project_user_binding.go | 4 +--- tag_definition.go | 4 +--- tenant.go | 4 +--- tenant_v4.go | 4 +--- workspace.go | 4 +--- workspace_group_binding.go | 4 +--- workspace_user_binding.go | 4 +--- 16 files changed, 16 insertions(+), 48 deletions(-) diff --git a/buildingblock.go b/buildingblock.go index f7c266ea..c89d6069 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -81,9 +81,7 @@ type MeshBuildingBlockClient struct { } func newBuildingBlockClient(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockClient { - return MeshBuildingBlockClient{ - meshObject: internal.NewMeshObjectClient[MeshBuildingBlock](ctx, httpClient, "v1"), - } + return MeshBuildingBlockClient{internal.NewMeshObjectClient[MeshBuildingBlock](ctx, httpClient, "v1")} } func (c MeshBuildingBlockClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) { diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 023c66ed..fa3a3d5f 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -69,9 +69,7 @@ type MeshBuildingBlockV2Client struct { } func newBuildingBlockV2Client(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockV2Client { - return MeshBuildingBlockV2Client{ - meshObject: internal.NewMeshObjectClient[MeshBuildingBlockV2](ctx, httpClient, "v2-preview"), - } + return MeshBuildingBlockV2Client{internal.NewMeshObjectClient[MeshBuildingBlockV2](ctx, httpClient, "v2-preview")} } func (c MeshBuildingBlockV2Client) Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { diff --git a/integrations.go b/integrations.go index 3f7e2cff..df2643e5 100644 --- a/integrations.go +++ b/integrations.go @@ -90,9 +90,7 @@ type MeshIntegrationClient struct { } func newIntegrationClient(ctx context.Context, httpClient *internal.HttpClient) MeshIntegrationClient { - return MeshIntegrationClient{ - meshObject: internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1-preview"), - } + return MeshIntegrationClient{internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1-preview")} } func (c MeshIntegrationClient) integrationId(workspace string, uuid string) string { diff --git a/landingzone.go b/landingzone.go index 9081ee44..6588d19e 100644 --- a/landingzone.go +++ b/landingzone.go @@ -70,9 +70,7 @@ type MeshLandingZoneClient struct { } func newLandingZoneClient(ctx context.Context, httpClient *internal.HttpClient) MeshLandingZoneClient { - return MeshLandingZoneClient{ - meshObject: internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1-preview"), - } + return MeshLandingZoneClient{internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1-preview")} } func (c MeshLandingZoneClient) Read(ctx context.Context, name string) (*MeshLandingZone, error) { diff --git a/location.go b/location.go index ee6dbd4d..3656744b 100644 --- a/location.go +++ b/location.go @@ -42,9 +42,7 @@ type MeshLocationClient struct { } func newLocationClient(ctx context.Context, httpClient *internal.HttpClient) MeshLocationClient { - return MeshLocationClient{ - meshObject: internal.NewMeshObjectClient[MeshLocation](ctx, httpClient, "v1-preview"), - } + return MeshLocationClient{internal.NewMeshObjectClient[MeshLocation](ctx, httpClient, "v1-preview")} } func (c MeshLocationClient) Read(ctx context.Context, name string) (*MeshLocation, error) { diff --git a/payment_method.go b/payment_method.go index 7f9a041c..98111ff2 100644 --- a/payment_method.go +++ b/payment_method.go @@ -43,9 +43,7 @@ type MeshPaymentMethodClient struct { } func newPaymentMethodClient(ctx context.Context, httpClient *internal.HttpClient) MeshPaymentMethodClient { - return MeshPaymentMethodClient{ - meshObject: internal.NewMeshObjectClient[MeshPaymentMethod](ctx, httpClient, "v2"), - } + return MeshPaymentMethodClient{internal.NewMeshObjectClient[MeshPaymentMethod](ctx, httpClient, "v2")} } func (c MeshPaymentMethodClient) Read(ctx context.Context, workspace string, identifier string) (*MeshPaymentMethod, error) { diff --git a/platform.go b/platform.go index 39005302..bd7b7fca 100644 --- a/platform.go +++ b/platform.go @@ -114,9 +114,7 @@ type MeshPlatformClient struct { } func newPlatformClient(ctx context.Context, httpClient *internal.HttpClient) MeshPlatformClient { - return MeshPlatformClient{ - meshObject: internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2-preview"), - } + return MeshPlatformClient{internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2-preview")} } func (c MeshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatform, error) { diff --git a/project.go b/project.go index efdb4d91..d8a2fba8 100644 --- a/project.go +++ b/project.go @@ -42,9 +42,7 @@ type MeshProjectClient struct { } func newProjectClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectClient { - return MeshProjectClient{ - meshObject: internal.NewMeshObjectClient[MeshProject](ctx, httpClient, "v2"), - } + return MeshProjectClient{internal.NewMeshObjectClient[MeshProject](ctx, httpClient, "v2")} } func (c MeshProjectClient) projectId(workspace string, name string) string { diff --git a/project_group_binding.go b/project_group_binding.go index 69d36b37..916d0e61 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -15,9 +15,7 @@ type MeshProjectGroupBindingClient struct { } func newProjectGroupBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectGroupBindingClient { - return MeshProjectGroupBindingClient{ - meshObject: internal.NewMeshObjectClient[MeshProjectGroupBinding](ctx, httpClient, "v3", "meshprojectbindings", "groupbindings"), - } + return MeshProjectGroupBindingClient{internal.NewMeshObjectClient[MeshProjectGroupBinding](ctx, httpClient, "v3", "meshprojectbindings", "groupbindings")} } func (c MeshProjectGroupBindingClient) Read(ctx context.Context, name string) (*MeshProjectGroupBinding, error) { diff --git a/project_user_binding.go b/project_user_binding.go index 64838ef8..2ddcd6be 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -15,9 +15,7 @@ type MeshProjectUserBindingClient struct { } func newProjectUserBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectUserBindingClient { - return MeshProjectUserBindingClient{ - meshObject: internal.NewMeshObjectClient[MeshProjectUserBinding](ctx, httpClient, "v3", "meshprojectbindings", "userbindings"), - } + return MeshProjectUserBindingClient{internal.NewMeshObjectClient[MeshProjectUserBinding](ctx, httpClient, "v3", "meshprojectbindings", "userbindings")} } func (c MeshProjectUserBindingClient) Read(ctx context.Context, name string) (*MeshProjectUserBinding, error) { diff --git a/tag_definition.go b/tag_definition.go index 4c2edebb..5b78ca94 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -74,9 +74,7 @@ type MeshTagDefinitionClient struct { } func newTagDefinitionClient(ctx context.Context, httpClient *internal.HttpClient) MeshTagDefinitionClient { - return MeshTagDefinitionClient{ - meshObject: internal.NewMeshObjectClient[MeshTagDefinition](ctx, httpClient, "v1"), - } + return MeshTagDefinitionClient{internal.NewMeshObjectClient[MeshTagDefinition](ctx, httpClient, "v1")} } func (c MeshTagDefinitionClient) List(ctx context.Context) ([]MeshTagDefinition, error) { diff --git a/tenant.go b/tenant.go index 732fa22e..c68e9fba 100644 --- a/tenant.go +++ b/tenant.go @@ -54,9 +54,7 @@ type MeshTenantClient struct { } func newTenantClient(ctx context.Context, httpClient *internal.HttpClient) MeshTenantClient { - return MeshTenantClient{ - meshObject: internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v3"), - } + return MeshTenantClient{internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v3")} } func (c MeshTenantClient) tenantId(workspace string, project string, platform string) string { diff --git a/tenant_v4.go b/tenant_v4.go index f60fa32f..4d6f3170 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -60,9 +60,7 @@ type MeshTenantV4Client struct { } func newTenantV4Client(ctx context.Context, httpClient *internal.HttpClient) MeshTenantV4Client { - return MeshTenantV4Client{ - meshObject: internal.NewMeshObjectClient[MeshTenantV4](ctx, httpClient, "v4-preview"), - } + return MeshTenantV4Client{internal.NewMeshObjectClient[MeshTenantV4](ctx, httpClient, "v4-preview")} } func (c MeshTenantV4Client) Read(ctx context.Context, uuid string) (*MeshTenantV4, error) { diff --git a/workspace.go b/workspace.go index ca984c88..e0193081 100644 --- a/workspace.go +++ b/workspace.go @@ -40,9 +40,7 @@ type MeshWorkspaceClient struct { } func newWorkspaceClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceClient { - return MeshWorkspaceClient{ - meshObject: internal.NewMeshObjectClient[MeshWorkspace](ctx, httpClient, "v2"), - } + return MeshWorkspaceClient{internal.NewMeshObjectClient[MeshWorkspace](ctx, httpClient, "v2")} } func (c MeshWorkspaceClient) Read(ctx context.Context, name string) (*MeshWorkspace, error) { diff --git a/workspace_group_binding.go b/workspace_group_binding.go index 74d0f74c..e4404a6c 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -15,9 +15,7 @@ type MeshWorkspaceGroupBindingClient struct { } func newWorkspaceGroupBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceGroupBindingClient { - return MeshWorkspaceGroupBindingClient{ - meshObject: internal.NewMeshObjectClient[MeshWorkspaceGroupBinding](ctx, httpClient, "v2", "meshworkspacebindings", "groupbindings"), - } + return MeshWorkspaceGroupBindingClient{internal.NewMeshObjectClient[MeshWorkspaceGroupBinding](ctx, httpClient, "v2", "meshworkspacebindings", "groupbindings")} } func (c MeshWorkspaceGroupBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceGroupBinding, error) { diff --git a/workspace_user_binding.go b/workspace_user_binding.go index d63a7bf5..eb552d18 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -15,9 +15,7 @@ type MeshWorkspaceUserBindingClient struct { } func newWorkspaceUserBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceUserBindingClient { - return MeshWorkspaceUserBindingClient{ - meshObject: internal.NewMeshObjectClient[MeshWorkspaceUserBinding](ctx, httpClient, "v2", "meshworkspacebindings", "userbindings"), - } + return MeshWorkspaceUserBindingClient{internal.NewMeshObjectClient[MeshWorkspaceUserBinding](ctx, httpClient, "v2", "meshworkspacebindings", "userbindings")} } func (c MeshWorkspaceUserBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceUserBinding, error) { From 234279bb06f0ae58817ca44afd933093d8f7beb7 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Fri, 16 Jan 2026 15:53:05 +0100 Subject: [PATCH 082/215] feat: platform type data sources and resource --- client.go | 2 ++ platform_type.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 platform_type.go diff --git a/client.go b/client.go index 3b1dee22..70ffc312 100644 --- a/client.go +++ b/client.go @@ -26,6 +26,7 @@ type Client struct { Workspace MeshWorkspaceClient WorkspaceGroupBinding MeshWorkspaceGroupBindingClient WorkspaceUserBinding MeshWorkspaceUserBindingClient + PlatformType MeshPlatformTypeClient } func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret string) Client { @@ -56,5 +57,6 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret str newWorkspaceClient(ctx, httpClient), newWorkspaceGroupBindingClient(ctx, httpClient), newWorkspaceUserBindingClient(ctx, httpClient), + newPlatformTypeClient(ctx, httpClient), } } diff --git a/platform_type.go b/platform_type.go new file mode 100644 index 00000000..cfabea66 --- /dev/null +++ b/platform_type.go @@ -0,0 +1,73 @@ +package client + +import ( + "context" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + +type MeshPlatformType struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshPlatformTypeMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPlatformTypeMetadata struct { + Name string `json:"name" tfsdk:"name"` + CreatedOn *string `json:"createdOn" tfsdk:"created_on"` + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` +} + +type MeshPlatformTypeSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Category string `json:"category" tfsdk:"category"` + DefaultEndpoint *string `json:"defaultEndpoint,omitempty" tfsdk:"default_endpoint"` + Icon string `json:"icon" tfsdk:"icon"` +} + +type MeshPlatformTypeCreate struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshPlatformTypeCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` +} + +type MeshPlatformTypeCreateMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +type MeshPlatformTypeClient struct { + meshObject internal.MeshObjectClient[MeshPlatformType] +} + +func newPlatformTypeClient(ctx context.Context, httpClient *internal.HttpClient) MeshPlatformTypeClient { + return MeshPlatformTypeClient{internal.NewMeshObjectClient[MeshPlatformType](ctx, httpClient, "v1-preview")} +} + +func (c MeshPlatformTypeClient) Read(ctx context.Context, identifier string) (*MeshPlatformType, error) { + return c.meshObject.Get(ctx, identifier) +} + +func (c MeshPlatformTypeClient) Create(ctx context.Context, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) { + return c.meshObject.Post(ctx, platformType) +} + +func (c MeshPlatformTypeClient) Update(ctx context.Context, name string, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) { + return c.meshObject.Put(ctx, name, platformType) +} + +func (c MeshPlatformTypeClient) Delete(ctx context.Context, name string) error { + return c.meshObject.Delete(ctx, name) +} + +func (c MeshPlatformTypeClient) List(ctx context.Context, category *string, lifecycleStatus *string) ([]MeshPlatformType, error) { + var options []internal.RequestOption + if category != nil { + options = append(options, internal.WithUrlQuery("category", *category)) + } + if lifecycleStatus != nil { + options = append(options, internal.WithUrlQuery("lifecycleStatus", *lifecycleStatus)) + } + return c.meshObject.List(ctx, options...) +} From 1eb24de91178857a8a86b23d0f074241926bc62b Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Tue, 20 Jan 2026 09:39:12 +0100 Subject: [PATCH 083/215] feat: add status field to platform type --- platform_type.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/platform_type.go b/platform_type.go index cfabea66..2cae4fbc 100644 --- a/platform_type.go +++ b/platform_type.go @@ -11,6 +11,11 @@ type MeshPlatformType struct { Kind string `json:"kind" tfsdk:"kind"` Metadata MeshPlatformTypeMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` + Status MeshPlatformTypeStatus `json:"status" tfsdk:"status"` +} + +type MeshPlatformTypeStatus struct { + LifecycleState string `json:"lifecycleState" tfsdk:"lifecycle_state"` } type MeshPlatformTypeMetadata struct { From c073f53e3ddbce48c054097bd8a3c47a841651c7 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 21 Jan 2026 09:49:42 +0100 Subject: [PATCH 084/215] fix: upstream structure has changed --- platform_type.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/platform_type.go b/platform_type.go index 2cae4fbc..366cb196 100644 --- a/platform_type.go +++ b/platform_type.go @@ -15,7 +15,11 @@ type MeshPlatformType struct { } type MeshPlatformTypeStatus struct { - LifecycleState string `json:"lifecycleState" tfsdk:"lifecycle_state"` + Lifecycle MeshPlatformTypeLifecycle `json:"lifecycle" tfsdk:"lifecycle"` +} + +type MeshPlatformTypeLifecycle struct { + State string `json:"state" tfsdk:"state"` } type MeshPlatformTypeMetadata struct { From 0b94a1b302a6b33847993e30cc5dd6bf1c9655fb Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Mon, 19 Jan 2026 17:04:05 +0100 Subject: [PATCH 085/215] feat: login via api token --- client.go | 43 ++++++++++++++++++++++++++++++- client_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 client_test.go diff --git a/client.go b/client.go index 70ffc312..3d48749a 100644 --- a/client.go +++ b/client.go @@ -2,8 +2,12 @@ package client import ( "context" + "encoding/base64" + "encoding/json" + "fmt" "net/http" "net/url" + "strings" "time" "github.com/meshcloud/terraform-provider-meshstack/client/internal" @@ -29,7 +33,7 @@ type Client struct { PlatformType MeshPlatformTypeClient } -func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret string) Client { +func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret, apiToken string) Client { httpClient := &internal.HttpClient{ Client: http.Client{Timeout: 5 * time.Minute}, RootUrl: rootUrl, @@ -40,6 +44,18 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret str ApiKey: apiKey, ApiSecret: apiSecret, } + + if apiToken != "" { + httpClient.Authorization = "Bearer " + apiToken + + if expiresAt, err := parseTokenExpiration(apiToken); err == nil { + httpClient.AuthorizationExpiresAt = expiresAt + } else { + // If token has no expiration we assume it is valid for the default duration. + httpClient.AuthorizationExpiresAt = time.Now().Add(6 * time.Hour) + } + } + return Client{ newBuildingBlockClient(ctx, httpClient), newBuildingBlockV2Client(ctx, httpClient), @@ -60,3 +76,28 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret str newPlatformTypeClient(ctx, httpClient), } } + +func parseTokenExpiration(token string) (time.Time, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, fmt.Errorf("invalid token format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, err + } + + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return time.Time{}, err + } + + if claims.Exp == 0 { + return time.Time{}, fmt.Errorf("expiration claim missing") + } + + return time.Unix(claims.Exp, 0), nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 00000000..0fed1521 --- /dev/null +++ b/client_test.go @@ -0,0 +1,69 @@ +package client + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTokenExpiration(t *testing.T) { + // Helper to create a dummy JWT with a specific expiration time + createToken := func(expTime time.Time) string { + header := `{"alg":"HS256","typ":"JWT"}` + payload := map[string]any{ + "sub": "1234567890", + "name": "John Doe", + "exp": expTime.Unix(), + } + + payloadBytes, _ := json.Marshal(payload) + + encodedHeader := base64.RawURLEncoding.EncodeToString([]byte(header)) + encodedPayload := base64.RawURLEncoding.EncodeToString(payloadBytes) + signature := "dummy_signature" + + return fmt.Sprintf("%s.%s.%s", encodedHeader, encodedPayload, signature) + } + + fixedBaseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + t.Run("Valid token", func(t *testing.T) { + expTime := fixedBaseTime + token := createToken(expTime) + + parsedTime, err := parseTokenExpiration(token) + + require.NoError(t, err) + assert.Equal(t, expTime.Unix(), parsedTime.Unix()) + }) + + t.Run("Invalid format - not enough parts", func(t *testing.T) { + token := "invalid.token" + _, err := parseTokenExpiration(token) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid token format") + }) + + t.Run("Invalid Base64 payload", func(t *testing.T) { + token := "header.invalid_base64$.signature" + _, err := parseTokenExpiration(token) + assert.Error(t, err) + }) + + t.Run("Missing exp claim", func(t *testing.T) { + header := `{"alg":"HS256","typ":"JWT"}` + payload := `{"sub":"1234567890"}` // No exp + encodedHeader := base64.RawURLEncoding.EncodeToString([]byte(header)) + encodedPayload := base64.RawURLEncoding.EncodeToString([]byte(payload)) + token := fmt.Sprintf("%s.%s.sig", encodedHeader, encodedPayload) + + _, err := parseTokenExpiration(token) + require.Error(t, err) + assert.Contains(t, err.Error(), "expiration claim missing") + }) +} From cd5eafa8f412595d28584c6718444ac16b307dd5 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 21 Jan 2026 09:16:23 +0100 Subject: [PATCH 086/215] feat: check meshStack version --- client.go | 28 ++++++++++++++++++++++++++-- internal/http_client.go | 9 +++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/client.go b/client.go index 3d48749a..733cc827 100644 --- a/client.go +++ b/client.go @@ -10,9 +10,13 @@ import ( "strings" "time" + "github.com/hashicorp/go-version" + "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) +const MinMeshStackVersion = "2026.2.0" + type Client struct { BuildingBlock MeshBuildingBlockClient BuildingBlockV2 MeshBuildingBlockV2Client @@ -33,7 +37,7 @@ type Client struct { PlatformType MeshPlatformTypeClient } -func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret, apiToken string) Client { +func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret string, apiToken string) (Client, error) { httpClient := &internal.HttpClient{ Client: http.Client{Timeout: 5 * time.Minute}, RootUrl: rootUrl, @@ -56,6 +60,26 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret, ap } } + // Validate meshStack version compatibility + meshInfo, err := httpClient.GetMeshInfo(ctx) + if err != nil { + return Client{}, fmt.Errorf("failed to retrieve meshStack version information from /mesh/info endpoint: %w", err) + } + + minVersion, err := version.NewVersion(MinMeshStackVersion) + if err != nil { + return Client{}, fmt.Errorf("invalid minimum version format %s: %w", MinMeshStackVersion, err) + } + + actualVersion, err := version.NewVersion(meshInfo.Version) + if err != nil { + return Client{}, fmt.Errorf("invalid meshStack version format %s: %w", meshInfo.Version, err) + } + + if actualVersion.LessThan(minVersion) { + return Client{}, fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshInfo.Version, MinMeshStackVersion) + } + return Client{ newBuildingBlockClient(ctx, httpClient), newBuildingBlockV2Client(ctx, httpClient), @@ -74,7 +98,7 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret, ap newWorkspaceGroupBindingClient(ctx, httpClient), newWorkspaceUserBindingClient(ctx, httpClient), newPlatformTypeClient(ctx, httpClient), - } + }, nil } func parseTokenExpiration(token string) (time.Time, error) { diff --git a/internal/http_client.go b/internal/http_client.go index 80a3d72d..ad006d7a 100644 --- a/internal/http_client.go +++ b/internal/http_client.go @@ -110,3 +110,12 @@ func unmarshalBody[T any](body []byte, err error) (*T, error) { } return &target, nil } + +type MeshInfo struct { + Version string `json:"version"` +} + +func (c *HttpClient) GetMeshInfo(ctx context.Context) (*MeshInfo, error) { + meshInfoUrl := c.RootUrl.JoinPath("/mesh/info") + return unmarshalBody[MeshInfo](c.doRequest(ctx, "GET", meshInfoUrl)) +} From 0f1e0562384ba330d3e42bf560aaa28514b32581 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 23 Jan 2026 13:48:11 +0100 Subject: [PATCH 087/215] feat: add client/version package for parsing meshStack version limited semver format is supported --- version/version.go | 75 ++++++++++++++++++++++++++++++++ version/version_test.go | 94 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 version/version.go create mode 100644 version/version_test.go diff --git a/version/version.go b/version/version.go new file mode 100644 index 00000000..5a259585 --- /dev/null +++ b/version/version.go @@ -0,0 +1,75 @@ +package version + +import ( + "cmp" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" +) + +type Version struct { + Major, Minor, Patch int +} + +func Parse(s string) (Version, error) { + parts := strings.Split(s, ".") + if len(parts) != 3 { + return Version{}, fmt.Errorf("cannot parse '%s' as version: expected 3, got %d fields separated by '.'", s, len(parts)) + } + var errs []error + partTo := func(i int, target *int) { + parsed, err := strconv.Atoi(parts[i]) + if err == nil && parsed < 0 { + err = fmt.Errorf("negative number '%d' not allowed", parsed) + } + if err != nil { + errs = append(errs, fmt.Errorf("part i=%d: %w", i, err)) + } else { + *target = parsed + } + } + var result Version + partTo(0, &result.Major) + partTo(1, &result.Minor) + partTo(2, &result.Patch) + if len(errs) > 0 { + return Version{}, fmt.Errorf("cannot parse '%s' as version: %w", s, errors.Join(errs...)) + } + return result, nil +} + +func MustParse(s string) Version { + version, err := Parse(s) + if err != nil { + panic(err) + } + return version +} + +func (v Version) Compare(other Version) int { + if major := cmp.Compare(v.Major, other.Major); major != 0 { + return major + } else if minor := cmp.Compare(v.Minor, other.Minor); minor != 0 { + return minor + } + return cmp.Compare(v.Patch, other.Patch) +} + +func (v Version) Less(other Version) bool { + return v.Compare(other) < 0 +} + +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +func (v *Version) UnmarshalJSON(bytes []byte) (err error) { + var s string + if err = json.Unmarshal(bytes, &s); err != nil { + return + } + *v, err = Parse(s) + return +} diff --git a/version/version_test.go b/version/version_test.go new file mode 100644 index 00000000..9e06840c --- /dev/null +++ b/version/version_test.go @@ -0,0 +1,94 @@ +package version + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + assertErrorContainsAllOf := func(contains ...string) assert.ErrorAssertionFunc { + return func(t assert.TestingT, err error, msgAndArgs ...interface{}) bool { + assert.NotEmpty(t, contains) + allOk := true + for _, contain := range contains { + ok := assert.ErrorContains(t, err, contain, msgAndArgs...) + allOk = allOk && ok + } + return allOk + } + } + tests := []struct { + name string + s string + want Version + wantErr assert.ErrorAssertionFunc + }{ + {"valid 1.0.0", "1.0.0", Version{1, 0, 0}, assert.NoError}, + {"valid 1.3.2", "1.3.2", Version{1, 3, 2}, assert.NoError}, + {"not enough parts", "1.1", Version{}, assertErrorContainsAllOf("cannot parse '1.1' as version: expected 3, got 2 fields separated by '.'")}, + {"negative minor", "1.-1.0", Version{}, assertErrorContainsAllOf("cannot parse '1.-1.0' as version: part i=1: negative number '-1' not allowed")}, + {"not a number", "1.1.x", Version{}, assertErrorContainsAllOf(`cannot parse '1.1.x' as version: part i=2: strconv.Atoi: parsing "x": invalid syntax`)}, + {"number too large", "100000000000000000000.1.0", Version{}, assertErrorContainsAllOf(`cannot parse '100000000000000000000.1.0' as version: part i=0: strconv.Atoi: parsing "100000000000000000000": value out of range`)}, + {"multiple errors", "y.x.1", Version{}, assertErrorContainsAllOf(`part i=0: strconv.Atoi: parsing "y": invalid syntax`, `part i=1: strconv.Atoi: parsing "x": invalid syntax`)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotV, err := Parse(tt.s) + if !tt.wantErr(t, err, fmt.Sprintf("Parse(%v)", tt.s)) { + return + } + assert.Equalf(t, tt.want, gotV, "Parse(%v)", tt.s) + }) + } +} + +func TestMustParse(t *testing.T) { + assert.NotPanics(t, func() { + MustParse("1.0.0") + }) + assert.Panics(t, func() { + MustParse("1.x.0") + }) +} + +func TestVersion_Compare(t *testing.T) { + tests := []struct { + v, other string + want int + }{ + {"0.0.0", "0.0.0", 0}, + {"0.1.0", "0.1.0", 0}, + {"1.1.0", "0.1.0", 1}, + {"1.1.12312331222", "2.1.0", -1}, + {"1.2.0", "1.3.0", -1}, + {"1.2.1", "1.2.0", 1}, + } + for _, tt := range tests { + symbol := "==" + if tt.want < 0 { + symbol = "<" + } else if tt.want > 0 { + symbol = ">" + } + t.Run(fmt.Sprintf("%s %s %s", tt.v, symbol, tt.other), func(t *testing.T) { + v, err := Parse(tt.v) + require.NoError(t, err) + other, err := Parse(tt.other) + require.NoError(t, err) + cmp := v.Compare(other) + assert.Equal(t, tt.want, cmp) + if cmp < 0 { + assert.True(t, v.Less(other)) + } else { + assert.False(t, v.Less(other)) + } + }) + } +} + +func TestVersion_String(t *testing.T) { + assert.Equal(t, "1.2.3", Version{1, 2, 3}.String()) +} From afc3e89ba796395a62e75494d2dcf8ff9195cbe4 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 23 Jan 2026 13:52:21 +0100 Subject: [PATCH 088/215] fix: use client/version --- client.go | 24 +++++------------------- internal/http_client.go | 4 +++- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/client.go b/client.go index 733cc827..2b0e818f 100644 --- a/client.go +++ b/client.go @@ -10,12 +10,11 @@ import ( "strings" "time" - "github.com/hashicorp/go-version" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -const MinMeshStackVersion = "2026.2.0" +var MinMeshStackVersion = version.MustParse("2026.2.0") type Client struct { BuildingBlock MeshBuildingBlockClient @@ -60,23 +59,10 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret str } } - // Validate meshStack version compatibility - meshInfo, err := httpClient.GetMeshInfo(ctx) - if err != nil { + // Check meshStack version compatibility + if meshInfo, err := httpClient.GetMeshInfo(ctx); err != nil { return Client{}, fmt.Errorf("failed to retrieve meshStack version information from /mesh/info endpoint: %w", err) - } - - minVersion, err := version.NewVersion(MinMeshStackVersion) - if err != nil { - return Client{}, fmt.Errorf("invalid minimum version format %s: %w", MinMeshStackVersion, err) - } - - actualVersion, err := version.NewVersion(meshInfo.Version) - if err != nil { - return Client{}, fmt.Errorf("invalid meshStack version format %s: %w", meshInfo.Version, err) - } - - if actualVersion.LessThan(minVersion) { + } else if meshInfo.Version.Less(MinMeshStackVersion) { return Client{}, fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshInfo.Version, MinMeshStackVersion) } diff --git a/internal/http_client.go b/internal/http_client.go index ad006d7a..0fee9d54 100644 --- a/internal/http_client.go +++ b/internal/http_client.go @@ -11,6 +11,8 @@ import ( "net/url" "slices" "time" + + "github.com/meshcloud/terraform-provider-meshstack/client/version" ) var ( @@ -112,7 +114,7 @@ func unmarshalBody[T any](body []byte, err error) (*T, error) { } type MeshInfo struct { - Version string `json:"version"` + Version version.Version `json:"version"` } func (c *HttpClient) GetMeshInfo(ctx context.Context) (*MeshInfo, error) { From 8752fc26403c9baf0acc5e4282c563eb3bf37a81 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 28 Jan 2026 10:16:31 +0100 Subject: [PATCH 089/215] fix: subscription creation error cooldown nullable --- platform_config_azure.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platform_config_azure.go b/platform_config_azure.go index 2d0375d1..5fc6b814 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -49,7 +49,7 @@ type AzureEnterpriseEnrollmentConfig struct { EnrollmentAccountId string `json:"enrollmentAccountId" tfsdk:"enrollment_account_id"` SubscriptionOfferType string `json:"subscriptionOfferType" tfsdk:"subscription_offer_type"` UseLegacySubscriptionEnrollment bool `json:"useLegacySubscriptionEnrollment" tfsdk:"use_legacy_subscription_enrollment"` - SubscriptionCreationErrorCooldownSec int64 `json:"subscriptionCreationErrorCooldownSec" tfsdk:"subscription_creation_error_cooldown_sec"` + SubscriptionCreationErrorCooldownSec *int64 `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` } type AzureCustomerAgreementConfig struct { @@ -57,7 +57,7 @@ type AzureCustomerAgreementConfig struct { DestinationEntraId string `json:"destinationEntraId" tfsdk:"destination_entra_id"` SourceEntraTenant string `json:"sourceEntraTenant" tfsdk:"source_entra_tenant"` BillingScope string `json:"billingScope" tfsdk:"billing_scope"` - SubscriptionCreationErrorCooldownSec int64 `json:"subscriptionCreationErrorCooldownSec" tfsdk:"subscription_creation_error_cooldown_sec"` + SubscriptionCreationErrorCooldownSec *int64 `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` } type AzurePreProvisionedSubscriptionConfig struct { From 9f43b5e52f5ab2ae2203c5a7cb83ad461d7002af Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Mon, 26 Jan 2026 17:36:00 +0100 Subject: [PATCH 090/215] feat: add `owned_by_workspace` for platform_type CU-86c7m8w3m --- platform_type.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/platform_type.go b/platform_type.go index 366cb196..ec2c3176 100644 --- a/platform_type.go +++ b/platform_type.go @@ -23,9 +23,10 @@ type MeshPlatformTypeLifecycle struct { } type MeshPlatformTypeMetadata struct { - Name string `json:"name" tfsdk:"name"` - CreatedOn *string `json:"createdOn" tfsdk:"created_on"` - Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn *string `json:"createdOn" tfsdk:"created_on"` + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` } type MeshPlatformTypeSpec struct { @@ -43,7 +44,8 @@ type MeshPlatformTypeCreate struct { } type MeshPlatformTypeCreateMetadata struct { - Name string `json:"name" tfsdk:"name"` + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } type MeshPlatformTypeClient struct { From 2595875957b1cd426552398914fc8d27ad53a41d Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 4 Feb 2026 13:13:58 +0100 Subject: [PATCH 091/215] feat: custom platforms in meshstack_platform --- platform.go | 1 + platform_config_custom.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 platform_config_custom.go diff --git a/platform.go b/platform.go index bd7b7fca..aa929916 100644 --- a/platform.go +++ b/platform.go @@ -62,6 +62,7 @@ type PlatformAvailability struct { type PlatformConfig struct { Type string `json:"type" tfsdk:"type"` + Custom *CustomPlatformConfig `json:"custom,omitempty" tfsdk:"custom"` Aws *AwsPlatformConfig `json:"aws,omitempty" tfsdk:"aws"` Aks *AksPlatformConfig `json:"aks,omitempty" tfsdk:"aks"` Azure *AzurePlatformConfig `json:"azure,omitempty" tfsdk:"azure"` diff --git a/platform_config_custom.go b/platform_config_custom.go new file mode 100644 index 00000000..293cc39c --- /dev/null +++ b/platform_config_custom.go @@ -0,0 +1,15 @@ +package client + +type CustomPlatformConfig struct { + PlatformTypeRef PlatformTypeRef `json:"platformTypeRef" tfsdk:"platform_type_ref"` + Metering *CustomMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` +} + +type PlatformTypeRef struct { + Name string `json:"name" tfsdk:"name"` + Kind string `json:"kind" tfsdk:"kind"` +} + +type CustomMeteringConfig struct { + Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` +} From ae81c68690d0b2e0663f2a4e5d28e1f37d383efb Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Fri, 6 Feb 2026 15:52:34 +0100 Subject: [PATCH 092/215] feat: service instance client --- client.go | 2 ++ service_instance.go | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 service_instance.go diff --git a/client.go b/client.go index 2b0e818f..f55c5982 100644 --- a/client.go +++ b/client.go @@ -27,6 +27,7 @@ type Client struct { Project MeshProjectClient ProjectGroupBinding MeshProjectGroupBindingClient ProjectUserBinding MeshProjectUserBindingClient + ServiceInstance MeshServiceInstanceClient TagDefinition MeshTagDefinitionClient Tenant MeshTenantClient TenantV4 MeshTenantV4Client @@ -77,6 +78,7 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret str newProjectClient(ctx, httpClient), newProjectGroupBindingClient(ctx, httpClient), newProjectUserBindingClient(ctx, httpClient), + newServiceInstanceClient(ctx, httpClient), newTagDefinitionClient(ctx, httpClient), newTenantClient(ctx, httpClient), newTenantV4Client(ctx, httpClient), diff --git a/service_instance.go b/service_instance.go new file mode 100644 index 00000000..58b6f2fb --- /dev/null +++ b/service_instance.go @@ -0,0 +1,70 @@ +package client + +import ( + "context" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + +type MeshServiceInstance struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshServiceInstanceMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshServiceInstanceSpec `json:"spec" tfsdk:"spec"` +} + +type MeshServiceInstanceMetadata struct { + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + MarketplaceIdentifier string `json:"marketplaceIdentifier" tfsdk:"marketplace_identifier"` + InstanceId string `json:"instanceId" tfsdk:"instance_id"` +} + +type MeshServiceInstanceSpec struct { + Creator string `json:"creator" tfsdk:"creator"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + PlanId string `json:"planId" tfsdk:"plan_id"` + ServiceId string `json:"serviceId" tfsdk:"service_id"` +} + +type MeshServiceInstanceClient struct { + meshObject internal.MeshObjectClient[MeshServiceInstance] +} + +type MeshServiceInstanceFilter struct { + WorkspaceIdentifier *string + ProjectIdentifier *string + MarketplaceIdentifier *string + ServiceIdentifier *string + PlanIdentifier *string +} + +func newServiceInstanceClient(ctx context.Context, httpClient *internal.HttpClient) MeshServiceInstanceClient { + return MeshServiceInstanceClient{internal.NewMeshObjectClient[MeshServiceInstance](ctx, httpClient, "v2")} +} + +func (c MeshServiceInstanceClient) Read(ctx context.Context, instanceId string) (*MeshServiceInstance, error) { + return c.meshObject.Get(ctx, instanceId) +} + +func (c MeshServiceInstanceClient) List(ctx context.Context, filter *MeshServiceInstanceFilter) ([]MeshServiceInstance, error) { + var options []internal.RequestOption + if filter != nil { + if filter.WorkspaceIdentifier != nil { + options = append(options, internal.WithUrlQuery("workspaceIdentifier", *filter.WorkspaceIdentifier)) + } + if filter.ProjectIdentifier != nil { + options = append(options, internal.WithUrlQuery("projectIdentifier", *filter.ProjectIdentifier)) + } + if filter.MarketplaceIdentifier != nil { + options = append(options, internal.WithUrlQuery("marketplaceIdentifier", *filter.MarketplaceIdentifier)) + } + if filter.ServiceIdentifier != nil { + options = append(options, internal.WithUrlQuery("serviceIdentifier", *filter.ServiceIdentifier)) + } + if filter.PlanIdentifier != nil { + options = append(options, internal.WithUrlQuery("planIdentifier", *filter.PlanIdentifier)) + } + } + return c.meshObject.List(ctx, options...) +} From 745f0b9b0bd108ffdf7524b4b1b9fc2e7c3b1a39 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Thu, 12 Feb 2026 15:42:53 +0100 Subject: [PATCH 093/215] feat: add support for `custom` landing zone CU-86c68mqgp --- landingzone.go | 1 + platform_properties_custom.go | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 platform_properties_custom.go diff --git a/landingzone.go b/landingzone.go index 6588d19e..0deb06d9 100644 --- a/landingzone.go +++ b/landingzone.go @@ -49,6 +49,7 @@ type MeshLandingZonePlatformProperties struct { Aks *AksPlatformProperties `json:"aks" tfsdk:"aks"` Azure *AzurePlatformProperties `json:"azure" tfsdk:"azure"` AzureRg *AzureRgPlatformProperties `json:"azurerg" tfsdk:"azurerg"` + Custom *CustomPlatformProperties `json:"custom" tfsdk:"custom"` Gcp *GcpPlatformProperties `json:"gcp" tfsdk:"gcp"` Kubernetes *KubernetesPlatformProperties `json:"kubernetes" tfsdk:"kubernetes"` OpenShift *OpenShiftPlatformProperties `json:"openshift" tfsdk:"openshift"` diff --git a/platform_properties_custom.go b/platform_properties_custom.go new file mode 100644 index 00000000..0d721af1 --- /dev/null +++ b/platform_properties_custom.go @@ -0,0 +1,5 @@ +package client + +type CustomPlatformProperties struct { + // Intentionally left empty, as custom platforms do not have any properties. +} From 03e67dcc80732a979734303a330573925d70731b Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 15 Jan 2026 14:22:30 +0100 Subject: [PATCH 094/215] refactor: rename name to better term 'kind' in internal.MeshObjectClient --- internal/mesh_object_client.go | 42 +++++++++++++++-------------- internal/mesh_object_client_test.go | 22 +++++++-------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index 96f4312c..7957654d 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -21,30 +21,32 @@ import ( // which are embedded in HttpClient for convenient construction with NewMeshObjectClient. type MeshObjectClient[M any] struct { *HttpClient - Name string + Kind string ApiVersion string ApiUrl *url.URL } // NewMeshObjectClient creates a new [MeshObjectClient] for a specific meshObject type with automatic URL path inference. -// The meshObject name is inferred from type M, and the API URL is constructed from explicitApiPaths or the pluralized type name. -func NewMeshObjectClient[M any](ctx context.Context, httpClient *HttpClient, apiVersion string, explicitApiPaths ...string) MeshObjectClient[M] { - name, typeName := inferMeshObjectName[M]() - - if len(explicitApiPaths) == 0 { - explicitApiPaths = []string{strings.ToLower(pluralizeName(name))} +// The meshObject kind is inferred from type M. T +// The API URL is constructed from explicitApiPathElems if provided, +// otherwise the pluralized and lowercased kind is used as a single element. +func NewMeshObjectClient[M any](ctx context.Context, httpClient *HttpClient, apiVersion string, explicitApiPathElems ...string) MeshObjectClient[M] { + kind, typeName := inferMeshObjectKindFromType[M]() + + if len(explicitApiPathElems) == 0 { + explicitApiPathElems = []string{strings.ToLower(pluralizeKind(kind))} } - explicitApiPaths = slices.Insert(explicitApiPaths, 0, "/api/meshobjects") - apiUrl := httpClient.RootUrl.JoinPath(explicitApiPaths...) - Log.Info(ctx, fmt.Sprintf("initialized %s", typeName), "url", apiUrl.String(), "name", name, "version", apiVersion) - return MeshObjectClient[M]{httpClient, name, apiVersion, apiUrl} + explicitApiPathElems = slices.Insert(explicitApiPathElems, 0, "/api/meshobjects") + apiUrl := httpClient.RootUrl.JoinPath(explicitApiPathElems...) + Log.Info(ctx, fmt.Sprintf("initialized %s client", typeName), "url", apiUrl.String(), "kind", kind, "version", apiVersion) + return MeshObjectClient[M]{httpClient, kind, apiVersion, apiUrl} } -func inferMeshObjectName[M any]() (name, typeName string) { +func inferMeshObjectKindFromType[M any]() (lowercase, typeName string) { var zero M typeName = reflect.TypeOf(zero).Name() - name = lowercaseFirst(typeName) - return regexp.MustCompile(`V\d+$`).ReplaceAllString(name, ""), typeName + lowercase = lowercaseFirst(typeName) + return regexp.MustCompile(`V\d+$`).ReplaceAllString(lowercase, ""), typeName } func lowercaseFirst(s string) string { @@ -56,16 +58,16 @@ func lowercaseFirst(s string) string { return string(runes) } -func pluralizeName(name string) string { - if strings.HasSuffix(name, "y") { +func pluralizeKind(kind string) string { + if strings.HasSuffix(kind, "y") { // this is ok, as we don't have meshObjects ending in 'y' yet, so take this shortcut - panic(fmt.Sprintf("Correctly pluralizing '%s' is not supported yet", name)) + panic(fmt.Sprintf("Correctly pluralizing meshObject kind '%s' is not supported yet", kind)) } - return fmt.Sprintf("%ss", name) + return fmt.Sprintf("%ss", kind) } func (c MeshObjectClient[M]) meshObjectMimeType() string { - return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", c.Name, c.ApiVersion) + return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", c.Kind, c.ApiVersion) } // Get retrieves a meshObject by ID. Returns nil if not found. @@ -97,7 +99,7 @@ func (c MeshObjectClient[M]) Delete(ctx context.Context, id string) (err error) // Accepts optional [RequestOption] parameters for filtering and querying. func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) ([]M, error) { var result []M - embeddedKey := pluralizeName(c.Name) + embeddedKey := pluralizeKind(c.Kind) pageNumber := 0 for { diff --git a/internal/mesh_object_client_test.go b/internal/mesh_object_client_test.go index 4c9219c3..fb8f068d 100644 --- a/internal/mesh_object_client_test.go +++ b/internal/mesh_object_client_test.go @@ -11,36 +11,36 @@ type MeshBuildingBlockV2 struct{} type MeshTenantV4 struct{} type MeshWorkspace struct{} -func TestInferMeshObjectName(t *testing.T) { +func Test_inferMeshObjectKindFromType(t *testing.T) { tests := []struct { - name string + kind string testFunc func() (string, string) expected string }{ { - name: "MeshBuildingBlock", - testFunc: inferMeshObjectName[MeshBuildingBlock], + kind: "MeshBuildingBlock", + testFunc: inferMeshObjectKindFromType[MeshBuildingBlock], expected: "meshBuildingBlock", }, { - name: "MeshBuildingBlockV2", - testFunc: inferMeshObjectName[MeshBuildingBlockV2], + kind: "MeshBuildingBlockV2", + testFunc: inferMeshObjectKindFromType[MeshBuildingBlockV2], expected: "meshBuildingBlock", }, { - name: "MeshWorkspace", - testFunc: inferMeshObjectName[MeshWorkspace], + kind: "MeshWorkspace", + testFunc: inferMeshObjectKindFromType[MeshWorkspace], expected: "meshWorkspace", }, { - name: "MeshTenantV4", - testFunc: inferMeshObjectName[MeshTenantV4], + kind: "MeshTenantV4", + testFunc: inferMeshObjectKindFromType[MeshTenantV4], expected: "meshTenant", }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.kind, func(t *testing.T) { actual, _ := tt.testFunc() assert.Equal(t, tt.expected, actual) }) From cb8d5963e275c6d6b056a8a5c84ea52322cb6bf5 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 21 Jan 2026 14:34:51 +0100 Subject: [PATCH 095/215] refactor: move Secret DTO from platform to commonly shared client/types package (as Secret) --- platform.go | 5 ----- platform_config_aks.go | 4 +++- platform_config_aws.go | 8 +++++--- platform_config_azure.go | 6 ++++-- platform_config_gcp.go | 4 +++- platform_config_kubernetes.go | 4 +++- types/clienttypes.go | 12 ++++++++++++ 7 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 types/clienttypes.go diff --git a/platform.go b/platform.go index aa929916..a0b0e90a 100644 --- a/platform.go +++ b/platform.go @@ -34,11 +34,6 @@ type MeshPlatformSpec struct { QuotaDefinitions []QuotaDefinition `json:"quotaDefinitions" tfsdk:"quota_definitions"` } -type SecretEmbedded struct { - Plaintext *string `json:"plaintext,omitempty" tfsdk:"plaintext"` - // TODO: add Hash field -} - type QuotaDefinition struct { QuotaKey string `json:"quotaKey" tfsdk:"quota_key"` MinValue int `json:"minValue" tfsdk:"min_value"` diff --git a/platform_config_aks.go b/platform_config_aks.go index 369650d2..8521a308 100644 --- a/platform_config_aks.go +++ b/platform_config_aks.go @@ -1,5 +1,7 @@ package client +import "github.com/meshcloud/terraform-provider-meshstack/client/types" + type AksPlatformConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` @@ -8,7 +10,7 @@ type AksPlatformConfig struct { } type AksReplicationConfig struct { - AccessToken SecretEmbedded `json:"accessToken" tfsdk:"access_token"` + AccessToken types.Secret `json:"accessToken" tfsdk:"access_token"` NamespaceNamePattern string `json:"namespaceNamePattern" tfsdk:"namespace_name_pattern"` GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` ServicePrincipal AksServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` diff --git a/platform_config_aws.go b/platform_config_aws.go index 01805e6f..7bfe5966 100644 --- a/platform_config_aws.go +++ b/platform_config_aws.go @@ -1,5 +1,7 @@ package client +import "github.com/meshcloud/terraform-provider-meshstack/client/types" + type AwsPlatformConfig struct { Region string `json:"region,omitempty" tfsdk:"region"` Replication *AwsReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` @@ -36,8 +38,8 @@ type AwsAuth struct { } type AwsServiceUserCredential struct { - AccessKey string `json:"accessKey" tfsdk:"access_key"` - SecretKey SecretEmbedded `json:"secretKey" tfsdk:"secret_key"` + AccessKey string `json:"accessKey" tfsdk:"access_key"` + SecretKey types.Secret `json:"secretKey" tfsdk:"secret_key"` } type AwsWorkloadIdentityCredential struct { @@ -48,7 +50,7 @@ type AwsSsoConfig struct { ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` Arn string `json:"arn" tfsdk:"arn"` GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` - SsoAccessToken SecretEmbedded `json:"ssoAccessToken" tfsdk:"sso_access_token"` + SsoAccessToken types.Secret `json:"ssoAccessToken" tfsdk:"sso_access_token"` AwsRoleMappings []AwsSsoRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` } diff --git a/platform_config_azure.go b/platform_config_azure.go index 5fc6b814..c805a2ed 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -1,5 +1,7 @@ package client +import "github.com/meshcloud/terraform-provider-meshstack/client/types" + type AzurePlatformConfig struct { EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` Replication *AzureReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` @@ -29,8 +31,8 @@ type AzureServicePrincipalConfig struct { } type AzureAuthConfig struct { - Type string `json:"type" tfsdk:"type"` - Credential *SecretEmbedded `json:"credential,omitempty" tfsdk:"credential"` + Type string `json:"type" tfsdk:"type"` + Credential *types.Secret `json:"credential,omitempty" tfsdk:"credential"` } type AzureGraphApiCredentials struct { diff --git a/platform_config_gcp.go b/platform_config_gcp.go index 83cc0377..8febcb6e 100644 --- a/platform_config_gcp.go +++ b/platform_config_gcp.go @@ -1,5 +1,7 @@ package client +import "github.com/meshcloud/terraform-provider-meshstack/client/types" + type GcpPlatformConfig struct { Replication *GcpReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` Metering *GcpMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` @@ -23,7 +25,7 @@ type GcpReplicationConfig struct { type GcpServiceAccountConfig struct { Type string `json:"type" tfsdk:"type"` - Credential *SecretEmbedded `json:"credential,omitempty" tfsdk:"credential"` + Credential *types.Secret `json:"credential,omitempty" tfsdk:"credential"` WorkloadIdentity *GcpServiceAccountWorkloadIdentityConfig `json:"workloadIdentity,omitempty" tfsdk:"workload_identity"` } diff --git a/platform_config_kubernetes.go b/platform_config_kubernetes.go index 893ba2d8..e4b34cf5 100644 --- a/platform_config_kubernetes.go +++ b/platform_config_kubernetes.go @@ -1,5 +1,7 @@ package client +import "github.com/meshcloud/terraform-provider-meshstack/client/types" + type KubernetesPlatformConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` @@ -13,7 +15,7 @@ type KubernetesReplicationConfig struct { } type KubernetesClientConfig struct { - AccessToken SecretEmbedded `json:"accessToken" tfsdk:"access_token"` + AccessToken types.Secret `json:"accessToken" tfsdk:"access_token"` } type KubernetesMeteringConfig struct { diff --git a/types/clienttypes.go b/types/clienttypes.go new file mode 100644 index 00000000..08d597d6 --- /dev/null +++ b/types/clienttypes.go @@ -0,0 +1,12 @@ +package types + +type ( + String = string + Number = int64 + Any = any + + Secret struct { + Plaintext *string `json:"plaintext,omitempty" tfsdk:"plaintext"` + Hash *string `json:"hash,omitempty" tfsdk:"-"` + } +) From d034bcb3af66e103485ac27f68ac8f416c42f3b5 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 21 Jan 2026 14:35:18 +0100 Subject: [PATCH 096/215] feat: add ptr.To helper in client/types/ptr --- types/ptr/pointer.go | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 types/ptr/pointer.go diff --git a/types/ptr/pointer.go b/types/ptr/pointer.go new file mode 100644 index 00000000..6c3ee9bd --- /dev/null +++ b/types/ptr/pointer.go @@ -0,0 +1,5 @@ +package ptr + +func To[T any](v T) *T { + return &v +} From 758df6c86b492d9914929a0ced02451386beb11a Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 30 Jan 2026 11:10:23 +0100 Subject: [PATCH 097/215] feat: support defining Go enum strings --- types/enum/enum.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 types/enum/enum.go diff --git a/types/enum/enum.go b/types/enum/enum.go new file mode 100644 index 00000000..4934aa72 --- /dev/null +++ b/types/enum/enum.go @@ -0,0 +1,53 @@ +package enum + +import ( + "fmt" + "strings" + + "github.com/meshcloud/terraform-provider-meshstack/client/types/ptr" +) + +func Of[T ~string](entries ...Entry[T]) Enum[T] { + return entries +} + +type Enum[T ~string] []Entry[T] + +func (e *Enum[T]) Entry(v string) (ee Entry[T]) { + ee = Entry[T](v) + *e = append(*e, ee) + return +} + +func (e Enum[T]) to(mapper func(entry Entry[T]) string) (result []string) { + for _, ee := range e { + result = append(result, mapper(ee)) + } + return +} + +func (e Enum[T]) Strings() []string { + return e.to(Entry[T].String) +} + +func (e Enum[T]) Markdown() string { + return strings.Join(e.to(Entry[T].Markdown), ", ") +} + +type Entry[T ~string] string + +func (ee Entry[T]) Ptr() *T { + return ptr.To(ee.Unwrap()) +} + +func (ee Entry[T]) Unwrap() T { + return T(ee) +} + +func (ee Entry[T]) String() string { + return string(ee) +} + +func (ee Entry[T]) Markdown() string { + return fmt.Sprintf("`%s`", ee) +} From 1fd17bc2c176a0efafa67cfdc314a696dfcca68d Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 30 Jan 2026 11:08:09 +0100 Subject: [PATCH 098/215] feat: add Variant[X, Y] in client/types/variant --- types/variant/variant.go | 85 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 types/variant/variant.go diff --git a/types/variant/variant.go b/types/variant/variant.go new file mode 100644 index 00000000..67951815 --- /dev/null +++ b/types/variant/variant.go @@ -0,0 +1,85 @@ +package variant + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" +) + +// A Variant represents a single JSON map entry having two different Go type representations X and Y. +// After JSON unmarshalling you can check with HasX, HasY which field has been detected, while X is preferred. +// An example usage is a Client DTO response which can either be struct representing a secret hash, +// or a simple string response if that's a non-sensitive value. +type Variant[X, Y any] struct { + X X + Y Y +} + +var ( + _ json.Unmarshaler = (*Variant[int, string])(nil) + _ json.Marshaler = Variant[int, string]{} +) + +func (v Variant[X, Y]) MarshalJSON() ([]byte, error) { + if v.HasX() { + return json.Marshal(v.X) + } else if v.HasY() { + return json.Marshal(v.Y) + } else { + return json.Marshal(nil) + } +} + +func (v Variant[X, Y]) HasX() bool { + x := reflect.ValueOf(v.X) + return x.IsValid() && !x.IsZero() +} + +func (v Variant[X, Y]) HasY() bool { + y := reflect.ValueOf(v.Y) + return y.IsValid() && !y.IsZero() +} + +func (v Variant[X, Y]) WithX(action func(x *X)) { + if v.HasX() { + action(&v.X) + } else { + action(nil) + } +} + +func (v Variant[X, Y]) WithY(action func(y *Y)) { + if v.HasY() { + action(&v.Y) + } else { + action(nil) + } +} + +func (v *Variant[X, Y]) UnmarshalJSON(bytes []byte) error { + errX := json.Unmarshal(bytes, &v.X) + errY := json.Unmarshal(bytes, &v.Y) + switch { + case v.HasX() && v.HasY(): + // Explicitly prefer X over Y and set Y to zero even if unmarshalling has also worked, + // this supports having Y with catch-all type 'any' + var zeroY Y + v.Y = zeroY + return errX + case v.HasX(): + return errX + case v.HasY(): + return errY + default: + var nothing any + if err := json.Unmarshal(bytes, ¬hing); err != nil { + return fmt.Errorf("cannot unmarshal to any: %w", err) + } + if nothing == nil { + // support optional unmarshalling aka neither X nor Y is set + return nil + } + return errors.Join(fmt.Errorf("variant[%T, %T]: cannot unmarshal '%s' to any field", v.X, v.Y, string(bytes)), errX, errY) + } +} From 90c3f91e6eea5f06f46f6a0d212c14cbca7c05e3 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 4 Feb 2026 11:07:00 +0100 Subject: [PATCH 099/215] feat: add mock client and resource test for meshstack_tag_definition --- tag_definition.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tag_definition.go b/tag_definition.go index 5b78ca94..dfc63008 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -69,30 +69,38 @@ type TagValueMultiSelect struct { DefaultValue *[]string `json:"defaultValue,omitempty" tfsdk:"default_value"` } -type MeshTagDefinitionClient struct { +type MeshTagDefinitionClient interface { + List(ctx context.Context) ([]MeshTagDefinition, error) + Read(ctx context.Context, name string) (*MeshTagDefinition, error) + Create(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) + Update(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) + Delete(ctx context.Context, name string) error +} + +type meshTagDefinitionClient struct { meshObject internal.MeshObjectClient[MeshTagDefinition] } func newTagDefinitionClient(ctx context.Context, httpClient *internal.HttpClient) MeshTagDefinitionClient { - return MeshTagDefinitionClient{internal.NewMeshObjectClient[MeshTagDefinition](ctx, httpClient, "v1")} + return meshTagDefinitionClient{internal.NewMeshObjectClient[MeshTagDefinition](ctx, httpClient, "v1")} } -func (c MeshTagDefinitionClient) List(ctx context.Context) ([]MeshTagDefinition, error) { +func (c meshTagDefinitionClient) List(ctx context.Context) ([]MeshTagDefinition, error) { return c.meshObject.List(ctx) } -func (c MeshTagDefinitionClient) Read(ctx context.Context, name string) (*MeshTagDefinition, error) { +func (c meshTagDefinitionClient) Read(ctx context.Context, name string) (*MeshTagDefinition, error) { return c.meshObject.Get(ctx, name) } -func (c MeshTagDefinitionClient) Create(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { +func (c meshTagDefinitionClient) Create(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { return c.meshObject.Post(ctx, tagDefinition) } -func (c MeshTagDefinitionClient) Update(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { +func (c meshTagDefinitionClient) Update(ctx context.Context, tagDefinition *MeshTagDefinition) (*MeshTagDefinition, error) { return c.meshObject.Put(ctx, tagDefinition.Metadata.Name, tagDefinition) } -func (c MeshTagDefinitionClient) Delete(ctx context.Context, name string) error { +func (c meshTagDefinitionClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } From e61f057ee879fb9c31fce2bc8c0455ef2bbe693d Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 4 Feb 2026 11:35:51 +0100 Subject: [PATCH 100/215] feat: add mock client and resource/datasource test for meshstack_platform, remove created_on, deleted_on --- platform.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/platform.go b/platform.go index a0b0e90a..34a7d899 100644 --- a/platform.go +++ b/platform.go @@ -14,11 +14,9 @@ type MeshPlatform struct { } type MeshPlatformMetadata struct { - Name string `json:"name" tfsdk:"name"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - Uuid string `json:"uuid" tfsdk:"uuid"` - CreatedOn string `json:"createdOn" tfsdk:"created_on"` - DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Uuid string `json:"uuid" tfsdk:"uuid"` } type MeshPlatformSpec struct { @@ -105,26 +103,33 @@ type TagMapper struct { ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` } -type MeshPlatformClient struct { +type MeshPlatformClient interface { + Read(ctx context.Context, uuid string) (*MeshPlatform, error) + Create(ctx context.Context, platform *MeshPlatformCreate) (*MeshPlatform, error) + Update(ctx context.Context, uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) + Delete(ctx context.Context, uuid string) error +} + +type meshPlatformClient struct { meshObject internal.MeshObjectClient[MeshPlatform] } func newPlatformClient(ctx context.Context, httpClient *internal.HttpClient) MeshPlatformClient { - return MeshPlatformClient{internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2-preview")} + return meshPlatformClient{internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2-preview")} } -func (c MeshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatform, error) { +func (c meshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatform, error) { return c.meshObject.Get(ctx, uuid) } -func (c MeshPlatformClient) Create(ctx context.Context, platform *MeshPlatformCreate) (*MeshPlatform, error) { +func (c meshPlatformClient) Create(ctx context.Context, platform *MeshPlatformCreate) (*MeshPlatform, error) { return c.meshObject.Post(ctx, platform) } -func (c MeshPlatformClient) Update(ctx context.Context, uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { +func (c meshPlatformClient) Update(ctx context.Context, uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { return c.meshObject.Put(ctx, uuid, platform) } -func (c MeshPlatformClient) Delete(ctx context.Context, uuid string) error { +func (c meshPlatformClient) Delete(ctx context.Context, uuid string) error { return c.meshObject.Delete(ctx, uuid) } From bac587fc688cd7ea9e6718716f96b5c183e26970 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 4 Feb 2026 11:48:03 +0100 Subject: [PATCH 101/215] feat: add mock client and extend resource test for meshstack_location --- location.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/location.go b/location.go index 3656744b..13bbee15 100644 --- a/location.go +++ b/location.go @@ -37,26 +37,33 @@ type MeshLocationCreateMetadata struct { Name string `json:"name" tfsdk:"name"` } -type MeshLocationClient struct { +type MeshLocationClient interface { + Read(ctx context.Context, name string) (*MeshLocation, error) + Create(ctx context.Context, location *MeshLocationCreate) (*MeshLocation, error) + Update(ctx context.Context, name string, location *MeshLocationCreate) (*MeshLocation, error) + Delete(ctx context.Context, name string) error +} + +type meshLocationClient struct { meshObject internal.MeshObjectClient[MeshLocation] } func newLocationClient(ctx context.Context, httpClient *internal.HttpClient) MeshLocationClient { - return MeshLocationClient{internal.NewMeshObjectClient[MeshLocation](ctx, httpClient, "v1-preview")} + return meshLocationClient{internal.NewMeshObjectClient[MeshLocation](ctx, httpClient, "v1-preview")} } -func (c MeshLocationClient) Read(ctx context.Context, name string) (*MeshLocation, error) { +func (c meshLocationClient) Read(ctx context.Context, name string) (*MeshLocation, error) { return c.meshObject.Get(ctx, name) } -func (c MeshLocationClient) Create(ctx context.Context, location *MeshLocationCreate) (*MeshLocation, error) { +func (c meshLocationClient) Create(ctx context.Context, location *MeshLocationCreate) (*MeshLocation, error) { return c.meshObject.Post(ctx, location) } -func (c MeshLocationClient) Update(ctx context.Context, name string, location *MeshLocationCreate) (*MeshLocation, error) { +func (c meshLocationClient) Update(ctx context.Context, name string, location *MeshLocationCreate) (*MeshLocation, error) { return c.meshObject.Put(ctx, name, location) } -func (c MeshLocationClient) Delete(ctx context.Context, name string) error { +func (c meshLocationClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } From ad78a3ee575359576184926df24b0094cdfbd8d8 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 4 Feb 2026 21:47:25 +0100 Subject: [PATCH 102/215] feat: add mock client and resource/datasource test for meshstack_platform_type, fix missing owned_by_workspace in example --- platform_type.go | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/platform_type.go b/platform_type.go index ec2c3176..3e93e091 100644 --- a/platform_type.go +++ b/platform_type.go @@ -25,7 +25,6 @@ type MeshPlatformTypeLifecycle struct { type MeshPlatformTypeMetadata struct { Name string `json:"name" tfsdk:"name"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - CreatedOn *string `json:"createdOn" tfsdk:"created_on"` Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` } @@ -48,31 +47,39 @@ type MeshPlatformTypeCreateMetadata struct { OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } -type MeshPlatformTypeClient struct { +type MeshPlatformTypeClient interface { + Create(ctx context.Context, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) + Read(ctx context.Context, identifier string) (*MeshPlatformType, error) + Update(ctx context.Context, name string, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) + Delete(ctx context.Context, name string) error + List(ctx context.Context, category *string, lifecycleStatus *string) ([]MeshPlatformType, error) +} + +type meshPlatformTypeClient struct { meshObject internal.MeshObjectClient[MeshPlatformType] } func newPlatformTypeClient(ctx context.Context, httpClient *internal.HttpClient) MeshPlatformTypeClient { - return MeshPlatformTypeClient{internal.NewMeshObjectClient[MeshPlatformType](ctx, httpClient, "v1-preview")} + return meshPlatformTypeClient{internal.NewMeshObjectClient[MeshPlatformType](ctx, httpClient, "v1-preview")} } -func (c MeshPlatformTypeClient) Read(ctx context.Context, identifier string) (*MeshPlatformType, error) { - return c.meshObject.Get(ctx, identifier) +func (c meshPlatformTypeClient) Create(ctx context.Context, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) { + return c.meshObject.Post(ctx, platformType) } -func (c MeshPlatformTypeClient) Create(ctx context.Context, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) { - return c.meshObject.Post(ctx, platformType) +func (c meshPlatformTypeClient) Read(ctx context.Context, identifier string) (*MeshPlatformType, error) { + return c.meshObject.Get(ctx, identifier) } -func (c MeshPlatformTypeClient) Update(ctx context.Context, name string, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) { +func (c meshPlatformTypeClient) Update(ctx context.Context, name string, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) { return c.meshObject.Put(ctx, name, platformType) } -func (c MeshPlatformTypeClient) Delete(ctx context.Context, name string) error { +func (c meshPlatformTypeClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } -func (c MeshPlatformTypeClient) List(ctx context.Context, category *string, lifecycleStatus *string) ([]MeshPlatformType, error) { +func (c meshPlatformTypeClient) List(ctx context.Context, category *string, lifecycleStatus *string) ([]MeshPlatformType, error) { var options []internal.RequestOption if category != nil { options = append(options, internal.WithUrlQuery("category", *category)) From 4f99ecfdcd348cc6f9f6c7c765bd3c67c6c66f10 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 5 Feb 2026 15:12:24 +0100 Subject: [PATCH 103/215] feat: implement meshstack_integration resource with mock client --- buildingblock_runner.go | 6 +++ integration.go | 90 ++++++++++++++++++++++++++++++++++ integration_config.go | 80 ++++++++++++++++++++++++++++++ integrations.go | 106 ---------------------------------------- 4 files changed, 176 insertions(+), 106 deletions(-) create mode 100644 buildingblock_runner.go create mode 100644 integration.go create mode 100644 integration_config.go delete mode 100644 integrations.go diff --git a/buildingblock_runner.go b/buildingblock_runner.go new file mode 100644 index 00000000..e73e102c --- /dev/null +++ b/buildingblock_runner.go @@ -0,0 +1,6 @@ +package client + +type BuildingBlockRunnerRef struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + Kind string `json:"kind" tfsdk:"kind"` +} diff --git a/integration.go b/integration.go new file mode 100644 index 00000000..8c6698a5 --- /dev/null +++ b/integration.go @@ -0,0 +1,90 @@ +package client + +import ( + "context" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/terraform-provider-meshstack/client/types" +) + +type MeshIntegration struct { + ApiVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata MeshIntegrationMetadata `json:"metadata"` + Spec MeshIntegrationSpec `json:"spec"` + Status *MeshIntegrationStatus `json:"status"` +} + +type MeshIntegrationMetadataAdapter[String any] struct { + Uuid String `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshIntegrationMetadata = MeshIntegrationMetadataAdapter[*types.String] + +type MeshIntegrationSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Config MeshIntegrationConfig `json:"config" tfsdk:"config"` +} + +type MeshIntegrationStatus struct { + IsBuiltIn bool `json:"isBuiltIn" tfsdk:"is_built_in"` + WorkloadIdentityFederation *MeshWorkloadIdentityFederation `json:"workloadIdentityFederation" tfsdk:"workload_identity_federation"` +} + +type MeshWorkloadIdentityFederation struct { + Issuer string `json:"issuer" tfsdk:"issuer"` + Subject string `json:"subject" tfsdk:"subject"` + Gcp *MeshWifProvider `json:"gcp" tfsdk:"gcp"` + Aws *MeshAwsWifProvider `json:"aws" tfsdk:"aws"` + Azure *MeshWifProvider `json:"azure" tfsdk:"azure"` +} + +type MeshWifProvider struct { + Audience string `json:"audience" tfsdk:"audience"` +} + +type MeshAwsWifProvider struct { + Audience string `json:"audience" tfsdk:"audience"` + Thumbprint string `json:"thumbprint" tfsdk:"thumbprint"` +} + +type MeshIntegrationClient interface { + Create(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) + Read(ctx context.Context, uuid string) (*MeshIntegration, error) + Update(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) + Delete(ctx context.Context, uuid string) error + List(ctx context.Context) ([]MeshIntegration, error) +} + +type meshIntegrationClientImpl struct { + meshObject internal.MeshObjectClient[MeshIntegration] +} + +func newIntegrationClient(ctx context.Context, httpClient *internal.HttpClient) MeshIntegrationClient { + return &meshIntegrationClientImpl{internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1-preview")} +} + +func (c meshIntegrationClientImpl) Create(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) { + integration.Kind = c.meshObject.Kind + integration.ApiVersion = c.meshObject.ApiVersion + return c.meshObject.Post(ctx, integration) +} + +func (c meshIntegrationClientImpl) Read(ctx context.Context, uuid string) (*MeshIntegration, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshIntegrationClientImpl) Update(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) { + integration.Kind = c.meshObject.Kind + integration.ApiVersion = c.meshObject.ApiVersion + return c.meshObject.Put(ctx, *integration.Metadata.Uuid, integration) +} + +func (c meshIntegrationClientImpl) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} + +func (c meshIntegrationClientImpl) List(ctx context.Context) ([]MeshIntegration, error) { + return c.meshObject.List(ctx) +} diff --git a/integration_config.go b/integration_config.go new file mode 100644 index 00000000..98f6f536 --- /dev/null +++ b/integration_config.go @@ -0,0 +1,80 @@ +package client + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" +) + +type MeshIntegrationConfigType string + +var ( + MeshIntegrationConfigTypes = enum.Enum[MeshIntegrationConfigType]{} + MeshIntegrationConfigTypeGithub = MeshIntegrationConfigTypes.Entry("github") + MeshIntegrationConfigTypeGitlab = MeshIntegrationConfigTypes.Entry("gitlab") + MeshIntegrationConfigTypeAzureDevops = MeshIntegrationConfigTypes.Entry("azuredevops") +) + +type MeshIntegrationGithubConfig struct { + Owner string `json:"owner" tfsdk:"owner"` + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + AppId string `json:"appId" tfsdk:"app_id"` + AppPrivateKey string `json:"appPrivateKey" tfsdk:"app_private_key"` + RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` +} + +type MeshIntegrationGitlabConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` +} + +type MeshIntegrationAzureDevopsConfig struct { + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + Organization string `json:"organization" tfsdk:"organization"` + PersonalAccessToken string `json:"personalAccessToken" tfsdk:"personal_access_token"` + RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` +} + +type MeshIntegrationConfig struct { + Type enum.Entry[MeshIntegrationConfigType] `json:"type" tfsdk:"-"` + Github *MeshIntegrationGithubConfig `json:"github,omitempty" tfsdk:"github"` + Gitlab *MeshIntegrationGitlabConfig `json:"gitlab,omitempty" tfsdk:"gitlab"` + AzureDevops *MeshIntegrationAzureDevopsConfig `json:"azuredevops,omitempty" tfsdk:"azuredevops"` +} + +func (m MeshIntegrationConfig) InferTypeFromNonNilField() (result enum.Entry[MeshIntegrationConfigType]) { + setResultIfNotNil := func(implType enum.Entry[MeshIntegrationConfigType], v any) { + if !reflect.ValueOf(v).IsZero() { + if len(result) > 0 && result != implType { + panic(fmt.Errorf("inferred config type %s but already set to %s", implType, result)) + } + result = implType + } + } + setResultIfNotNil(MeshIntegrationConfigTypeGithub, m.Github) + setResultIfNotNil(MeshIntegrationConfigTypeGitlab, m.Gitlab) + setResultIfNotNil(MeshIntegrationConfigTypeAzureDevops, m.AzureDevops) + if len(result) == 0 { + panic("cannot infer config type") + } + return +} + +func (m MeshIntegrationConfig) MarshalJSON() ([]byte, error) { + m.Type = m.InferTypeFromNonNilField() + // Using wrapped type avoids calling MarshalJSON recursively! + type wrapped MeshIntegrationConfig + return json.Marshal(wrapped(m)) +} + +func (m *MeshIntegrationConfig) UnmarshalJSON(bytes []byte) error { + type wrapped MeshIntegrationConfig + var target wrapped + if err := json.Unmarshal(bytes, &target); err != nil { + return err + } + *m = MeshIntegrationConfig(target) + return nil +} diff --git a/integrations.go b/integrations.go deleted file mode 100644 index df2643e5..00000000 --- a/integrations.go +++ /dev/null @@ -1,106 +0,0 @@ -package client - -import ( - "context" - - "github.com/meshcloud/terraform-provider-meshstack/client/internal" -) - -type MeshIntegration struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshIntegrationMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshIntegrationSpec `json:"spec" tfsdk:"spec"` - Status *MeshIntegrationStatus `json:"status,omitempty" tfsdk:"status"` -} - -type MeshIntegrationMetadata struct { - Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - CreatedOn *string `json:"createdOn,omitempty" tfsdk:"created_on"` -} - -type MeshIntegrationSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Config MeshIntegrationConfig `json:"config" tfsdk:"config"` -} - -type MeshIntegrationStatus struct { - IsBuiltIn bool `json:"isBuiltIn" tfsdk:"is_built_in"` - WorkloadIdentityFederation *MeshWorkloadIdentityFederation `json:"workloadIdentityFederation,omitempty" tfsdk:"workload_identity_federation"` -} - -// Integration Config wrapper with type discrimination. -type MeshIntegrationConfig struct { - Type string `json:"type" tfsdk:"type"` - Github *MeshIntegrationGithubConfig `json:"github,omitempty" tfsdk:"github"` - Gitlab *MeshIntegrationGitlabConfig `json:"gitlab,omitempty" tfsdk:"gitlab"` - AzureDevops *MeshIntegrationAzureDevopsConfig `json:"azuredevops,omitempty" tfsdk:"azuredevops"` -} - -// GitHub Integration. -type MeshIntegrationGithubConfig struct { - Owner string `json:"owner" tfsdk:"owner"` - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - AppId string `json:"appId" tfsdk:"app_id"` - AppPrivateKey string `json:"appPrivateKey" tfsdk:"app_private_key"` - RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` -} - -// GitLab Integration. -type MeshIntegrationGitlabConfig struct { - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` -} - -// Azure DevOps Integration. -type MeshIntegrationAzureDevopsConfig struct { - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - Organization string `json:"organization" tfsdk:"organization"` - PersonalAccessToken string `json:"personalAccessToken" tfsdk:"personal_access_token"` - RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` -} - -// Building Block Runner Reference. -type BuildingBlockRunnerRef struct { - Uuid string `json:"uuid" tfsdk:"uuid"` - Kind string `json:"kind" tfsdk:"kind"` -} - -// Workload Identity Federation. -type MeshWorkloadIdentityFederation struct { - Issuer string `json:"issuer" tfsdk:"issuer"` - Subject string `json:"subject" tfsdk:"subject"` - Gcp *MeshWifProvider `json:"gcp,omitempty" tfsdk:"gcp"` - Aws *MeshAwsWifProvider `json:"aws,omitempty" tfsdk:"aws"` - Azure *MeshWifProvider `json:"azure,omitempty" tfsdk:"azure"` -} - -type MeshWifProvider struct { - Audience string `json:"audience" tfsdk:"audience"` -} - -type MeshAwsWifProvider struct { - Audience string `json:"audience" tfsdk:"audience"` - Thumbprint string `json:"thumbprint" tfsdk:"thumbprint"` -} - -type MeshIntegrationClient struct { - meshObject internal.MeshObjectClient[MeshIntegration] -} - -func newIntegrationClient(ctx context.Context, httpClient *internal.HttpClient) MeshIntegrationClient { - return MeshIntegrationClient{internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1-preview")} -} - -func (c MeshIntegrationClient) integrationId(workspace string, uuid string) string { - return workspace + "/" + uuid -} - -func (c MeshIntegrationClient) Read(ctx context.Context, workspace string, uuid string) (*MeshIntegration, error) { - return c.meshObject.Get(ctx, c.integrationId(workspace, uuid)) -} - -func (c MeshIntegrationClient) List(ctx context.Context) ([]MeshIntegration, error) { - return c.meshObject.List(ctx) -} From 5ed8ad78516bcdbfd72402f6bbcd1cc274f9acfc Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Sun, 8 Feb 2026 19:37:07 +0100 Subject: [PATCH 104/215] feat: use default runner in meshstack_integration if omitted, add ref output, Secret support, better tests --- integration.go | 19 ++++++++----------- integration_config.go | 23 ++++++++++++----------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/integration.go b/integration.go index 8c6698a5..80e583ab 100644 --- a/integration.go +++ b/integration.go @@ -4,24 +4,21 @@ import ( "context" "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/types" ) type MeshIntegration struct { - ApiVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Metadata MeshIntegrationMetadata `json:"metadata"` - Spec MeshIntegrationSpec `json:"spec"` - Status *MeshIntegrationStatus `json:"status"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` + Kind string `json:"kind" tfsdk:"-"` + Metadata MeshIntegrationMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshIntegrationSpec `json:"spec" tfsdk:"spec"` + Status *MeshIntegrationStatus `json:"status" tfsdk:"status"` } -type MeshIntegrationMetadataAdapter[String any] struct { - Uuid String `json:"uuid,omitempty" tfsdk:"uuid"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +type MeshIntegrationMetadata struct { + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } -type MeshIntegrationMetadata = MeshIntegrationMetadataAdapter[*types.String] - type MeshIntegrationSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` Config MeshIntegrationConfig `json:"config" tfsdk:"config"` diff --git a/integration_config.go b/integration_config.go index 98f6f536..5b5b6801 100644 --- a/integration_config.go +++ b/integration_config.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" + "github.com/meshcloud/terraform-provider-meshstack/client/types" "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" ) @@ -18,23 +19,23 @@ var ( ) type MeshIntegrationGithubConfig struct { - Owner string `json:"owner" tfsdk:"owner"` - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - AppId string `json:"appId" tfsdk:"app_id"` - AppPrivateKey string `json:"appPrivateKey" tfsdk:"app_private_key"` - RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` + Owner string `json:"owner" tfsdk:"owner"` + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + AppId string `json:"appId" tfsdk:"app_id"` + AppPrivateKey types.Secret `json:"appPrivateKey" tfsdk:"app_private_key"` + RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` } type MeshIntegrationGitlabConfig struct { - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` } type MeshIntegrationAzureDevopsConfig struct { - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - Organization string `json:"organization" tfsdk:"organization"` - PersonalAccessToken string `json:"personalAccessToken" tfsdk:"personal_access_token"` - RunnerRef BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + Organization string `json:"organization" tfsdk:"organization"` + PersonalAccessToken types.Secret `json:"personalAccessToken" tfsdk:"personal_access_token"` + RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` } type MeshIntegrationConfig struct { From 8c376281b13103c2b4c90a7a15a494af174937ba Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Sat, 10 Jan 2026 20:25:24 +0100 Subject: [PATCH 105/215] feat: add client.BuildingBlockDefinition(version) with model --- buildingblock_definition.go | 110 +++++++++ buildingblock_definition_version.go | 225 ++++++++++++++++++ ...block_definition_version_implementation.go | 116 +++++++++ buildingblock_definition_version_test.go | 52 ++++ client.go | 40 ++-- testdata/bbd_input/empty.json | 1 + testdata/bbd_input/not_sensitive.json | 5 + .../bbd_input/not_sensitive_but_hash.json | 6 + testdata/bbd_input/sensitive.json | 6 + testdata/bbd_input/sensitive_but_no_hash.json | 4 + types/clienttypes.go | 10 +- types/clienttypes_test.go | 46 ++++ 12 files changed, 600 insertions(+), 21 deletions(-) create mode 100644 buildingblock_definition.go create mode 100644 buildingblock_definition_version.go create mode 100644 buildingblock_definition_version_implementation.go create mode 100644 buildingblock_definition_version_test.go create mode 100644 testdata/bbd_input/empty.json create mode 100644 testdata/bbd_input/not_sensitive.json create mode 100644 testdata/bbd_input/not_sensitive_but_hash.json create mode 100644 testdata/bbd_input/sensitive.json create mode 100644 testdata/bbd_input/sensitive_but_no_hash.json create mode 100644 types/clienttypes_test.go diff --git a/buildingblock_definition.go b/buildingblock_definition.go new file mode 100644 index 00000000..4323804f --- /dev/null +++ b/buildingblock_definition.go @@ -0,0 +1,110 @@ +package client + +import ( + "context" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" +) + +type MeshBuildingBlockType string + +var ( + MeshBuildingBlockTypes = enum.Enum[MeshBuildingBlockType]{} + MeshBuildingBlockTypeTenantLevel = MeshBuildingBlockTypes.Entry("TENANT_LEVEL") + MeshBuildingBlockTypeWorkspaceLevel = MeshBuildingBlockTypes.Entry("WORKSPACE_LEVEL") +) + +type MeshBuildingBlockDefinitionMetadata struct { + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` +} + +type BuildingBlockDefinitionSupportedPlatform string + +type MeshBuildingBlockDefinitionSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + TargetType MeshBuildingBlockType `json:"targetType" tfsdk:"target_type"` + Description string `json:"description" tfsdk:"description"` + Readme *string `json:"readme,omitempty" tfsdk:"readme"` + RunTransparency bool `json:"runTransparency" tfsdk:"run_transparency"` + UseInLandingZonesOnly bool `json:"useInLandingZonesOnly" tfsdk:"use_in_landing_zones_only"` + SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` + DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + // NotificationSubscribers can also specify emails with prefix 'email:', so it's not only usernames (as the JSON field name suggests)! + NotificationSubscribers []string `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` + Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` + // SupportedPlatforms are currently platform types only. Specifying single platforms is currently unsupported. + // Have this list of string with a dedicated type, to convert it to/from Platform Type refs. + SupportedPlatforms []BuildingBlockDefinitionSupportedPlatform `json:"supportedPlatforms" tfsdk:"supported_platforms"` +} + +type MeshBuildingBlockDefinitionStatusVersion struct { + VersionUuid string `json:"versionUuid"` + VersionNumber int64 `json:"versionNumber"` + State MeshBuildingBlockDefinitionVersionState `json:"state"` +} + +type MeshBuildingBlockDefinitionStatus struct { + UsageCount *int64 `json:"usageCount"` + Versions []MeshBuildingBlockDefinitionStatusVersion `json:"versions"` + LatestVersion int64 `json:"latestVersion"` + LatestVersionUuid string `json:"latestVersionUuid"` + LatestReleasedVersion *int64 `json:"latestReleasedVersion"` + LatestReleasedVersionUuid *string `json:"latestReleasedVersionUuid"` +} + +type MeshBuildingBlockDefinition struct { + ApiVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata MeshBuildingBlockDefinitionMetadata `json:"metadata"` + Spec MeshBuildingBlockDefinitionSpec `json:"spec"` + Status *MeshBuildingBlockDefinitionStatus `json:"status,omitempty"` +} + +type MeshBuildingBlockDefinitionClient interface { + List(ctx context.Context, workspaceIdentifier *string) ([]MeshBuildingBlockDefinition, error) + Read(ctx context.Context, uuid string) (*MeshBuildingBlockDefinition, error) + Create(ctx context.Context, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) + Update(ctx context.Context, uuid string, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) + Delete(ctx context.Context, uuid string) error +} + +type meshBuildingBlockDefinitionClient struct { + meshObject internal.MeshObjectClient[MeshBuildingBlockDefinition] +} + +func newBuildingBlockDefinitionClient(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockDefinitionClient { + return meshBuildingBlockDefinitionClient{ + meshObject: internal.NewMeshObjectClient[MeshBuildingBlockDefinition](ctx, httpClient, "v1-preview"), + } +} + +func (c meshBuildingBlockDefinitionClient) List(ctx context.Context, workspaceIdentifier *string) ([]MeshBuildingBlockDefinition, error) { + var options []internal.RequestOption + if workspaceIdentifier != nil { + options = append(options, internal.WithUrlQuery("workspaceIdentifier", *workspaceIdentifier)) + } + return c.meshObject.List(ctx, options...) +} + +func (c meshBuildingBlockDefinitionClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlockDefinition, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshBuildingBlockDefinitionClient) Create(ctx context.Context, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) { + definition.Kind = c.meshObject.Kind + definition.ApiVersion = c.meshObject.ApiVersion + return c.meshObject.Post(ctx, definition) +} + +func (c meshBuildingBlockDefinitionClient) Update(ctx context.Context, uuid string, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) { + definition.Kind = c.meshObject.Kind + definition.ApiVersion = c.meshObject.ApiVersion + return c.meshObject.Put(ctx, uuid, definition) +} + +func (c meshBuildingBlockDefinitionClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} diff --git a/buildingblock_definition_version.go b/buildingblock_definition_version.go new file mode 100644 index 00000000..4ba86238 --- /dev/null +++ b/buildingblock_definition_version.go @@ -0,0 +1,225 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" +) + +// Enums + +type MeshBuildingBlockDefinitionVersionState string + +var ( + MeshBuildingBlockDefinitionVersionStates = enum.Enum[MeshBuildingBlockDefinitionVersionState]{} + MeshBuildingBlockDefinitionVersionStateDraft = MeshBuildingBlockDefinitionVersionStates.Entry("DRAFT") + MeshBuildingBlockDefinitionVersionStateReleased = MeshBuildingBlockDefinitionVersionStates.Entry("RELEASED") +) + +type BuildingBlockDeletionMode string + +var ( + BuildingBlockDeletionModes = enum.Enum[BuildingBlockDeletionMode]{} + BuildingBlockDeletionModeDelete = BuildingBlockDeletionModes.Entry("DELETE") + BuildingBlockDeletionModePurge = BuildingBlockDeletionModes.Entry("PURGE") +) + +type MeshBuildingBlockIOType string + +var ( + MeshBuildingBlockIOTypes = enum.Enum[MeshBuildingBlockIOType]{} + MeshBuildingBlockIOTypeString = MeshBuildingBlockIOTypes.Entry("STRING") + MeshBuildingBlockIOTypeCode = MeshBuildingBlockIOTypes.Entry("CODE") + MeshBuildingBlockIOTypeInteger = MeshBuildingBlockIOTypes.Entry("INTEGER") + MeshBuildingBlockIOTypeBoolean = MeshBuildingBlockIOTypes.Entry("BOOLEAN") + MeshBuildingBlockIOTypeFile = MeshBuildingBlockIOTypes.Entry("FILE") + MeshBuildingBlockIOTypeList = MeshBuildingBlockIOTypes.Entry("LIST") + MeshBuildingBlockIOTypeSingleSelect = MeshBuildingBlockIOTypes.Entry("SINGLE_SELECT") + MeshBuildingBlockIOTypeMultiSelect = MeshBuildingBlockIOTypes.Entry("MULTI_SELECT") +) + +type MeshBuildingBlockInputAssignmentType string + +var ( + MeshBuildingBlockInputAssignmentTypes = enum.Enum[MeshBuildingBlockInputAssignmentType]{} + MeshBuildingBlockInputAssignmentTypeAuthor = MeshBuildingBlockInputAssignmentTypes.Entry("AUTHOR") + MeshBuildingBlockInputAssignmentTypeUserInput = MeshBuildingBlockInputAssignmentTypes.Entry("USER_INPUT") + MeshBuildingBlockInputAssignmentTypePlatformOperatorManualInput = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_OPERATOR_MANUAL_INPUT") + MeshBuildingBlockInputAssignmentTypeBuildingBlockOutput = MeshBuildingBlockInputAssignmentTypes.Entry("BUILDING_BLOCK_OUTPUT") + MeshBuildingBlockInputAssignmentTypePlatformTenantID = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_TENANT_ID") + MeshBuildingBlockInputAssignmentTypeWorkspaceIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("WORKSPACE_IDENTIFIER") + MeshBuildingBlockInputAssignmentTypeProjectIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("PROJECT_IDENTIFIER") + MeshBuildingBlockInputAssignmentTypeFullPlatformIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("FULL_PLATFORM_IDENTIFIER") + MeshBuildingBlockInputAssignmentTypeTenantBuildingBlockUuid = MeshBuildingBlockInputAssignmentTypes.Entry("TENANT_BUILDING_BLOCK_UUID") + MeshBuildingBlockInputAssignmentTypeStatic = MeshBuildingBlockInputAssignmentTypes.Entry("STATIC") + MeshBuildingBlockInputAssignmentTypeUserPermissions = MeshBuildingBlockInputAssignmentTypes.Entry("USER_PERMISSIONS") +) + +type MeshBuildingBlockDefinitionOutputAssignmentType string + +var ( + MeshBuildingBlockDefinitionOutputAssignmentTypes = enum.Enum[MeshBuildingBlockDefinitionOutputAssignmentType]{} + MeshBuildingBlockDefinitionOutputAssignmentTypeNone = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("NONE") + MeshBuildingBlockDefinitionOutputAssignmentTypePlatformTenantID = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("PLATFORM_TENANT_ID") + MeshBuildingBlockDefinitionOutputAssignmentTypeSignInURL = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("SIGN_IN_URL") + MeshBuildingBlockDefinitionOutputAssignmentTypeResourceURL = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("RESOURCE_URL") + MeshBuildingBlockDefinitionOutputAssignmentTypeSummary = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("SUMMARY") +) + +// Ref types + +type BuildingBlockDefinitionRef struct { + Uuid string `json:"uuid"` + Kind string `json:"kind"` +} + +type MeshIntegrationRef struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + Kind string `json:"kind" tfsdk:"kind"` +} + +// Input and Output types + +type MeshBuildingBlockDefinitionInput struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Type MeshBuildingBlockIOType `json:"type" tfsdk:"type"` + AssignmentType MeshBuildingBlockInputAssignmentType `json:"assignmentType" tfsdk:"assignment_type"` + IsEnvironment bool `json:"isEnvironment" tfsdk:"is_environment"` + IsSensitive bool `json:"isSensitive" tfsdk:"-"` + // If IsSensitive is true, the [types.Variant] (typedef [types.SecretOrAny]) for fields + // MeshBuildingBlockDefinitionInputAdapter.Argument and + // MeshBuildingBlockDefinitionInputAdapter.DefaultValue + // is of [types.Secret] (case [types.Variant.X]). + // Otherwise, the [types.Variant] is of [types.Any] (case [types.Variant.Y]). + // As this is a fallback detection when JSON (un)marshaling, + // types.Any must go second as [types.Variant] intentionally prefers X over Y. + Argument types.SecretOrAny `json:"argument,omitempty" tfsdk:"argument"` + DefaultValue types.SecretOrAny `json:"defaultValue,omitempty" tfsdk:"default_value"` + UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` + SelectableValues []types.SetElem `json:"selectableValues,omitempty" tfsdk:"selectable_values"` + Description *string `json:"description,omitempty" tfsdk:"description"` + ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` + ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` +} + +func (m *MeshBuildingBlockDefinitionInput) UnmarshalJSON(bytes []byte) error { + type wrapped MeshBuildingBlockDefinitionInput + var target wrapped + if err := json.Unmarshal(bytes, &target); err != nil { + return err + } + *m = MeshBuildingBlockDefinitionInput(target) + switch { + case !m.IsSensitive: + // ensure "any" struct fields never end up in X accidentally, + // as X is only set when IsSensitive is true! + var errs []error + moveXtoYIfPresent := func(v *types.SecretOrAny) { + if v.HasX() { + xJson, err := json.Marshal(v.X) + errs = append(errs, err) + v.X = types.Secret{} + errs = append(errs, json.Unmarshal(xJson, &v.Y)) + } + } + moveXtoYIfPresent(&m.Argument) + moveXtoYIfPresent(&m.DefaultValue) + return errors.Join(errs...) + case m.Argument.HasY(), m.DefaultValue.HasY(): + return fmt.Errorf("got sensitive argument or default_value but variant Y is set instead") + default: + return nil + } +} + +type MeshBuildingBlockDefinitionOutput struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Type MeshBuildingBlockIOType `json:"type" tfsdk:"type"` + AssignmentType MeshBuildingBlockDefinitionOutputAssignmentType `json:"assignmentType" tfsdk:"assignment_type"` +} + +// Main version types + +type MeshBuildingBlockDefinitionVersionMetadata struct { + Uuid string `json:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace"` + CreatedOn string `json:"createdOn"` +} + +type BuildingBlockDependencyRef string +type MeshBuildingBlockDefinitionVersionSpec struct { + BuildingBlockDefinitionRef *BuildingBlockDefinitionRef `json:"buildingBlockDefinitionRef" tfsdk:"-"` + OnlyApplyOncePerTenant bool `json:"onlyApplyOncePerTenant" tfsdk:"only_apply_once_per_tenant"` + DeletionMode BuildingBlockDeletionMode `json:"deletionMode" tfsdk:"deletion_mode"` + Outputs map[string]MeshBuildingBlockDefinitionOutput `json:"outputs" tfsdk:"outputs"` + VersionNumber *int64 `json:"versionNumber,omitempty" tfsdk:"version_number"` + State *MeshBuildingBlockDefinitionVersionState `json:"state,omitempty" tfsdk:"state"` + RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` + DependencyDefinitionUUIDs []BuildingBlockDependencyRef `json:"dependencyDefinitionUuids,omitempty" tfsdk:"dependency_refs"` + Implementation MeshBuildingBlockDefinitionImplementation `json:"implementation" tfsdk:"implementation"` + Inputs map[string]*MeshBuildingBlockDefinitionInput `json:"inputs" tfsdk:"inputs"` +} + +type MeshBuildingBlockDefinitionVersionStatus struct { + State MeshBuildingBlockDefinitionVersionState `json:"state" tfsdk:"state"` + UsageCount int64 `json:"usageCount" tfsdk:"usage_count"` +} + +type MeshBuildingBlockDefinitionVersion struct { + ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + Kind string `json:"kind" tfsdk:"kind"` + Metadata MeshBuildingBlockDefinitionVersionMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockDefinitionVersionSpec `json:"spec" tfsdk:"spec"` + Status *MeshBuildingBlockDefinitionVersionStatus `json:"status,omitempty" tfsdk:"status"` +} + +// MeshBuildingBlockDefinitionVersionClient manages a version of a building block definition. +// As such a version is tightly coupled to the definition, there's no single Get or Delete implemented. +// A Get is not required as we always expose all versions of a definition anyway, and a Delete happens together when the definition is deleted. +type MeshBuildingBlockDefinitionVersionClient interface { + List(ctx context.Context, buildingBlockDefinitionUuid string) ([]MeshBuildingBlockDefinitionVersion, error) + Create(ctx context.Context, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) + Update(ctx context.Context, uuid, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) +} + +type meshBuildingBlockDefinitionVersionClient struct { + meshObject internal.MeshObjectClient[MeshBuildingBlockDefinitionVersion] +} + +func newBuildingBlockDefinitionVersionClient(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockDefinitionVersionClient { + return meshBuildingBlockDefinitionVersionClient{ + meshObject: internal.NewMeshObjectClient[MeshBuildingBlockDefinitionVersion](ctx, httpClient, "v1-preview"), + } +} + +func (c meshBuildingBlockDefinitionVersionClient) List(ctx context.Context, buildingBlockDefinitionUuid string) ([]MeshBuildingBlockDefinitionVersion, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery("buildingBlockDefinitionUuid", buildingBlockDefinitionUuid)) +} + +func (c meshBuildingBlockDefinitionVersionClient) Create(ctx context.Context, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) { + return c.meshObject.Post(ctx, MeshBuildingBlockDefinitionVersion{ + ApiVersion: c.meshObject.ApiVersion, + Kind: c.meshObject.Kind, + Metadata: MeshBuildingBlockDefinitionVersionMetadata{ + OwnedByWorkspace: ownedByWorkspace, + }, + Spec: versionSpec, + }) +} + +func (c meshBuildingBlockDefinitionVersionClient) Update(ctx context.Context, uuid, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) { + return c.meshObject.Put(ctx, uuid, MeshBuildingBlockDefinitionVersion{ + ApiVersion: c.meshObject.ApiVersion, + Kind: c.meshObject.Kind, + Metadata: MeshBuildingBlockDefinitionVersionMetadata{ + Uuid: uuid, + OwnedByWorkspace: ownedByWorkspace, + }, + Spec: versionSpec, + }) +} diff --git a/buildingblock_definition_version_implementation.go b/buildingblock_definition_version_implementation.go new file mode 100644 index 00000000..f8d169d1 --- /dev/null +++ b/buildingblock_definition_version_implementation.go @@ -0,0 +1,116 @@ +package client + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" +) + +type MeshBuildingBlockImplementationType string + +var ( + MeshBuildingBlockImplementationTypes = enum.Enum[MeshBuildingBlockImplementationType]{} + MeshBuildingBlockImplementationTypeManual = MeshBuildingBlockImplementationTypes.Entry("manual") + MeshBuildingBlockImplementationTypeTerraform = MeshBuildingBlockImplementationTypes.Entry("terraform") + MeshBuildingBlockImplementationTypeGithubWorkflows = MeshBuildingBlockImplementationTypes.Entry("githubWorkflows") + MeshBuildingBlockImplementationTypeGitlabPipeline = MeshBuildingBlockImplementationTypes.Entry("gitlabPipeline") + MeshBuildingBlockImplementationTypeAzureDevOpsPipeline = MeshBuildingBlockImplementationTypes.Entry("azureDevOpsPipeline") +) + +type MeshBuildingBlockDefinitionSshKnownHost struct { + Host string `json:"host" tfsdk:"host"` + KeyType string `json:"keyType" tfsdk:"key_type"` + KeyValue string `json:"keyValue" tfsdk:"key_value"` +} + +type MeshBuildingBlockDefinitionTerraformImplementation struct { + TerraformVersion string `json:"terraformVersion" tfsdk:"terraform_version"` + RepositoryURL string `json:"repositoryUrl" tfsdk:"repository_url"` + Async bool `json:"async" tfsdk:"async"` + RepositoryPath *string `json:"repositoryPath,omitempty" tfsdk:"repository_path"` + RefName *string `json:"refName,omitempty" tfsdk:"ref_name"` + SSHKnownHost *MeshBuildingBlockDefinitionSshKnownHost `json:"sshKnownHost,omitempty" tfsdk:"ssh_known_host"` + UseMeshHTTPBackendFallback bool `json:"useMeshHttpBackendFallback" tfsdk:"use_mesh_http_backend_fallback"` + SSHPrivateKey *types.Secret `json:"sshPrivateKey,omitempty" tfsdk:"ssh_private_key"` +} + +type MeshBuildingBlockDefinitionGitHubWorkflowsImplementation struct { + Repository string `json:"repository" tfsdk:"repository"` + Branch string `json:"branch" tfsdk:"branch"` + ApplyWorkflow string `json:"applyWorkflow" tfsdk:"apply_workflow"` + DestroyWorkflow *string `json:"destroyWorkflow" tfsdk:"destroy_workflow"` + Async bool `json:"async" tfsdk:"async"` + OmitRunObjectInput bool `json:"omitRunObjectInput" tfsdk:"omit_run_object_input"` + IntegrationRef MeshIntegrationRef `json:"integrationRef" tfsdk:"integration_ref"` +} + +type MeshBuildingBlockDefinitionManualImplementation struct { +} + +type MeshBuildingBlockDefinitionGitLabPipelineImplementation struct { + ProjectID string `json:"projectId" tfsdk:"project_id"` + RefName string `json:"refName" tfsdk:"ref_name"` + IntegrationRef MeshIntegrationRef `json:"integrationRef" tfsdk:"integration_ref"` + PipelineTriggerToken types.Secret `json:"pipelineTriggerToken" tfsdk:"pipeline_trigger_token"` +} + +type MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation struct { + Project string `json:"project" tfsdk:"project"` + PipelineID string `json:"pipelineId" tfsdk:"pipeline_id"` + Async bool `json:"async" tfsdk:"async"` + IntegrationRef MeshIntegrationRef `json:"integrationRef" tfsdk:"integration_ref"` +} + +type MeshBuildingBlockDefinitionImplementation struct { + Type enum.Entry[MeshBuildingBlockImplementationType] `json:"type" tfsdk:"-"` + Manual *MeshBuildingBlockDefinitionManualImplementation `json:"manual,omitempty" tfsdk:"manual"` + GithubWorkflows *MeshBuildingBlockDefinitionGitHubWorkflowsImplementation `json:"githubWorkflows,omitempty" tfsdk:"github_workflows"` + AzureDevOpsPipeline *MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation `json:"azureDevOpsPipeline,omitempty" tfsdk:"azure_devops_pipeline"` + GitlabPipeline *MeshBuildingBlockDefinitionGitLabPipelineImplementation `json:"gitlabPipeline,omitempty" tfsdk:"gitlab_pipeline"` + Terraform *MeshBuildingBlockDefinitionTerraformImplementation `json:"terraform,omitempty" tfsdk:"terraform"` +} + +func (m MeshBuildingBlockDefinitionImplementation) InferTypeFromNonNilField() (result enum.Entry[MeshBuildingBlockImplementationType]) { + setResultIfNotNil := func(implType enum.Entry[MeshBuildingBlockImplementationType], v any) { + // Manual implementation is an empty struct, so carefully check v for nilness using reflection! + if !reflect.ValueOf(v).IsZero() { + if len(result) > 0 && result != implType { + panic(fmt.Errorf("inferred implementation type %s but already set to %s", implType, result)) + } + result = implType + } + } + setResultIfNotNil(MeshBuildingBlockImplementationTypeManual, m.Manual) + setResultIfNotNil(MeshBuildingBlockImplementationTypeTerraform, m.Terraform) + setResultIfNotNil(MeshBuildingBlockImplementationTypeGithubWorkflows, m.GithubWorkflows) + setResultIfNotNil(MeshBuildingBlockImplementationTypeGitlabPipeline, m.GitlabPipeline) + setResultIfNotNil(MeshBuildingBlockImplementationTypeAzureDevOpsPipeline, m.AzureDevOpsPipeline) + if len(result) == 0 { + panic("cannot infer implementation type") + } + return +} + +func (m MeshBuildingBlockDefinitionImplementation) MarshalJSON() ([]byte, error) { + if len(m.Type) == 0 { + m.Type = m.InferTypeFromNonNilField() + } + type wrapped MeshBuildingBlockDefinitionImplementation + return json.Marshal(wrapped(m)) +} + +func (m *MeshBuildingBlockDefinitionImplementation) UnmarshalJSON(bytes []byte) error { + type wrapped MeshBuildingBlockDefinitionImplementation + var target wrapped + if err := json.Unmarshal(bytes, &target); err != nil { + return err + } + *m = MeshBuildingBlockDefinitionImplementation(target) + if m.Type == MeshBuildingBlockImplementationTypeManual { + m.Manual = &MeshBuildingBlockDefinitionManualImplementation{} + } + return nil +} diff --git a/buildingblock_definition_version_test.go b/buildingblock_definition_version_test.go new file mode 100644 index 00000000..9316d46c --- /dev/null +++ b/buildingblock_definition_version_test.go @@ -0,0 +1,52 @@ +package client + +import ( + "embed" + "encoding/json" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/terraform-provider-meshstack/client/types/ptr" +) + +var ( + //go:embed testdata/bbd_input + bbdInputTestdata embed.FS +) + +func TestMeshBuildingBlockDefinitionInput_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + wantSensitive bool + wantArgument types.SecretOrAny + wantDefaultValue types.SecretOrAny + wantErr assert.ErrorAssertionFunc + }{ + {"empty", false, types.SecretOrAny{}, types.SecretOrAny{}, assert.NoError}, + {"not_sensitive", false, types.SecretOrAny{Y: true}, types.SecretOrAny{Y: "some-string"}, assert.NoError}, + {"not_sensitive_but_hash", false, types.SecretOrAny{Y: map[string]any{"hash": "some-hash-looks-like-secret"}}, types.SecretOrAny{}, assert.NoError}, + {"sensitive", true, types.SecretOrAny{}, types.SecretOrAny{X: types.Secret{Hash: ptr.To("some-hash")}}, assert.NoError}, + {"sensitive_but_no_hash", true, types.SecretOrAny{Y: map[string]any{}}, types.SecretOrAny{}, func(t assert.TestingT, err error, msgAndArgs ...any) bool { + return assert.ErrorContains(t, err, "got sensitive argument or default_value but variant Y is set instead") + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jsonFile, err := bbdInputTestdata.ReadFile(path.Join("testdata/bbd_input", path.Base(tt.name)+".json")) + require.NoError(t, err) + var target MeshBuildingBlockDefinitionInput + if tt.wantErr(t, json.Unmarshal(jsonFile, &target)) { + expected := MeshBuildingBlockDefinitionInput{ + IsSensitive: tt.wantSensitive, + Argument: tt.wantArgument, + DefaultValue: tt.wantDefaultValue, + } + assert.Equal(t, expected, target) + } + }) + } +} diff --git a/client.go b/client.go index f55c5982..e47a1f48 100644 --- a/client.go +++ b/client.go @@ -17,24 +17,26 @@ import ( var MinMeshStackVersion = version.MustParse("2026.2.0") type Client struct { - BuildingBlock MeshBuildingBlockClient - BuildingBlockV2 MeshBuildingBlockV2Client - Integration MeshIntegrationClient - LandingZone MeshLandingZoneClient - Location MeshLocationClient - PaymentMethod MeshPaymentMethodClient - Platform MeshPlatformClient - Project MeshProjectClient - ProjectGroupBinding MeshProjectGroupBindingClient - ProjectUserBinding MeshProjectUserBindingClient - ServiceInstance MeshServiceInstanceClient - TagDefinition MeshTagDefinitionClient - Tenant MeshTenantClient - TenantV4 MeshTenantV4Client - Workspace MeshWorkspaceClient - WorkspaceGroupBinding MeshWorkspaceGroupBindingClient - WorkspaceUserBinding MeshWorkspaceUserBindingClient - PlatformType MeshPlatformTypeClient + BuildingBlock MeshBuildingBlockClient + BuildingBlockV2 MeshBuildingBlockV2Client + BuildingBlockDefinition MeshBuildingBlockDefinitionClient + BuildingBlockDefinitionVersion MeshBuildingBlockDefinitionVersionClient + Integration MeshIntegrationClient + LandingZone MeshLandingZoneClient + Location MeshLocationClient + PaymentMethod MeshPaymentMethodClient + Platform MeshPlatformClient + Project MeshProjectClient + ProjectGroupBinding MeshProjectGroupBindingClient + ProjectUserBinding MeshProjectUserBindingClient + ServiceInstance MeshServiceInstanceClient + TagDefinition MeshTagDefinitionClient + Tenant MeshTenantClient + TenantV4 MeshTenantV4Client + Workspace MeshWorkspaceClient + WorkspaceGroupBinding MeshWorkspaceGroupBindingClient + WorkspaceUserBinding MeshWorkspaceUserBindingClient + PlatformType MeshPlatformTypeClient } func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret string, apiToken string) (Client, error) { @@ -70,6 +72,8 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret str return Client{ newBuildingBlockClient(ctx, httpClient), newBuildingBlockV2Client(ctx, httpClient), + newBuildingBlockDefinitionClient(ctx, httpClient), + newBuildingBlockDefinitionVersionClient(ctx, httpClient), newIntegrationClient(ctx, httpClient), newLandingZoneClient(ctx, httpClient), newLocationClient(ctx, httpClient), diff --git a/testdata/bbd_input/empty.json b/testdata/bbd_input/empty.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/bbd_input/empty.json @@ -0,0 +1 @@ +{} diff --git a/testdata/bbd_input/not_sensitive.json b/testdata/bbd_input/not_sensitive.json new file mode 100644 index 00000000..4bf4ef43 --- /dev/null +++ b/testdata/bbd_input/not_sensitive.json @@ -0,0 +1,5 @@ +{ + "isSensitive": false, + "argument": true, + "defaultValue": "some-string" +} diff --git a/testdata/bbd_input/not_sensitive_but_hash.json b/testdata/bbd_input/not_sensitive_but_hash.json new file mode 100644 index 00000000..49d2b6dd --- /dev/null +++ b/testdata/bbd_input/not_sensitive_but_hash.json @@ -0,0 +1,6 @@ +{ + "isSensitive": false, + "argument": { + "hash": "some-hash-looks-like-secret" + } +} diff --git a/testdata/bbd_input/sensitive.json b/testdata/bbd_input/sensitive.json new file mode 100644 index 00000000..861803d2 --- /dev/null +++ b/testdata/bbd_input/sensitive.json @@ -0,0 +1,6 @@ +{ + "isSensitive": true, + "defaultValue": { + "hash": "some-hash" + } +} diff --git a/testdata/bbd_input/sensitive_but_no_hash.json b/testdata/bbd_input/sensitive_but_no_hash.json new file mode 100644 index 00000000..c9fc4ed9 --- /dev/null +++ b/testdata/bbd_input/sensitive_but_no_hash.json @@ -0,0 +1,4 @@ +{ + "isSensitive": true, + "argument": {} +} diff --git a/types/clienttypes.go b/types/clienttypes.go index 08d597d6..50c7a87d 100644 --- a/types/clienttypes.go +++ b/types/clienttypes.go @@ -1,12 +1,16 @@ package types +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/types/variant" +) + type ( - String = string - Number = int64 - Any = any + SetElem string Secret struct { Plaintext *string `json:"plaintext,omitempty" tfsdk:"plaintext"` Hash *string `json:"hash,omitempty" tfsdk:"-"` } + + SecretOrAny = variant.Variant[Secret, any] ) diff --git a/types/clienttypes_test.go b/types/clienttypes_test.go new file mode 100644 index 00000000..39a3f993 --- /dev/null +++ b/types/clienttypes_test.go @@ -0,0 +1,46 @@ +package types + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/terraform-provider-meshstack/client/types/ptr" +) + +func TestSecretOrAny(t *testing.T) { + type testCase struct { + name string + json string + v SecretOrAny + + wantX, wantY bool + } + tests := []testCase{ + {"empty", `null`, SecretOrAny{}, false, false}, + {"X plaintext", `{"plaintext":"some-secret"}`, SecretOrAny{X: Secret{Plaintext: ptr.To("some-secret")}}, true, false}, + {"Y string", `"some-string"`, SecretOrAny{Y: "some-string"}, false, true}, + {"Y bool", `true`, SecretOrAny{Y: true}, false, true}, + {"Y number", `1.23123`, SecretOrAny{Y: 1.23123}, false, true}, + {"Y other struct", `{"A":"aa","B":"bb"}`, SecretOrAny{Y: map[string]any{"A": "aa", "B": "bb"}}, false, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Run("unmarshal", func(t *testing.T) { + var unmarshalled SecretOrAny + require.NoError(t, json.Unmarshal([]byte(tt.json), &unmarshalled)) + assert.Equal(t, tt.v, unmarshalled) + assert.Equal(t, tt.wantX, unmarshalled.HasX()) + assert.Equal(t, tt.wantY, unmarshalled.HasY()) + }) + + t.Run("marshal", func(t *testing.T) { + marshalled, err := json.Marshal(tt.v) + require.NoError(t, err) + assert.Equal(t, tt.json, string(marshalled)) + }) + }) + } +} From f62ac8b35b5eaa6f3fd7e670cbbdf7ecc8939061 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 11 Feb 2026 10:41:17 +0100 Subject: [PATCH 106/215] feat: add meshstack_building_block_definition --- buildingblock_definition_version_test.go | 4 ++-- .../empty.json | 0 .../not_sensitive.json | 0 .../not_sensitive_but_hash.json | 0 .../sensitive.json | 0 .../sensitive_but_no_hash.json | 0 6 files changed, 2 insertions(+), 2 deletions(-) rename testdata/{bbd_input => building_block_definition_version_input}/empty.json (100%) rename testdata/{bbd_input => building_block_definition_version_input}/not_sensitive.json (100%) rename testdata/{bbd_input => building_block_definition_version_input}/not_sensitive_but_hash.json (100%) rename testdata/{bbd_input => building_block_definition_version_input}/sensitive.json (100%) rename testdata/{bbd_input => building_block_definition_version_input}/sensitive_but_no_hash.json (100%) diff --git a/buildingblock_definition_version_test.go b/buildingblock_definition_version_test.go index 9316d46c..884a3ce3 100644 --- a/buildingblock_definition_version_test.go +++ b/buildingblock_definition_version_test.go @@ -14,7 +14,7 @@ import ( ) var ( - //go:embed testdata/bbd_input + //go:embed testdata/building_block_definition_version_input bbdInputTestdata embed.FS ) @@ -36,7 +36,7 @@ func TestMeshBuildingBlockDefinitionInput_UnmarshalJSON(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - jsonFile, err := bbdInputTestdata.ReadFile(path.Join("testdata/bbd_input", path.Base(tt.name)+".json")) + jsonFile, err := bbdInputTestdata.ReadFile(path.Join("testdata/building_block_definition_version_input", path.Base(tt.name)+".json")) require.NoError(t, err) var target MeshBuildingBlockDefinitionInput if tt.wantErr(t, json.Unmarshal(jsonFile, &target)) { diff --git a/testdata/bbd_input/empty.json b/testdata/building_block_definition_version_input/empty.json similarity index 100% rename from testdata/bbd_input/empty.json rename to testdata/building_block_definition_version_input/empty.json diff --git a/testdata/bbd_input/not_sensitive.json b/testdata/building_block_definition_version_input/not_sensitive.json similarity index 100% rename from testdata/bbd_input/not_sensitive.json rename to testdata/building_block_definition_version_input/not_sensitive.json diff --git a/testdata/bbd_input/not_sensitive_but_hash.json b/testdata/building_block_definition_version_input/not_sensitive_but_hash.json similarity index 100% rename from testdata/bbd_input/not_sensitive_but_hash.json rename to testdata/building_block_definition_version_input/not_sensitive_but_hash.json diff --git a/testdata/bbd_input/sensitive.json b/testdata/building_block_definition_version_input/sensitive.json similarity index 100% rename from testdata/bbd_input/sensitive.json rename to testdata/building_block_definition_version_input/sensitive.json diff --git a/testdata/bbd_input/sensitive_but_no_hash.json b/testdata/building_block_definition_version_input/sensitive_but_no_hash.json similarity index 100% rename from testdata/bbd_input/sensitive_but_no_hash.json rename to testdata/building_block_definition_version_input/sensitive_but_no_hash.json From 0dc1f1a484e3c7fbc911cc8378569c9fa2467bac Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 11 Feb 2026 14:14:47 +0100 Subject: [PATCH 107/215] fix: handle empty argument/defaultValue correctly in SecretOrAny --- types/clienttypes_test.go | 1 + types/variant/variant.go | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/types/clienttypes_test.go b/types/clienttypes_test.go index 39a3f993..cfaeb95a 100644 --- a/types/clienttypes_test.go +++ b/types/clienttypes_test.go @@ -24,6 +24,7 @@ func TestSecretOrAny(t *testing.T) { {"Y string", `"some-string"`, SecretOrAny{Y: "some-string"}, false, true}, {"Y bool", `true`, SecretOrAny{Y: true}, false, true}, {"Y number", `1.23123`, SecretOrAny{Y: 1.23123}, false, true}, + {"Y empty string", `""`, SecretOrAny{Y: ""}, false, true}, {"Y other struct", `{"A":"aa","B":"bb"}`, SecretOrAny{Y: map[string]any{"A": "aa", "B": "bb"}}, false, true}, } for _, tt := range tests { diff --git a/types/variant/variant.go b/types/variant/variant.go index 67951815..a7f3f66a 100644 --- a/types/variant/variant.go +++ b/types/variant/variant.go @@ -31,14 +31,24 @@ func (v Variant[X, Y]) MarshalJSON() ([]byte, error) { } } +func has[T any](xy any) bool { + v := reflect.ValueOf(xy) + kind := reflect.TypeFor[T]().Kind() + if kind != reflect.Interface { + // T is not any (aka as a valid 'zero' representation) + return !v.IsZero() + } else { + // T is any, so we only check for validness + return v.IsValid() + } +} + func (v Variant[X, Y]) HasX() bool { - x := reflect.ValueOf(v.X) - return x.IsValid() && !x.IsZero() + return has[X](v.X) } func (v Variant[X, Y]) HasY() bool { - y := reflect.ValueOf(v.Y) - return y.IsValid() && !y.IsZero() + return has[Y](v.Y) } func (v Variant[X, Y]) WithX(action func(x *X)) { From 91d1926b171b8d0d6b3e5f8096db6d7136463cc0 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 13 Feb 2026 10:35:31 +0100 Subject: [PATCH 108/215] feat: add permissions to meshstack_building_block_definition.version_spec --- api_permissions.go | 86 +++++++++++++++++++++++++++++ buildingblock_definition_version.go | 1 + 2 files changed, 87 insertions(+) create mode 100644 api_permissions.go diff --git a/api_permissions.go b/api_permissions.go new file mode 100644 index 00000000..e7e89ed9 --- /dev/null +++ b/api_permissions.go @@ -0,0 +1,86 @@ +package client + +import ( + "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" +) + +// API Permissions as defined in https://docs.meshcloud.io/api/authentication/api-permissions/ + +type ApiPermission string + +// Workspace Permissions (non-admin). +var ( + WorkspacePermissions = enum.Enum[ApiPermission]{} + + PermissionBuildingBlockDefinitionDelete = WorkspacePermissions.Entry("BUILDINGBLOCKDEFINITION_DELETE") + PermissionBuildingBlockDefinitionList = WorkspacePermissions.Entry("BUILDINGBLOCKDEFINITION_LIST") + PermissionBuildingBlockDefinitionSave = WorkspacePermissions.Entry("BUILDINGBLOCKDEFINITION_SAVE") + + PermissionBuildingBlockRunnerDelete = WorkspacePermissions.Entry("BUILDINGBLOCKRUNNER_DELETE") + PermissionBuildingBlockRunnerList = WorkspacePermissions.Entry("BUILDINGBLOCKRUNNER_LIST") + PermissionBuildingBlockRunnerSave = WorkspacePermissions.Entry("BUILDINGBLOCKRUNNER_SAVE") + + PermissionBuildingBlockDelete = WorkspacePermissions.Entry("BUILDINGBLOCK_DELETE") + PermissionBuildingBlockList = WorkspacePermissions.Entry("BUILDINGBLOCK_LIST") + PermissionBuildingBlockSave = WorkspacePermissions.Entry("BUILDINGBLOCK_SAVE") + + PermissionCommunicationDefinitionDelete = WorkspacePermissions.Entry("COMMUNICATIONDEFINITION_DELETE") + PermissionCommunicationDefinitionList = WorkspacePermissions.Entry("COMMUNICATIONDEFINITION_LIST") + PermissionCommunicationDefinitionSave = WorkspacePermissions.Entry("COMMUNICATIONDEFINITION_SAVE") + + PermissionCommunicationDelete = WorkspacePermissions.Entry("COMMUNICATION_DELETE") + PermissionCommunicationList = WorkspacePermissions.Entry("COMMUNICATION_LIST") + PermissionCommunicationSave = WorkspacePermissions.Entry("COMMUNICATION_SAVE") + + PermissionEventLogList = WorkspacePermissions.Entry("EVENTLOG_LIST") + + PermissionIntegrationDelete = WorkspacePermissions.Entry("INTEGRATION_DELETE") + PermissionIntegrationList = WorkspacePermissions.Entry("INTEGRATION_LIST") + PermissionIntegrationSave = WorkspacePermissions.Entry("INTEGRATION_SAVE") + + PermissionLandingZoneDelete = WorkspacePermissions.Entry("LANDINGZONE_DELETE") + PermissionLandingZoneList = WorkspacePermissions.Entry("LANDINGZONE_LIST") + PermissionLandingZoneSave = WorkspacePermissions.Entry("LANDINGZONE_SAVE") + + PermissionManagedBuildingBlockRunSourceSave = WorkspacePermissions.Entry("MANAGED_BUILDINGBLOCKRUNSOURCE_SAVE") + PermissionManagedBuildingBlockRunList = WorkspacePermissions.Entry("MANAGED_BUILDINGBLOCKRUN_LIST") + PermissionManagedBuildingBlockRunSave = WorkspacePermissions.Entry("MANAGED_BUILDINGBLOCKRUN_SAVE") + PermissionManagedBuildingBlockList = WorkspacePermissions.Entry("MANAGED_BUILDINGBLOCK_LIST") + PermissionManagedTenantImport = WorkspacePermissions.Entry("MANAGED_TENANT_IMPORT") + + PermissionPaymentMethodList = WorkspacePermissions.Entry("PAYMENTMETHOD_LIST") + + PermissionPlatformInstanceDelete = WorkspacePermissions.Entry("PLATFORMINSTANCE_DELETE") + PermissionPlatformInstanceList = WorkspacePermissions.Entry("PLATFORMINSTANCE_LIST") + PermissionPlatformInstanceSave = WorkspacePermissions.Entry("PLATFORMINSTANCE_SAVE") + + PermissionProjectPrincipalRoleDelete = WorkspacePermissions.Entry("PROJECTPRINCIPALROLE_DELETE") + PermissionProjectPrincipalRoleList = WorkspacePermissions.Entry("PROJECTPRINCIPALROLE_LIST") + PermissionProjectPrincipalRoleSave = WorkspacePermissions.Entry("PROJECTPRINCIPALROLE_SAVE") + + PermissionProjectDelete = WorkspacePermissions.Entry("PROJECT_DELETE") + PermissionProjectList = WorkspacePermissions.Entry("PROJECT_LIST") + PermissionProjectSave = WorkspacePermissions.Entry("PROJECT_SAVE") + + PermissionServiceInstanceDelete = WorkspacePermissions.Entry("SERVICEINSTANCE_DELETE") + PermissionServiceInstanceList = WorkspacePermissions.Entry("SERVICEINSTANCE_LIST") + PermissionServiceInstanceSave = WorkspacePermissions.Entry("SERVICEINSTANCE_SAVE") + + PermissionTenantDelete = WorkspacePermissions.Entry("TENANT_DELETE") + PermissionTenantList = WorkspacePermissions.Entry("TENANT_LIST") + PermissionTenantSave = WorkspacePermissions.Entry("TENANT_SAVE") + + PermissionTfStateDelete = WorkspacePermissions.Entry("TFSTATE_DELETE") + PermissionTfStateList = WorkspacePermissions.Entry("TFSTATE_LIST") + PermissionTfStateSave = WorkspacePermissions.Entry("TFSTATE_SAVE") + + PermissionWorkspacePrincipalBindingDelete = WorkspacePermissions.Entry("WORKSPACEPRINCIPALBINDING_DELETE") + PermissionWorkspacePrincipalBindingList = WorkspacePermissions.Entry("WORKSPACEPRINCIPALBINDING_LIST") + PermissionWorkspacePrincipalBindingSave = WorkspacePermissions.Entry("WORKSPACEPRINCIPALBINDING_SAVE") + + PermissionWorkspaceUserGroupList = WorkspacePermissions.Entry("WORKSPACEUSERGROUP_LIST") + + PermissionWorkspaceDelete = WorkspacePermissions.Entry("WORKSPACE_DELETE") + PermissionWorkspaceList = WorkspacePermissions.Entry("WORKSPACE_LIST") + PermissionWorkspaceSave = WorkspacePermissions.Entry("WORKSPACE_SAVE") +) diff --git a/buildingblock_definition_version.go b/buildingblock_definition_version.go index 4ba86238..02511e08 100644 --- a/buildingblock_definition_version.go +++ b/buildingblock_definition_version.go @@ -156,6 +156,7 @@ type MeshBuildingBlockDefinitionVersionSpec struct { BuildingBlockDefinitionRef *BuildingBlockDefinitionRef `json:"buildingBlockDefinitionRef" tfsdk:"-"` OnlyApplyOncePerTenant bool `json:"onlyApplyOncePerTenant" tfsdk:"only_apply_once_per_tenant"` DeletionMode BuildingBlockDeletionMode `json:"deletionMode" tfsdk:"deletion_mode"` + Permissions []ApiPermission `json:"permissions,omitempty" tfsdk:"permissions"` Outputs map[string]MeshBuildingBlockDefinitionOutput `json:"outputs" tfsdk:"outputs"` VersionNumber *int64 `json:"versionNumber,omitempty" tfsdk:"version_number"` State *MeshBuildingBlockDefinitionVersionState `json:"state,omitempty" tfsdk:"state"` From 25726ac7078bf4acdf57d5653ffe996c5953c176 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Tue, 17 Feb 2026 13:50:03 +0100 Subject: [PATCH 109/215] fix: missing/wrong azure config fields --- platform_config_azure.go | 1 + 1 file changed, 1 insertion(+) diff --git a/platform_config_azure.go b/platform_config_azure.go index c805a2ed..f3c411e0 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -10,6 +10,7 @@ type AzurePlatformConfig struct { type AzureReplicationConfig struct { ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + UpdateSubscriptionName bool `json:"updateSubscriptionName" tfsdk:"update_subscription_name"` Provisioning *AzureSubscriptionProvisioningConfig `json:"provisioning,omitempty" tfsdk:"provisioning"` B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` SubscriptionNamePattern string `json:"subscriptionNamePattern" tfsdk:"subscription_name_pattern"` From 92797c7aa56097c656c1d4d8911c653c663059e6 Mon Sep 17 00:00:00 2001 From: Stefan Tomm Date: Tue, 17 Feb 2026 11:07:26 +0100 Subject: [PATCH 110/215] feat: add parameter support to ServiceInstance resource --- service_instance.go | 23 +++++++++++++++-------- types/clienttypes.go | 2 ++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/service_instance.go b/service_instance.go index 58b6f2fb..3312b612 100644 --- a/service_instance.go +++ b/service_instance.go @@ -4,6 +4,7 @@ import ( "context" "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/terraform-provider-meshstack/client/types" ) type MeshServiceInstance struct { @@ -21,13 +22,19 @@ type MeshServiceInstanceMetadata struct { } type MeshServiceInstanceSpec struct { - Creator string `json:"creator" tfsdk:"creator"` - DisplayName string `json:"displayName" tfsdk:"display_name"` - PlanId string `json:"planId" tfsdk:"plan_id"` - ServiceId string `json:"serviceId" tfsdk:"service_id"` + Creator string `json:"creator" tfsdk:"creator"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + PlanId string `json:"planId" tfsdk:"plan_id"` + ServiceId string `json:"serviceId" tfsdk:"service_id"` + Parameters map[string]types.Any `json:"parameters" tfsdk:"parameters"` } -type MeshServiceInstanceClient struct { +type MeshServiceInstanceClient interface { + Read(ctx context.Context, instanceId string) (*MeshServiceInstance, error) + List(ctx context.Context, filter *MeshServiceInstanceFilter) ([]MeshServiceInstance, error) +} + +type meshServiceInstanceClient struct { meshObject internal.MeshObjectClient[MeshServiceInstance] } @@ -40,14 +47,14 @@ type MeshServiceInstanceFilter struct { } func newServiceInstanceClient(ctx context.Context, httpClient *internal.HttpClient) MeshServiceInstanceClient { - return MeshServiceInstanceClient{internal.NewMeshObjectClient[MeshServiceInstance](ctx, httpClient, "v2")} + return meshServiceInstanceClient{internal.NewMeshObjectClient[MeshServiceInstance](ctx, httpClient, "v2")} } -func (c MeshServiceInstanceClient) Read(ctx context.Context, instanceId string) (*MeshServiceInstance, error) { +func (c meshServiceInstanceClient) Read(ctx context.Context, instanceId string) (*MeshServiceInstance, error) { return c.meshObject.Get(ctx, instanceId) } -func (c MeshServiceInstanceClient) List(ctx context.Context, filter *MeshServiceInstanceFilter) ([]MeshServiceInstance, error) { +func (c meshServiceInstanceClient) List(ctx context.Context, filter *MeshServiceInstanceFilter) ([]MeshServiceInstance, error) { var options []internal.RequestOption if filter != nil { if filter.WorkspaceIdentifier != nil { diff --git a/types/clienttypes.go b/types/clienttypes.go index 50c7a87d..f1415cba 100644 --- a/types/clienttypes.go +++ b/types/clienttypes.go @@ -13,4 +13,6 @@ type ( } SecretOrAny = variant.Variant[Secret, any] + + Any any ) From 79b2afda6fd6014cdff7171ccd792d55462db961 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 17 Feb 2026 20:32:41 +0100 Subject: [PATCH 111/215] fix: make BBD notification_subscribers a set and handle removal of invalid usernames by backend --- buildingblock_definition.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/buildingblock_definition.go b/buildingblock_definition.go index 4323804f..b601eaea 100644 --- a/buildingblock_definition.go +++ b/buildingblock_definition.go @@ -4,6 +4,7 @@ import ( "context" "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/terraform-provider-meshstack/client/types" "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" ) @@ -33,8 +34,8 @@ type MeshBuildingBlockDefinitionSpec struct { SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` // NotificationSubscribers can also specify emails with prefix 'email:', so it's not only usernames (as the JSON field name suggests)! - NotificationSubscribers []string `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` - Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` + NotificationSubscribers []types.SetElem `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` + Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` // SupportedPlatforms are currently platform types only. Specifying single platforms is currently unsupported. // Have this list of string with a dedicated type, to convert it to/from Platform Type refs. SupportedPlatforms []BuildingBlockDefinitionSupportedPlatform `json:"supportedPlatforms" tfsdk:"supported_platforms"` From 3edb184c7236a2a57a47592c656738784203ac1c Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Tue, 17 Feb 2026 12:28:14 +0100 Subject: [PATCH 112/215] feat: add `owned_by_workspace` for meshstack_location CU-86c88kv75 --- client.go | 2 +- location.go | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/client.go b/client.go index e47a1f48..dce111f9 100644 --- a/client.go +++ b/client.go @@ -14,7 +14,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.2.0") +var MinMeshStackVersion = version.MustParse("2026.7.0") type Client struct { BuildingBlock MeshBuildingBlockClient diff --git a/location.go b/location.go index 13bbee15..5bcbb20c 100644 --- a/location.go +++ b/location.go @@ -14,8 +14,9 @@ type MeshLocation struct { } type MeshLocationMetadata struct { - Name string `json:"name" tfsdk:"name"` - Uuid string `json:"uuid" tfsdk:"uuid"` + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Uuid string `json:"uuid" tfsdk:"uuid"` } type MeshLocationSpec struct { @@ -34,7 +35,8 @@ type MeshLocationCreate struct { } type MeshLocationCreateMetadata struct { - Name string `json:"name" tfsdk:"name"` + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } type MeshLocationClient interface { From 09bfce5039a936f7b4b36de3d83d3c98929d4fea Mon Sep 17 00:00:00 2001 From: Stefan Tomm Date: Mon, 16 Feb 2026 11:55:25 +0100 Subject: [PATCH 113/215] feat: make meshPlatform related resources GA --- integration.go | 2 +- landingzone.go | 2 +- location.go | 2 +- platform.go | 2 +- platform_type.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/integration.go b/integration.go index 80e583ab..5983c758 100644 --- a/integration.go +++ b/integration.go @@ -59,7 +59,7 @@ type meshIntegrationClientImpl struct { } func newIntegrationClient(ctx context.Context, httpClient *internal.HttpClient) MeshIntegrationClient { - return &meshIntegrationClientImpl{internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1-preview")} + return &meshIntegrationClientImpl{internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1")} } func (c meshIntegrationClientImpl) Create(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) { diff --git a/landingzone.go b/landingzone.go index 0deb06d9..42544335 100644 --- a/landingzone.go +++ b/landingzone.go @@ -71,7 +71,7 @@ type MeshLandingZoneClient struct { } func newLandingZoneClient(ctx context.Context, httpClient *internal.HttpClient) MeshLandingZoneClient { - return MeshLandingZoneClient{internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1-preview")} + return MeshLandingZoneClient{internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1")} } func (c MeshLandingZoneClient) Read(ctx context.Context, name string) (*MeshLandingZone, error) { diff --git a/location.go b/location.go index 5bcbb20c..57ab04cc 100644 --- a/location.go +++ b/location.go @@ -51,7 +51,7 @@ type meshLocationClient struct { } func newLocationClient(ctx context.Context, httpClient *internal.HttpClient) MeshLocationClient { - return meshLocationClient{internal.NewMeshObjectClient[MeshLocation](ctx, httpClient, "v1-preview")} + return meshLocationClient{internal.NewMeshObjectClient[MeshLocation](ctx, httpClient, "v1")} } func (c meshLocationClient) Read(ctx context.Context, name string) (*MeshLocation, error) { diff --git a/platform.go b/platform.go index 34a7d899..4686d951 100644 --- a/platform.go +++ b/platform.go @@ -115,7 +115,7 @@ type meshPlatformClient struct { } func newPlatformClient(ctx context.Context, httpClient *internal.HttpClient) MeshPlatformClient { - return meshPlatformClient{internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2-preview")} + return meshPlatformClient{internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2")} } func (c meshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatform, error) { diff --git a/platform_type.go b/platform_type.go index 3e93e091..c97024ba 100644 --- a/platform_type.go +++ b/platform_type.go @@ -60,7 +60,7 @@ type meshPlatformTypeClient struct { } func newPlatformTypeClient(ctx context.Context, httpClient *internal.HttpClient) MeshPlatformTypeClient { - return meshPlatformTypeClient{internal.NewMeshObjectClient[MeshPlatformType](ctx, httpClient, "v1-preview")} + return meshPlatformTypeClient{internal.NewMeshObjectClient[MeshPlatformType](ctx, httpClient, "v1")} } func (c meshPlatformTypeClient) Create(ctx context.Context, platformType *MeshPlatformTypeCreate) (*MeshPlatformType, error) { From cb7aa2384ed211c8f879b26678dd1f210580998d Mon Sep 17 00:00:00 2001 From: Stefan Tomm Date: Wed, 18 Feb 2026 10:14:45 +0100 Subject: [PATCH 114/215] refactor: remove apiVersion and kind from platform related terraform models --- landingzone.go | 6 +++--- location.go | 4 ++-- platform.go | 8 ++++---- platform_type.go | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/landingzone.go b/landingzone.go index 42544335..1e8e33e1 100644 --- a/landingzone.go +++ b/landingzone.go @@ -7,8 +7,8 @@ import ( ) type MeshLandingZone struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` + Kind string `json:"kind" tfsdk:"-"` Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` Status MeshLandingZoneStatus `json:"status" tfsdk:"status"` @@ -61,7 +61,7 @@ type MeshLandingZoneQuota struct { } type MeshLandingZoneCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` } diff --git a/location.go b/location.go index 57ab04cc..9a25b333 100644 --- a/location.go +++ b/location.go @@ -7,7 +7,7 @@ import ( ) type MeshLocation struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` Metadata MeshLocationMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` Status MeshLocationStatus `json:"status" tfsdk:"status"` @@ -29,7 +29,7 @@ type MeshLocationStatus struct { } type MeshLocationCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` Metadata MeshLocationCreateMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` } diff --git a/platform.go b/platform.go index 4686d951..ed42e961 100644 --- a/platform.go +++ b/platform.go @@ -7,8 +7,8 @@ import ( ) type MeshPlatform struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` + Kind string `json:"kind" tfsdk:"-"` Metadata MeshPlatformMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` } @@ -66,7 +66,7 @@ type PlatformConfig struct { } type MeshPlatformCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` Metadata MeshPlatformCreateMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` } @@ -77,7 +77,7 @@ type MeshPlatformCreateMetadata struct { } type MeshPlatformUpdate struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` Metadata MeshPlatformUpdateMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` } diff --git a/platform_type.go b/platform_type.go index c97024ba..29df7a2c 100644 --- a/platform_type.go +++ b/platform_type.go @@ -7,8 +7,8 @@ import ( ) type MeshPlatformType struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` + Kind string `json:"kind" tfsdk:"-"` Metadata MeshPlatformTypeMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` Status MeshPlatformTypeStatus `json:"status" tfsdk:"status"` @@ -36,8 +36,8 @@ type MeshPlatformTypeSpec struct { } type MeshPlatformTypeCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` + ApiVersion string `json:"apiVersion" tfsdk:"-"` + Kind string `json:"kind" tfsdk:"-"` Metadata MeshPlatformTypeCreateMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` } From 415725fcc059fa8683a65e885dc2bbc85e382d41 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 18 Feb 2026 19:40:51 +0100 Subject: [PATCH 115/215] feat: support write-only ephemeral secrets in meshstack_platform resource --- platform.go | 74 +++++++++++++++------------------------- platform_config_azure.go | 2 +- 2 files changed, 29 insertions(+), 47 deletions(-) diff --git a/platform.go b/platform.go index ed42e961..2393c970 100644 --- a/platform.go +++ b/platform.go @@ -4,6 +4,7 @@ import ( "context" "github.com/meshcloud/terraform-provider-meshstack/client/internal" + clientTypes "github.com/meshcloud/terraform-provider-meshstack/client/types" ) type MeshPlatform struct { @@ -14,30 +15,30 @@ type MeshPlatform struct { } type MeshPlatformMetadata struct { - Name string `json:"name" tfsdk:"name"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - Uuid string `json:"uuid" tfsdk:"uuid"` + Name string `json:"name" tfsdk:"name"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` } type MeshPlatformSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Description string `json:"description" tfsdk:"description"` - Endpoint string `json:"endpoint" tfsdk:"endpoint"` - SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` - DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` - LocationRef LocationRef `json:"locationRef" tfsdk:"location_ref"` - ContributingWorkspaces []string `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` - Availability PlatformAvailability `json:"availability" tfsdk:"availability"` - Config PlatformConfig `json:"config" tfsdk:"config"` - QuotaDefinitions []QuotaDefinition `json:"quotaDefinitions" tfsdk:"quota_definitions"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + Endpoint string `json:"endpoint" tfsdk:"endpoint"` + SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` + DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + LocationRef LocationRef `json:"locationRef" tfsdk:"location_ref"` + ContributingWorkspaces []clientTypes.SetElem `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` + Availability PlatformAvailability `json:"availability" tfsdk:"availability"` + Config PlatformConfig `json:"config" tfsdk:"config"` + QuotaDefinitions []QuotaDefinition `json:"quotaDefinitions" tfsdk:"quota_definitions"` } type QuotaDefinition struct { QuotaKey string `json:"quotaKey" tfsdk:"quota_key"` - MinValue int `json:"minValue" tfsdk:"min_value"` - MaxValue int `json:"maxValue" tfsdk:"max_value"` + MinValue int64 `json:"minValue" tfsdk:"min_value"` + MaxValue int64 `json:"maxValue" tfsdk:"max_value"` Unit string `json:"unit" tfsdk:"unit"` - AutoApprovalThreshold int `json:"autoApprovalThreshold" tfsdk:"auto_approval_threshold"` + AutoApprovalThreshold int64 `json:"autoApprovalThreshold" tfsdk:"auto_approval_threshold"` Description string `json:"description" tfsdk:"description"` Label string `json:"label" tfsdk:"label"` } @@ -48,9 +49,9 @@ type LocationRef struct { } type PlatformAvailability struct { - Restriction string `json:"restriction" tfsdk:"restriction"` - PublicationState string `json:"publicationState" tfsdk:"publication_state"` - RestrictedToWorkspaces []string `json:"restrictedToWorkspaces,omitempty" tfsdk:"restricted_to_workspaces"` + Restriction string `json:"restriction" tfsdk:"restriction"` + PublicationState string `json:"publicationState" tfsdk:"publication_state"` + RestrictedToWorkspaces []clientTypes.SetElem `json:"restrictedToWorkspaces,omitempty" tfsdk:"restricted_to_workspaces"` } type PlatformConfig struct { @@ -65,29 +66,6 @@ type PlatformConfig struct { OpenShift *OpenShiftPlatformConfig `json:"openshift,omitempty" tfsdk:"openshift"` } -type MeshPlatformCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Metadata MeshPlatformCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` -} - -type MeshPlatformCreateMetadata struct { - Name string `json:"name" tfsdk:"name"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` -} - -type MeshPlatformUpdate struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Metadata MeshPlatformUpdateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` -} - -type MeshPlatformUpdateMetadata struct { - Name string `json:"name" tfsdk:"name"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - Uuid string `json:"uuid" tfsdk:"uuid"` -} - type MeshPlatformMeteringProcessingConfig struct { CompactTimelinesAfterDays int64 `json:"compactTimelinesAfterDays" tfsdk:"compact_timelines_after_days"` DeleteRawDataAfterDays int64 `json:"deleteRawDataAfterDays" tfsdk:"delete_raw_data_after_days"` @@ -105,8 +83,8 @@ type TagMapper struct { type MeshPlatformClient interface { Read(ctx context.Context, uuid string) (*MeshPlatform, error) - Create(ctx context.Context, platform *MeshPlatformCreate) (*MeshPlatform, error) - Update(ctx context.Context, uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) + Create(ctx context.Context, platform MeshPlatform) (*MeshPlatform, error) + Update(ctx context.Context, uuid string, platform MeshPlatform) (*MeshPlatform, error) Delete(ctx context.Context, uuid string) error } @@ -122,11 +100,15 @@ func (c meshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatfor return c.meshObject.Get(ctx, uuid) } -func (c meshPlatformClient) Create(ctx context.Context, platform *MeshPlatformCreate) (*MeshPlatform, error) { +func (c meshPlatformClient) Create(ctx context.Context, platform MeshPlatform) (*MeshPlatform, error) { + platform.Kind = c.meshObject.Kind + platform.ApiVersion = c.meshObject.ApiVersion return c.meshObject.Post(ctx, platform) } -func (c meshPlatformClient) Update(ctx context.Context, uuid string, platform *MeshPlatformUpdate) (*MeshPlatform, error) { +func (c meshPlatformClient) Update(ctx context.Context, uuid string, platform MeshPlatform) (*MeshPlatform, error) { + platform.Kind = c.meshObject.Kind + platform.ApiVersion = c.meshObject.ApiVersion return c.meshObject.Put(ctx, uuid, platform) } diff --git a/platform_config_azure.go b/platform_config_azure.go index f3c411e0..30c96201 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -42,7 +42,7 @@ type AzureGraphApiCredentials struct { } type AzureSubscriptionProvisioningConfig struct { - SubscriptionOwnerObjectIds []string `json:"subscriptionOwnerObjectIds" tfsdk:"subscription_owner_object_ids"` + SubscriptionOwnerObjectIds []types.SetElem `json:"subscriptionOwnerObjectIds" tfsdk:"subscription_owner_object_ids"` EnterpriseEnrollment *AzureEnterpriseEnrollmentConfig `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` CustomerAgreement *AzureCustomerAgreementConfig `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` PreProvisioned *AzurePreProvisionedSubscriptionConfig `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` From 7b0b05712ca79da76922a5607a9a7b2a671f6470 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 19 Feb 2026 11:58:45 +0100 Subject: [PATCH 116/215] refactor: rename to StringSetElem --- buildingblock_definition.go | 4 ++-- buildingblock_definition_version.go | 14 +++++++------- platform.go | 26 +++++++++++++------------- platform_config_azure.go | 2 +- types/clienttypes.go | 2 +- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/buildingblock_definition.go b/buildingblock_definition.go index b601eaea..124a0120 100644 --- a/buildingblock_definition.go +++ b/buildingblock_definition.go @@ -34,8 +34,8 @@ type MeshBuildingBlockDefinitionSpec struct { SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` // NotificationSubscribers can also specify emails with prefix 'email:', so it's not only usernames (as the JSON field name suggests)! - NotificationSubscribers []types.SetElem `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` - Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` + NotificationSubscribers []types.StringSetElem `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` + Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` // SupportedPlatforms are currently platform types only. Specifying single platforms is currently unsupported. // Have this list of string with a dedicated type, to convert it to/from Platform Type refs. SupportedPlatforms []BuildingBlockDefinitionSupportedPlatform `json:"supportedPlatforms" tfsdk:"supported_platforms"` diff --git a/buildingblock_definition_version.go b/buildingblock_definition_version.go index 02511e08..015da2fd 100644 --- a/buildingblock_definition_version.go +++ b/buildingblock_definition_version.go @@ -98,13 +98,13 @@ type MeshBuildingBlockDefinitionInput struct { // Otherwise, the [types.Variant] is of [types.Any] (case [types.Variant.Y]). // As this is a fallback detection when JSON (un)marshaling, // types.Any must go second as [types.Variant] intentionally prefers X over Y. - Argument types.SecretOrAny `json:"argument,omitempty" tfsdk:"argument"` - DefaultValue types.SecretOrAny `json:"defaultValue,omitempty" tfsdk:"default_value"` - UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` - SelectableValues []types.SetElem `json:"selectableValues,omitempty" tfsdk:"selectable_values"` - Description *string `json:"description,omitempty" tfsdk:"description"` - ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` - ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` + Argument types.SecretOrAny `json:"argument,omitempty" tfsdk:"argument"` + DefaultValue types.SecretOrAny `json:"defaultValue,omitempty" tfsdk:"default_value"` + UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` + SelectableValues []types.StringSetElem `json:"selectableValues,omitempty" tfsdk:"selectable_values"` + Description *string `json:"description,omitempty" tfsdk:"description"` + ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` + ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` } func (m *MeshBuildingBlockDefinitionInput) UnmarshalJSON(bytes []byte) error { diff --git a/platform.go b/platform.go index 2393c970..d4f3027d 100644 --- a/platform.go +++ b/platform.go @@ -21,16 +21,16 @@ type MeshPlatformMetadata struct { } type MeshPlatformSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Description string `json:"description" tfsdk:"description"` - Endpoint string `json:"endpoint" tfsdk:"endpoint"` - SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` - DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` - LocationRef LocationRef `json:"locationRef" tfsdk:"location_ref"` - ContributingWorkspaces []clientTypes.SetElem `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` - Availability PlatformAvailability `json:"availability" tfsdk:"availability"` - Config PlatformConfig `json:"config" tfsdk:"config"` - QuotaDefinitions []QuotaDefinition `json:"quotaDefinitions" tfsdk:"quota_definitions"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + Endpoint string `json:"endpoint" tfsdk:"endpoint"` + SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` + DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + LocationRef LocationRef `json:"locationRef" tfsdk:"location_ref"` + ContributingWorkspaces []clientTypes.StringSetElem `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` + Availability PlatformAvailability `json:"availability" tfsdk:"availability"` + Config PlatformConfig `json:"config" tfsdk:"config"` + QuotaDefinitions []QuotaDefinition `json:"quotaDefinitions" tfsdk:"quota_definitions"` } type QuotaDefinition struct { @@ -49,9 +49,9 @@ type LocationRef struct { } type PlatformAvailability struct { - Restriction string `json:"restriction" tfsdk:"restriction"` - PublicationState string `json:"publicationState" tfsdk:"publication_state"` - RestrictedToWorkspaces []clientTypes.SetElem `json:"restrictedToWorkspaces,omitempty" tfsdk:"restricted_to_workspaces"` + Restriction string `json:"restriction" tfsdk:"restriction"` + PublicationState string `json:"publicationState" tfsdk:"publication_state"` + RestrictedToWorkspaces []clientTypes.StringSetElem `json:"restrictedToWorkspaces,omitempty" tfsdk:"restricted_to_workspaces"` } type PlatformConfig struct { diff --git a/platform_config_azure.go b/platform_config_azure.go index 30c96201..10c046e9 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -42,7 +42,7 @@ type AzureGraphApiCredentials struct { } type AzureSubscriptionProvisioningConfig struct { - SubscriptionOwnerObjectIds []types.SetElem `json:"subscriptionOwnerObjectIds" tfsdk:"subscription_owner_object_ids"` + SubscriptionOwnerObjectIds []types.StringSetElem `json:"subscriptionOwnerObjectIds" tfsdk:"subscription_owner_object_ids"` EnterpriseEnrollment *AzureEnterpriseEnrollmentConfig `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` CustomerAgreement *AzureCustomerAgreementConfig `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` PreProvisioned *AzurePreProvisionedSubscriptionConfig `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` diff --git a/types/clienttypes.go b/types/clienttypes.go index f1415cba..957a3bb5 100644 --- a/types/clienttypes.go +++ b/types/clienttypes.go @@ -5,7 +5,7 @@ import ( ) type ( - SetElem string + StringSetElem string Secret struct { Plaintext *string `json:"plaintext,omitempty" tfsdk:"plaintext"` From a54b425927173eff97b56d741610776c5cf66897 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 24 Feb 2026 11:51:14 +0100 Subject: [PATCH 117/215] fix: properly handle null/empty in BBD, simplify set handling in generic.ValueFrom --- buildingblock_definition.go | 6 +++--- buildingblock_definition_version.go | 18 ++++++++-------- platform.go | 32 ++++++++++++++--------------- platform_config_azure.go | 4 ++-- platform_config_openshift.go | 16 ++++++++------- types/clienttypes.go | 24 +++++++++++++++++++++- types/clienttypes_test.go | 30 +++++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 38 deletions(-) diff --git a/buildingblock_definition.go b/buildingblock_definition.go index 124a0120..605bbd2c 100644 --- a/buildingblock_definition.go +++ b/buildingblock_definition.go @@ -34,11 +34,11 @@ type MeshBuildingBlockDefinitionSpec struct { SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` // NotificationSubscribers can also specify emails with prefix 'email:', so it's not only usernames (as the JSON field name suggests)! - NotificationSubscribers []types.StringSetElem `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` - Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` + NotificationSubscribers types.Set[string] `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` + Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` // SupportedPlatforms are currently platform types only. Specifying single platforms is currently unsupported. // Have this list of string with a dedicated type, to convert it to/from Platform Type refs. - SupportedPlatforms []BuildingBlockDefinitionSupportedPlatform `json:"supportedPlatforms" tfsdk:"supported_platforms"` + SupportedPlatforms types.Set[BuildingBlockDefinitionSupportedPlatform] `json:"supportedPlatforms" tfsdk:"supported_platforms"` } type MeshBuildingBlockDefinitionStatusVersion struct { diff --git a/buildingblock_definition_version.go b/buildingblock_definition_version.go index 015da2fd..dd3d6277 100644 --- a/buildingblock_definition_version.go +++ b/buildingblock_definition_version.go @@ -98,13 +98,13 @@ type MeshBuildingBlockDefinitionInput struct { // Otherwise, the [types.Variant] is of [types.Any] (case [types.Variant.Y]). // As this is a fallback detection when JSON (un)marshaling, // types.Any must go second as [types.Variant] intentionally prefers X over Y. - Argument types.SecretOrAny `json:"argument,omitempty" tfsdk:"argument"` - DefaultValue types.SecretOrAny `json:"defaultValue,omitempty" tfsdk:"default_value"` - UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` - SelectableValues []types.StringSetElem `json:"selectableValues,omitempty" tfsdk:"selectable_values"` - Description *string `json:"description,omitempty" tfsdk:"description"` - ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` - ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` + Argument types.SecretOrAny `json:"argument,omitempty" tfsdk:"argument"` + DefaultValue types.SecretOrAny `json:"defaultValue,omitempty" tfsdk:"default_value"` + UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` + SelectableValues types.Set[string] `json:"selectableValues,omitempty" tfsdk:"selectable_values"` + Description *string `json:"description,omitempty" tfsdk:"description"` + ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` + ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` } func (m *MeshBuildingBlockDefinitionInput) UnmarshalJSON(bytes []byte) error { @@ -156,12 +156,12 @@ type MeshBuildingBlockDefinitionVersionSpec struct { BuildingBlockDefinitionRef *BuildingBlockDefinitionRef `json:"buildingBlockDefinitionRef" tfsdk:"-"` OnlyApplyOncePerTenant bool `json:"onlyApplyOncePerTenant" tfsdk:"only_apply_once_per_tenant"` DeletionMode BuildingBlockDeletionMode `json:"deletionMode" tfsdk:"deletion_mode"` - Permissions []ApiPermission `json:"permissions,omitempty" tfsdk:"permissions"` + Permissions types.Set[ApiPermission] `json:"permissions,omitempty" tfsdk:"permissions"` Outputs map[string]MeshBuildingBlockDefinitionOutput `json:"outputs" tfsdk:"outputs"` VersionNumber *int64 `json:"versionNumber,omitempty" tfsdk:"version_number"` State *MeshBuildingBlockDefinitionVersionState `json:"state,omitempty" tfsdk:"state"` RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` - DependencyDefinitionUUIDs []BuildingBlockDependencyRef `json:"dependencyDefinitionUuids,omitempty" tfsdk:"dependency_refs"` + DependencyDefinitionUUIDs types.Set[BuildingBlockDependencyRef] `json:"dependencyDefinitionUuids,omitempty" tfsdk:"dependency_refs"` Implementation MeshBuildingBlockDefinitionImplementation `json:"implementation" tfsdk:"implementation"` Inputs map[string]*MeshBuildingBlockDefinitionInput `json:"inputs" tfsdk:"inputs"` } diff --git a/platform.go b/platform.go index d4f3027d..cc7133c9 100644 --- a/platform.go +++ b/platform.go @@ -4,7 +4,7 @@ import ( "context" "github.com/meshcloud/terraform-provider-meshstack/client/internal" - clientTypes "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/terraform-provider-meshstack/client/types" ) type MeshPlatform struct { @@ -21,16 +21,16 @@ type MeshPlatformMetadata struct { } type MeshPlatformSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Description string `json:"description" tfsdk:"description"` - Endpoint string `json:"endpoint" tfsdk:"endpoint"` - SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` - DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` - LocationRef LocationRef `json:"locationRef" tfsdk:"location_ref"` - ContributingWorkspaces []clientTypes.StringSetElem `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` - Availability PlatformAvailability `json:"availability" tfsdk:"availability"` - Config PlatformConfig `json:"config" tfsdk:"config"` - QuotaDefinitions []QuotaDefinition `json:"quotaDefinitions" tfsdk:"quota_definitions"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + Endpoint string `json:"endpoint" tfsdk:"endpoint"` + SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` + DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + LocationRef LocationRef `json:"locationRef" tfsdk:"location_ref"` + ContributingWorkspaces types.Set[string] `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` + Availability PlatformAvailability `json:"availability" tfsdk:"availability"` + Config PlatformConfig `json:"config" tfsdk:"config"` + QuotaDefinitions types.Set[QuotaDefinition] `json:"quotaDefinitions" tfsdk:"quota_definitions"` } type QuotaDefinition struct { @@ -49,9 +49,9 @@ type LocationRef struct { } type PlatformAvailability struct { - Restriction string `json:"restriction" tfsdk:"restriction"` - PublicationState string `json:"publicationState" tfsdk:"publication_state"` - RestrictedToWorkspaces []clientTypes.StringSetElem `json:"restrictedToWorkspaces,omitempty" tfsdk:"restricted_to_workspaces"` + Restriction string `json:"restriction" tfsdk:"restriction"` + PublicationState string `json:"publicationState" tfsdk:"publication_state"` + RestrictedToWorkspaces types.Set[string] `json:"restrictedToWorkspaces,omitempty" tfsdk:"restricted_to_workspaces"` } type PlatformConfig struct { @@ -72,8 +72,8 @@ type MeshPlatformMeteringProcessingConfig struct { } type MeshTenantTags struct { - NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` - TagMappers []TagMapper `json:"tagMappers" tfsdk:"tag_mappers"` + NamespacePrefix string `json:"namespacePrefix" tfsdk:"namespace_prefix"` + TagMappers types.Set[TagMapper] `json:"tagMappers" tfsdk:"tag_mappers"` } type TagMapper struct { diff --git a/platform_config_azure.go b/platform_config_azure.go index 10c046e9..966e1bf2 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -17,7 +17,7 @@ type AzureReplicationConfig struct { GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` BlueprintServicePrincipal string `json:"blueprintServicePrincipal" tfsdk:"blueprint_service_principal"` BlueprintLocation string `json:"blueprintLocation" tfsdk:"blueprint_location"` - AzureRoleMappings []AzureRoleMapping `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` + AzureRoleMappings types.Set[AzureRoleMapping] `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` @@ -42,7 +42,7 @@ type AzureGraphApiCredentials struct { } type AzureSubscriptionProvisioningConfig struct { - SubscriptionOwnerObjectIds []types.StringSetElem `json:"subscriptionOwnerObjectIds" tfsdk:"subscription_owner_object_ids"` + SubscriptionOwnerObjectIds types.Set[string] `json:"subscriptionOwnerObjectIds" tfsdk:"subscription_owner_object_ids"` EnterpriseEnrollment *AzureEnterpriseEnrollmentConfig `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` CustomerAgreement *AzureCustomerAgreementConfig `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` PreProvisioned *AzurePreProvisionedSubscriptionConfig `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` diff --git a/platform_config_openshift.go b/platform_config_openshift.go index 1dcf7222..e974b95d 100644 --- a/platform_config_openshift.go +++ b/platform_config_openshift.go @@ -1,5 +1,7 @@ package client +import "github.com/meshcloud/terraform-provider-meshstack/client/types" + type OpenShiftPlatformConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` @@ -8,13 +10,13 @@ type OpenShiftPlatformConfig struct { } type OpenShiftReplicationConfig struct { - ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` - WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` - ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` - EnableTemplateInstantiation bool `json:"enableTemplateInstantiation" tfsdk:"enable_template_instantiation"` - OpenshiftRoleMappings []OpenShiftPlatformRoleMapping `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` - IdentityProviderName string `json:"identityProviderName" tfsdk:"identity_provider_name"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` + ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` + EnableTemplateInstantiation bool `json:"enableTemplateInstantiation" tfsdk:"enable_template_instantiation"` + OpenshiftRoleMappings types.Set[OpenShiftPlatformRoleMapping] `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` + IdentityProviderName string `json:"identityProviderName" tfsdk:"identity_provider_name"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` } type OpenShiftMeteringConfig struct { diff --git a/types/clienttypes.go b/types/clienttypes.go index 957a3bb5..17589cb5 100644 --- a/types/clienttypes.go +++ b/types/clienttypes.go @@ -1,11 +1,14 @@ package types import ( + "reflect" + "strings" + "github.com/meshcloud/terraform-provider-meshstack/client/types/variant" ) type ( - StringSetElem string + Set[T any] []T Secret struct { Plaintext *string `json:"plaintext,omitempty" tfsdk:"plaintext"` @@ -16,3 +19,22 @@ type ( Any any ) + +// IsSet returns true if the given type uses the generic Set type, ignoring the concrete container type T. +func IsSet(other reflect.Type) bool { + var ( + setType = reflect.TypeFor[Set[any]]() + ) + if other.PkgPath() == setType.PkgPath() { + stripGenerics := func(s string) string { + if startIdx := strings.Index(s, "["); startIdx > 0 { + return s[0 : startIdx-1] + } + return s + } + if stripGenerics(other.Name()) == stripGenerics(setType.Name()) { + return true + } + } + return false +} diff --git a/types/clienttypes_test.go b/types/clienttypes_test.go index cfaeb95a..8c4e7f33 100644 --- a/types/clienttypes_test.go +++ b/types/clienttypes_test.go @@ -2,6 +2,7 @@ package types import ( "encoding/json" + "reflect" "testing" "github.com/stretchr/testify/assert" @@ -45,3 +46,32 @@ func TestSecretOrAny(t *testing.T) { }) } } + +func TestIsSet(t *testing.T) { + type ( + someStruct struct { + A string + } + someString string + someSet Set[someString] + ) + tests := []struct { + name string + t reflect.Type + want bool + }{ + {"bool", reflect.TypeFor[bool](), false}, + {"any", reflect.TypeFor[any](), false}, + {"int", reflect.TypeFor[any](), false}, + {"some set (not supported)", reflect.TypeFor[someSet](), false}, + {"set of string", reflect.TypeFor[Set[string]](), true}, + {"set of int", reflect.TypeFor[Set[string]](), true}, + {"set of struct", reflect.TypeFor[Set[someStruct]](), true}, + {"set of some string", reflect.TypeFor[Set[someString]](), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, IsSet(tt.t), "IsSet(%v)", tt.t) + }) + } +} From 76f4f65c34e1149321742650f6c31918a544e40c Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Mon, 9 Mar 2026 22:22:01 +0100 Subject: [PATCH 118/215] feat: add pre_run_script field to meshstack_building_block_definition and update examples --- buildingblock_definition_version_implementation.go | 1 + 1 file changed, 1 insertion(+) diff --git a/buildingblock_definition_version_implementation.go b/buildingblock_definition_version_implementation.go index f8d169d1..9b67e9f3 100644 --- a/buildingblock_definition_version_implementation.go +++ b/buildingblock_definition_version_implementation.go @@ -35,6 +35,7 @@ type MeshBuildingBlockDefinitionTerraformImplementation struct { SSHKnownHost *MeshBuildingBlockDefinitionSshKnownHost `json:"sshKnownHost,omitempty" tfsdk:"ssh_known_host"` UseMeshHTTPBackendFallback bool `json:"useMeshHttpBackendFallback" tfsdk:"use_mesh_http_backend_fallback"` SSHPrivateKey *types.Secret `json:"sshPrivateKey,omitempty" tfsdk:"ssh_private_key"` + PreRunScript *string `json:"preRunScript,omitempty" tfsdk:"pre_run_script"` } type MeshBuildingBlockDefinitionGitHubWorkflowsImplementation struct { From 65c842a2e05bcc1a5d5edb3f92afde6259f65083 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:04:34 +0000 Subject: [PATCH 119/215] fix: address review comments and CI failures for pre_run_script field Co-authored-by: JohannesRudolph <130103+JohannesRudolph@users.noreply.github.com> --- client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client.go b/client.go index dce111f9..a3c8eef0 100644 --- a/client.go +++ b/client.go @@ -14,7 +14,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.7.0") +var MinMeshStackVersion = version.MustParse("2026.10.0") type Client struct { BuildingBlock MeshBuildingBlockClient From 35e74a5f9c7222360595cbb6b057a8f7f57a1538 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:54:41 +0000 Subject: [PATCH 120/215] feat: Add aws_identity_store support to meshstack_platform resource Co-authored-by: JohannesRudolph <130103+JohannesRudolph@users.noreply.github.com> --- platform_config_aws.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/platform_config_aws.go b/platform_config_aws.go index 7bfe5966..e1b96f31 100644 --- a/platform_config_aws.go +++ b/platform_config_aws.go @@ -19,6 +19,7 @@ type AwsReplicationConfig struct { AccountEmailPattern string `json:"accountEmailPattern" tfsdk:"account_email_pattern"` TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` AwsSso *AwsSsoConfig `json:"awsSso,omitempty" tfsdk:"aws_sso"` + AwsIdentityStore *AwsIdentityStoreConfig `json:"awsIdentityStore,omitempty" tfsdk:"aws_identity_store"` EnrollmentConfiguration *AwsEnrollmentConfiguration `json:"enrollmentConfiguration,omitempty" tfsdk:"enrollment_configuration"` SelfDowngradeAccessRole bool `json:"selfDowngradeAccessRole" tfsdk:"self_downgrade_access_role"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` @@ -66,6 +67,24 @@ type AwsEnrollmentConfiguration struct { AccountFactoryProductId string `json:"accountFactoryProductId" tfsdk:"account_factory_product_id"` } +type AwsIdentityStoreConfig struct { + IdentityStoreId string `json:"identityStoreId" tfsdk:"identity_store_id"` + Arn string `json:"arn" tfsdk:"arn"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + AwsRoleMappings []AwsIdentityStoreRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` + SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` +} + +type AwsIdentityStoreRoleMapping struct { + ProjectRoleRef AwsIdentityStoreProjectRoleRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AwsRole string `json:"awsRole" tfsdk:"aws_role"` + PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` +} + +type AwsIdentityStoreProjectRoleRef struct { + Name string `json:"name" tfsdk:"name"` +} + type AwsMeteringConfig struct { AccessConfig AwsAccessConfig `json:"accessConfig" tfsdk:"access_config"` Filter string `json:"filter" tfsdk:"filter"` From c6d89618a4ac00d375dfbbc87585683fac8103ee Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Thu, 12 Mar 2026 09:22:39 +0100 Subject: [PATCH 121/215] fix: use existing role refs and remove wrong mst- prefix docs meshStack does not enforce such a prefix, a parallel fix will be made to meshStack docs upstream --- platform_config_aws.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/platform_config_aws.go b/platform_config_aws.go index e1b96f31..b7a60db9 100644 --- a/platform_config_aws.go +++ b/platform_config_aws.go @@ -76,13 +76,9 @@ type AwsIdentityStoreConfig struct { } type AwsIdentityStoreRoleMapping struct { - ProjectRoleRef AwsIdentityStoreProjectRoleRef `json:"projectRoleRef" tfsdk:"project_role_ref"` - AwsRole string `json:"awsRole" tfsdk:"aws_role"` - PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` -} - -type AwsIdentityStoreProjectRoleRef struct { - Name string `json:"name" tfsdk:"name"` + ProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + AwsRole string `json:"awsRole" tfsdk:"aws_role"` + PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` } type AwsMeteringConfig struct { From 807b3b2823de9f7d7cf74e4bf66930c8d90c0dd4 Mon Sep 17 00:00:00 2001 From: Stefan Tomm Date: Wed, 18 Mar 2026 10:26:00 +0100 Subject: [PATCH 122/215] fix: only support the actually valid output IO types BD-2293 --- buildingblock_definition_version.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/buildingblock_definition_version.go b/buildingblock_definition_version.go index dd3d6277..0c300b40 100644 --- a/buildingblock_definition_version.go +++ b/buildingblock_definition_version.go @@ -43,6 +43,13 @@ var ( MeshBuildingBlockIOTypeMultiSelect = MeshBuildingBlockIOTypes.Entry("MULTI_SELECT") ) +var MeshBuildingBlockOutputIOTypes = enum.Of( + MeshBuildingBlockIOTypeString, + MeshBuildingBlockIOTypeCode, + MeshBuildingBlockIOTypeInteger, + MeshBuildingBlockIOTypeBoolean, +) + type MeshBuildingBlockInputAssignmentType string var ( From 73b7ccbf54255718341e78f4e517e482d1a54105 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 1 Apr 2026 21:46:52 +0200 Subject: [PATCH 123/215] fix: fix platform config boolean flags for AWS and AzureRG (#139) * feat: reproducer for incorrect platform flag handling * fix: remove allowHierarchicalManagementGroupAssignment from AzureRG platform config Remove the field from AzureRG resource/data source schemas, client model, example config and test expectations. This flag is only applicable to Azure (subscription-based) platforms. Fixes https://github.com/meshcloud/terraform-provider-meshstack/issues/123 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- platform_config_azurerg.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/platform_config_azurerg.go b/platform_config_azurerg.go index e984d303..dab2f733 100644 --- a/platform_config_azurerg.go +++ b/platform_config_azurerg.go @@ -6,14 +6,13 @@ type AzureRgPlatformConfig struct { } type AzureRgReplicationConfig struct { - ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` - Subscription string `json:"subscription" tfsdk:"subscription"` - ResourceGroupNamePattern string `json:"resourceGroupNamePattern" tfsdk:"resource_group_name_pattern"` - UserGroupNamePattern string `json:"userGroupNamePattern" tfsdk:"user_group_name_pattern"` - B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` - UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` - AllowHierarchicalManagementGroupAssignment bool `json:"allowHierarchicalManagementGroupAssignment" tfsdk:"allow_hierarchical_management_group_assignment"` + ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` + Subscription string `json:"subscription" tfsdk:"subscription"` + ResourceGroupNamePattern string `json:"resourceGroupNamePattern" tfsdk:"resource_group_name_pattern"` + UserGroupNamePattern string `json:"userGroupNamePattern" tfsdk:"user_group_name_pattern"` + B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` } From 0c4bd126b82b92f7b86fb3618bd112c261d224f7 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 9 Apr 2026 16:25:49 +0200 Subject: [PATCH 124/215] refactor: remove api_version/kind, extract client interfaces BREAKING CHANGES: - Remove api_version and kind computed attributes from all resources and data sources. These were always fixed values determined by the meshObject type and are now handled internally by the client library. IMPROVEMENTS: - Extract client interfaces for all meshObject types, enabling mock-based unit testing. - Add computed 'ref' attribute to workspace, tenant_v4, and platform_type resources exposing kind + uuid for cross-resource references. - Auto-inject apiVersion/kind in HTTP layer via withMeshObjectPayload(). - Move kind inference to client/types package using InferKind[T]() generic helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- buildingblock.go | 30 ++++++++------- buildingblock_definition.go | 12 ++---- buildingblock_definition_version.go | 12 ++---- buildingblock_v2.go | 31 ++++++++------- client_kind.go | 48 ++++++++++++++++++++++++ client_kind_test.go | 33 ++++++++++++++++ integration.go | 12 ++---- internal/mesh_object_client.go | 58 +++++++++++++++++++++-------- internal/mesh_object_client_test.go | 48 ------------------------ landingzone.go | 32 +++++++++------- location.go | 12 +++--- payment_method.go | 30 ++++++++------- platform.go | 10 +---- platform_type.go | 14 +++---- project.go | 30 +++++++++------ project_binding.go | 10 ++--- project_group_binding.go | 16 +++++--- project_user_binding.go | 16 +++++--- service_instance.go | 6 +-- tag_definition.go | 6 +-- tenant.go | 24 +++++++----- tenant_v4.go | 27 ++++++++------ workspace.go | 32 +++++++++------- workspace_binding.go | 10 ++--- workspace_group_binding.go | 16 +++++--- workspace_user_binding.go | 16 +++++--- 26 files changed, 334 insertions(+), 257 deletions(-) create mode 100644 client_kind.go create mode 100644 client_kind_test.go delete mode 100644 internal/mesh_object_client_test.go diff --git a/buildingblock.go b/buildingblock.go index c89d6069..97ecff7d 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -18,11 +18,9 @@ const ( ) type MeshBuildingBlock struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshBuildingBlockMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` - Status MeshBuildingBlockStatus `json:"status" tfsdk:"status"` + Metadata MeshBuildingBlockMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` + Status MeshBuildingBlockStatus `json:"status" tfsdk:"status"` } type MeshBuildingBlockMetadata struct { @@ -59,10 +57,8 @@ type MeshBuildingBlockStatus struct { } type MeshBuildingBlockCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshBuildingBlockCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` + Metadata MeshBuildingBlockCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` } type MeshBuildingBlockCreateMetadata struct { @@ -76,22 +72,28 @@ type MeshBuildingBlockDefinitionRef struct { Uuid string `json:"uuid" tfsdk:"uuid"` } -type MeshBuildingBlockClient struct { +type MeshBuildingBlockClient interface { + Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) + Create(ctx context.Context, bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) + Delete(ctx context.Context, uuid string) error +} + +type meshBuildingBlockClient struct { meshObject internal.MeshObjectClient[MeshBuildingBlock] } func newBuildingBlockClient(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockClient { - return MeshBuildingBlockClient{internal.NewMeshObjectClient[MeshBuildingBlock](ctx, httpClient, "v1")} + return meshBuildingBlockClient{internal.NewMeshObjectClient[MeshBuildingBlock](ctx, httpClient, "v1")} } -func (c MeshBuildingBlockClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) { +func (c meshBuildingBlockClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) { return c.meshObject.Get(ctx, uuid) } -func (c MeshBuildingBlockClient) Create(ctx context.Context, bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { +func (c meshBuildingBlockClient) Create(ctx context.Context, bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { return c.meshObject.Post(ctx, bb) } -func (c MeshBuildingBlockClient) Delete(ctx context.Context, uuid string) error { +func (c meshBuildingBlockClient) Delete(ctx context.Context, uuid string) error { return c.meshObject.Delete(ctx, uuid) } diff --git a/buildingblock_definition.go b/buildingblock_definition.go index 605bbd2c..0262f38a 100644 --- a/buildingblock_definition.go +++ b/buildingblock_definition.go @@ -57,11 +57,9 @@ type MeshBuildingBlockDefinitionStatus struct { } type MeshBuildingBlockDefinition struct { - ApiVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Metadata MeshBuildingBlockDefinitionMetadata `json:"metadata"` - Spec MeshBuildingBlockDefinitionSpec `json:"spec"` - Status *MeshBuildingBlockDefinitionStatus `json:"status,omitempty"` + Metadata MeshBuildingBlockDefinitionMetadata `json:"metadata"` + Spec MeshBuildingBlockDefinitionSpec `json:"spec"` + Status *MeshBuildingBlockDefinitionStatus `json:"status,omitempty"` } type MeshBuildingBlockDefinitionClient interface { @@ -95,14 +93,10 @@ func (c meshBuildingBlockDefinitionClient) Read(ctx context.Context, uuid string } func (c meshBuildingBlockDefinitionClient) Create(ctx context.Context, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) { - definition.Kind = c.meshObject.Kind - definition.ApiVersion = c.meshObject.ApiVersion return c.meshObject.Post(ctx, definition) } func (c meshBuildingBlockDefinitionClient) Update(ctx context.Context, uuid string, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) { - definition.Kind = c.meshObject.Kind - definition.ApiVersion = c.meshObject.ApiVersion return c.meshObject.Put(ctx, uuid, definition) } diff --git a/buildingblock_definition_version.go b/buildingblock_definition_version.go index 0c300b40..77bd0ed7 100644 --- a/buildingblock_definition_version.go +++ b/buildingblock_definition_version.go @@ -179,11 +179,9 @@ type MeshBuildingBlockDefinitionVersionStatus struct { } type MeshBuildingBlockDefinitionVersion struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshBuildingBlockDefinitionVersionMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshBuildingBlockDefinitionVersionSpec `json:"spec" tfsdk:"spec"` - Status *MeshBuildingBlockDefinitionVersionStatus `json:"status,omitempty" tfsdk:"status"` + Metadata MeshBuildingBlockDefinitionVersionMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockDefinitionVersionSpec `json:"spec" tfsdk:"spec"` + Status *MeshBuildingBlockDefinitionVersionStatus `json:"status,omitempty" tfsdk:"status"` } // MeshBuildingBlockDefinitionVersionClient manages a version of a building block definition. @@ -211,8 +209,6 @@ func (c meshBuildingBlockDefinitionVersionClient) List(ctx context.Context, buil func (c meshBuildingBlockDefinitionVersionClient) Create(ctx context.Context, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) { return c.meshObject.Post(ctx, MeshBuildingBlockDefinitionVersion{ - ApiVersion: c.meshObject.ApiVersion, - Kind: c.meshObject.Kind, Metadata: MeshBuildingBlockDefinitionVersionMetadata{ OwnedByWorkspace: ownedByWorkspace, }, @@ -222,8 +218,6 @@ func (c meshBuildingBlockDefinitionVersionClient) Create(ctx context.Context, ow func (c meshBuildingBlockDefinitionVersionClient) Update(ctx context.Context, uuid, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) { return c.meshObject.Put(ctx, uuid, MeshBuildingBlockDefinitionVersion{ - ApiVersion: c.meshObject.ApiVersion, - Kind: c.meshObject.Kind, Metadata: MeshBuildingBlockDefinitionVersionMetadata{ Uuid: uuid, OwnedByWorkspace: ownedByWorkspace, diff --git a/buildingblock_v2.go b/buildingblock_v2.go index fa3a3d5f..db09c735 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -18,11 +18,9 @@ const ( ) type MeshBuildingBlockV2 struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshBuildingBlockV2Metadata `json:"metadata" tfsdk:"metadata"` - Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` - Status MeshBuildingBlockV2Status `json:"status" tfsdk:"status"` + Metadata MeshBuildingBlockV2Metadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` + Status MeshBuildingBlockV2Status `json:"status" tfsdk:"status"` } type MeshBuildingBlockV2Metadata struct { @@ -53,9 +51,7 @@ type MeshBuildingBlockV2TargetRef struct { } type MeshBuildingBlockV2Create struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` + Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` } type MeshBuildingBlockV2Status struct { @@ -64,29 +60,36 @@ type MeshBuildingBlockV2Status struct { ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` } -type MeshBuildingBlockV2Client struct { +type MeshBuildingBlockV2Client interface { + Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) + ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) + Create(ctx context.Context, bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) + Delete(ctx context.Context, uuid string) error +} + +type meshBuildingBlockV2Client struct { meshObject internal.MeshObjectClient[MeshBuildingBlockV2] } func newBuildingBlockV2Client(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockV2Client { - return MeshBuildingBlockV2Client{internal.NewMeshObjectClient[MeshBuildingBlockV2](ctx, httpClient, "v2-preview")} + return meshBuildingBlockV2Client{internal.NewMeshObjectClient[MeshBuildingBlockV2](ctx, httpClient, "v2-preview")} } -func (c MeshBuildingBlockV2Client) Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { +func (c meshBuildingBlockV2Client) Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) { return c.ReadFunc(uuid)(ctx) } -func (c MeshBuildingBlockV2Client) ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) { +func (c meshBuildingBlockV2Client) ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) { return func(ctx context.Context) (*MeshBuildingBlockV2, error) { return c.meshObject.Get(ctx, uuid) } } -func (c MeshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { +func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { return c.meshObject.Post(ctx, bb) } -func (c MeshBuildingBlockV2Client) Delete(ctx context.Context, uuid string) error { +func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string) error { return c.meshObject.Delete(ctx, uuid) } diff --git a/client_kind.go b/client_kind.go new file mode 100644 index 00000000..340a02f3 --- /dev/null +++ b/client_kind.go @@ -0,0 +1,48 @@ +package client + +// meshObjectKind provides typed constants for meshObject kind strings used across the provider. +type meshObjectKind struct { + BuildingBlock string + BuildingBlockDefinition string + BuildingBlockDefinitionVersion string + BuildingBlockRunner string + Integration string + LandingZone string + Location string + PaymentMethod string + Platform string + PlatformType string + Project string + ProjectGroupBinding string + ProjectRole string + ProjectUserBinding string + ServiceInstance string + TagDefinition string + Tenant string + Workspace string + WorkspaceGroupBinding string + WorkspaceUserBinding string +} + +var MeshObjectKind = meshObjectKind{ + BuildingBlock: "meshBuildingBlock", + BuildingBlockDefinition: "meshBuildingBlockDefinition", + BuildingBlockDefinitionVersion: "meshBuildingBlockDefinitionVersion", + BuildingBlockRunner: "meshBuildingBlockRunner", + Integration: "meshIntegration", + LandingZone: "meshLandingZone", + Location: "meshLocation", + PaymentMethod: "meshPaymentMethod", + Platform: "meshPlatform", + PlatformType: "meshPlatformType", + Project: "meshProject", + ProjectGroupBinding: "meshProjectGroupBinding", + ProjectRole: "meshProjectRole", + ProjectUserBinding: "meshProjectUserBinding", + ServiceInstance: "meshServiceInstance", + TagDefinition: "meshTagDefinition", + Tenant: "meshTenant", + Workspace: "meshWorkspace", + WorkspaceGroupBinding: "meshWorkspaceGroupBinding", + WorkspaceUserBinding: "meshWorkspaceUserBinding", +} diff --git a/client_kind_test.go b/client_kind_test.go new file mode 100644 index 00000000..f011b00d --- /dev/null +++ b/client_kind_test.go @@ -0,0 +1,33 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + +func TestKind(t *testing.T) { + // verify hardcoded kind strings match InferKind for all client types + assert.Equal(t, internal.InferKind[MeshBuildingBlock](), MeshObjectKind.BuildingBlock) + assert.Equal(t, internal.InferKind[MeshBuildingBlockV2](), MeshObjectKind.BuildingBlock) + assert.Equal(t, internal.InferKind[MeshBuildingBlockDefinition](), MeshObjectKind.BuildingBlockDefinition) + assert.Equal(t, internal.InferKind[MeshBuildingBlockDefinitionVersion](), MeshObjectKind.BuildingBlockDefinitionVersion) + assert.Equal(t, internal.InferKind[MeshIntegration](), MeshObjectKind.Integration) + assert.Equal(t, internal.InferKind[MeshLandingZone](), MeshObjectKind.LandingZone) + assert.Equal(t, internal.InferKind[MeshLocation](), MeshObjectKind.Location) + assert.Equal(t, internal.InferKind[MeshPaymentMethod](), MeshObjectKind.PaymentMethod) + assert.Equal(t, internal.InferKind[MeshPlatform](), MeshObjectKind.Platform) + assert.Equal(t, internal.InferKind[MeshPlatformType](), MeshObjectKind.PlatformType) + assert.Equal(t, internal.InferKind[MeshProject](), MeshObjectKind.Project) + assert.Equal(t, internal.InferKind[MeshProjectGroupBinding](), MeshObjectKind.ProjectGroupBinding) + assert.Equal(t, internal.InferKind[MeshProjectUserBinding](), MeshObjectKind.ProjectUserBinding) + assert.Equal(t, internal.InferKind[MeshServiceInstance](), MeshObjectKind.ServiceInstance) + assert.Equal(t, internal.InferKind[MeshTagDefinition](), MeshObjectKind.TagDefinition) + assert.Equal(t, internal.InferKind[MeshTenant](), MeshObjectKind.Tenant) + assert.Equal(t, internal.InferKind[MeshTenantV4](), MeshObjectKind.Tenant) + assert.Equal(t, internal.InferKind[MeshWorkspace](), MeshObjectKind.Workspace) + assert.Equal(t, internal.InferKind[MeshWorkspaceGroupBinding](), MeshObjectKind.WorkspaceGroupBinding) + assert.Equal(t, internal.InferKind[MeshWorkspaceUserBinding](), MeshObjectKind.WorkspaceUserBinding) +} diff --git a/integration.go b/integration.go index 5983c758..78cbdbaf 100644 --- a/integration.go +++ b/integration.go @@ -7,11 +7,9 @@ import ( ) type MeshIntegration struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Kind string `json:"kind" tfsdk:"-"` - Metadata MeshIntegrationMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshIntegrationSpec `json:"spec" tfsdk:"spec"` - Status *MeshIntegrationStatus `json:"status" tfsdk:"status"` + Metadata MeshIntegrationMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshIntegrationSpec `json:"spec" tfsdk:"spec"` + Status *MeshIntegrationStatus `json:"status" tfsdk:"status"` } type MeshIntegrationMetadata struct { @@ -63,8 +61,6 @@ func newIntegrationClient(ctx context.Context, httpClient *internal.HttpClient) } func (c meshIntegrationClientImpl) Create(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) { - integration.Kind = c.meshObject.Kind - integration.ApiVersion = c.meshObject.ApiVersion return c.meshObject.Post(ctx, integration) } @@ -73,8 +69,6 @@ func (c meshIntegrationClientImpl) Read(ctx context.Context, uuid string) (*Mesh } func (c meshIntegrationClientImpl) Update(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) { - integration.Kind = c.meshObject.Kind - integration.ApiVersion = c.meshObject.ApiVersion return c.meshObject.Put(ctx, *integration.Metadata.Uuid, integration) } diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index 7957654d..dcd65b82 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -2,6 +2,7 @@ package internal import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -31,31 +32,31 @@ type MeshObjectClient[M any] struct { // The API URL is constructed from explicitApiPathElems if provided, // otherwise the pluralized and lowercased kind is used as a single element. func NewMeshObjectClient[M any](ctx context.Context, httpClient *HttpClient, apiVersion string, explicitApiPathElems ...string) MeshObjectClient[M] { - kind, typeName := inferMeshObjectKindFromType[M]() + kind := InferKind[M]() if len(explicitApiPathElems) == 0 { explicitApiPathElems = []string{strings.ToLower(pluralizeKind(kind))} } explicitApiPathElems = slices.Insert(explicitApiPathElems, 0, "/api/meshobjects") apiUrl := httpClient.RootUrl.JoinPath(explicitApiPathElems...) - Log.Info(ctx, fmt.Sprintf("initialized %s client", typeName), "url", apiUrl.String(), "kind", kind, "version", apiVersion) + Log.Info(ctx, fmt.Sprintf("initialized %s client", reflect.TypeFor[M]().Name()), "url", apiUrl.String(), "kind", kind, "version", apiVersion) return MeshObjectClient[M]{httpClient, kind, apiVersion, apiUrl} } -func inferMeshObjectKindFromType[M any]() (lowercase, typeName string) { - var zero M - typeName = reflect.TypeOf(zero).Name() - lowercase = lowercaseFirst(typeName) - return regexp.MustCompile(`V\d+$`).ReplaceAllString(lowercase, ""), typeName -} +var versionSuffixRe = regexp.MustCompile(`V\d+$`) -func lowercaseFirst(s string) string { - if s == "" { - return s - } - runes := []rune(s) +// InferKind infers the meshObject kind from a struct type name using the same convention +// as the meshObject API: MeshWorkspace → "meshWorkspace", MeshTenantV4 → "meshTenant". +// Version suffixes (V\d+) are stripped. +// Tested when client.Kind is statically initialized. +func InferKind[M any]() string { + typeName := reflect.TypeFor[M]().Name() + + runes := []rune(typeName) runes[0] = unicode.ToLower(runes[0]) - return string(runes) + kind := string(runes) + + return versionSuffixRe.ReplaceAllString(kind, "") } func pluralizeKind(kind string) string { @@ -80,13 +81,38 @@ func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (*M, error) { } // Post creates a new meshObject with the given payload. +// Automatically injects apiVersion and kind into the JSON payload. func (c MeshObjectClient[M]) Post(ctx context.Context, payload any) (*M, error) { - return unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodPost, c.ApiUrl, withPayload(payload, c.meshObjectMimeType()))) + return unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodPost, c.ApiUrl, c.withMeshObjectPayload(payload))) } // Put updates an existing meshObject by ID with the given payload. +// Automatically injects apiVersion and kind into the JSON payload. func (c MeshObjectClient[M]) Put(ctx context.Context, id string, payload any) (*M, error) { - return unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodPut, c.ApiUrl.JoinPath(id), withPayload(payload, c.meshObjectMimeType()))) + return unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodPut, c.ApiUrl.JoinPath(id), c.withMeshObjectPayload(payload))) +} + +// withMeshObjectPayload returns a RequestOption that sets the payload with apiVersion and kind injected, +// using the meshObject MIME type for content negotiation. +// Panics on marshal errors which indicates a programming error (payload is always a well-typed struct). +// +// The double marshal/unmarshal round-trip converts the typed struct to a map[string]any so we can +// inject the top-level apiVersion and kind fields without coupling the struct type to those fields. +func (c MeshObjectClient[M]) withMeshObjectPayload(payload any) RequestOption { + intermediate, err := json.Marshal(payload) + if err != nil { + panic(fmt.Sprintf("failed to marshal %T: %v", payload, err)) + } + + var m map[string]any + if err := json.Unmarshal(intermediate, &m); err != nil { + panic(fmt.Sprintf("failed to unmarshal %T to map: %v", payload, err)) + } + + m["apiVersion"] = c.ApiVersion + m["kind"] = c.Kind + + return withPayload(m, c.meshObjectMimeType()) } // Delete removes a meshObject by ID. diff --git a/internal/mesh_object_client_test.go b/internal/mesh_object_client_test.go deleted file mode 100644 index fb8f068d..00000000 --- a/internal/mesh_object_client_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package internal - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -type MeshBuildingBlock struct{} -type MeshBuildingBlockV2 struct{} -type MeshTenantV4 struct{} -type MeshWorkspace struct{} - -func Test_inferMeshObjectKindFromType(t *testing.T) { - tests := []struct { - kind string - testFunc func() (string, string) - expected string - }{ - { - kind: "MeshBuildingBlock", - testFunc: inferMeshObjectKindFromType[MeshBuildingBlock], - expected: "meshBuildingBlock", - }, - { - kind: "MeshBuildingBlockV2", - testFunc: inferMeshObjectKindFromType[MeshBuildingBlockV2], - expected: "meshBuildingBlock", - }, - { - kind: "MeshWorkspace", - testFunc: inferMeshObjectKindFromType[MeshWorkspace], - expected: "meshWorkspace", - }, - { - kind: "MeshTenantV4", - testFunc: inferMeshObjectKindFromType[MeshTenantV4], - expected: "meshTenant", - }, - } - - for _, tt := range tests { - t.Run(tt.kind, func(t *testing.T) { - actual, _ := tt.testFunc() - assert.Equal(t, tt.expected, actual) - }) - } -} diff --git a/landingzone.go b/landingzone.go index 1e8e33e1..157d1649 100644 --- a/landingzone.go +++ b/landingzone.go @@ -7,11 +7,9 @@ import ( ) type MeshLandingZone struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Kind string `json:"kind" tfsdk:"-"` - Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` - Status MeshLandingZoneStatus `json:"status" tfsdk:"status"` + Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` + Status MeshLandingZoneStatus `json:"status" tfsdk:"status"` } type MeshLandingZoneMetadata struct { @@ -61,31 +59,37 @@ type MeshLandingZoneQuota struct { } type MeshLandingZoneCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` + Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` } -type MeshLandingZoneClient struct { +type MeshLandingZoneClient interface { + Read(ctx context.Context, name string) (*MeshLandingZone, error) + Create(ctx context.Context, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) + Update(ctx context.Context, name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) + Delete(ctx context.Context, name string) error +} + +type meshLandingZoneClient struct { meshObject internal.MeshObjectClient[MeshLandingZone] } func newLandingZoneClient(ctx context.Context, httpClient *internal.HttpClient) MeshLandingZoneClient { - return MeshLandingZoneClient{internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1")} + return meshLandingZoneClient{internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1")} } -func (c MeshLandingZoneClient) Read(ctx context.Context, name string) (*MeshLandingZone, error) { +func (c meshLandingZoneClient) Read(ctx context.Context, name string) (*MeshLandingZone, error) { return c.meshObject.Get(ctx, name) } -func (c MeshLandingZoneClient) Create(ctx context.Context, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { +func (c meshLandingZoneClient) Create(ctx context.Context, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { return c.meshObject.Post(ctx, landingZone) } -func (c MeshLandingZoneClient) Update(ctx context.Context, name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { +func (c meshLandingZoneClient) Update(ctx context.Context, name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { return c.meshObject.Put(ctx, name, landingZone) } -func (c MeshLandingZoneClient) Delete(ctx context.Context, name string) error { +func (c meshLandingZoneClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } diff --git a/location.go b/location.go index 9a25b333..33bf0a84 100644 --- a/location.go +++ b/location.go @@ -7,10 +7,9 @@ import ( ) type MeshLocation struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Metadata MeshLocationMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` - Status MeshLocationStatus `json:"status" tfsdk:"status"` + Metadata MeshLocationMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` + Status MeshLocationStatus `json:"status" tfsdk:"status"` } type MeshLocationMetadata struct { @@ -29,9 +28,8 @@ type MeshLocationStatus struct { } type MeshLocationCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Metadata MeshLocationCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` + Metadata MeshLocationCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` } type MeshLocationCreateMetadata struct { diff --git a/payment_method.go b/payment_method.go index 98111ff2..68670535 100644 --- a/payment_method.go +++ b/payment_method.go @@ -7,10 +7,8 @@ import ( ) type MeshPaymentMethod struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshPaymentMethodMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshPaymentMethodSpec `json:"spec" tfsdk:"spec"` + Metadata MeshPaymentMethodMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPaymentMethodSpec `json:"spec" tfsdk:"spec"` } type MeshPaymentMethodMetadata struct { @@ -28,9 +26,8 @@ type MeshPaymentMethodSpec struct { } type MeshPaymentMethodCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Metadata MeshPaymentMethodCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshPaymentMethodSpec `json:"spec" tfsdk:"spec"` + Metadata MeshPaymentMethodCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPaymentMethodSpec `json:"spec" tfsdk:"spec"` } type MeshPaymentMethodCreateMetadata struct { @@ -38,26 +35,33 @@ type MeshPaymentMethodCreateMetadata struct { OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } -type MeshPaymentMethodClient struct { +type MeshPaymentMethodClient interface { + Read(ctx context.Context, workspace string, identifier string) (*MeshPaymentMethod, error) + Create(ctx context.Context, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) + Update(ctx context.Context, identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) + Delete(ctx context.Context, identifier string) error +} + +type meshPaymentMethodClient struct { meshObject internal.MeshObjectClient[MeshPaymentMethod] } func newPaymentMethodClient(ctx context.Context, httpClient *internal.HttpClient) MeshPaymentMethodClient { - return MeshPaymentMethodClient{internal.NewMeshObjectClient[MeshPaymentMethod](ctx, httpClient, "v2")} + return meshPaymentMethodClient{internal.NewMeshObjectClient[MeshPaymentMethod](ctx, httpClient, "v2")} } -func (c MeshPaymentMethodClient) Read(ctx context.Context, workspace string, identifier string) (*MeshPaymentMethod, error) { +func (c meshPaymentMethodClient) Read(ctx context.Context, workspace string, identifier string) (*MeshPaymentMethod, error) { return c.meshObject.Get(ctx, identifier) } -func (c MeshPaymentMethodClient) Create(ctx context.Context, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { +func (c meshPaymentMethodClient) Create(ctx context.Context, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { return c.meshObject.Post(ctx, paymentMethod) } -func (c MeshPaymentMethodClient) Update(ctx context.Context, identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { +func (c meshPaymentMethodClient) Update(ctx context.Context, identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) { return c.meshObject.Put(ctx, identifier, paymentMethod) } -func (c MeshPaymentMethodClient) Delete(ctx context.Context, identifier string) error { +func (c meshPaymentMethodClient) Delete(ctx context.Context, identifier string) error { return c.meshObject.Delete(ctx, identifier) } diff --git a/platform.go b/platform.go index cc7133c9..c276a8d3 100644 --- a/platform.go +++ b/platform.go @@ -8,10 +8,8 @@ import ( ) type MeshPlatform struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Kind string `json:"kind" tfsdk:"-"` - Metadata MeshPlatformMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` + Metadata MeshPlatformMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformSpec `json:"spec" tfsdk:"spec"` } type MeshPlatformMetadata struct { @@ -101,14 +99,10 @@ func (c meshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatfor } func (c meshPlatformClient) Create(ctx context.Context, platform MeshPlatform) (*MeshPlatform, error) { - platform.Kind = c.meshObject.Kind - platform.ApiVersion = c.meshObject.ApiVersion return c.meshObject.Post(ctx, platform) } func (c meshPlatformClient) Update(ctx context.Context, uuid string, platform MeshPlatform) (*MeshPlatform, error) { - platform.Kind = c.meshObject.Kind - platform.ApiVersion = c.meshObject.ApiVersion return c.meshObject.Put(ctx, uuid, platform) } diff --git a/platform_type.go b/platform_type.go index 29df7a2c..6160b32d 100644 --- a/platform_type.go +++ b/platform_type.go @@ -7,11 +7,9 @@ import ( ) type MeshPlatformType struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Kind string `json:"kind" tfsdk:"-"` - Metadata MeshPlatformTypeMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` - Status MeshPlatformTypeStatus `json:"status" tfsdk:"status"` + Metadata MeshPlatformTypeMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` + Status MeshPlatformTypeStatus `json:"status" tfsdk:"status"` } type MeshPlatformTypeStatus struct { @@ -36,10 +34,8 @@ type MeshPlatformTypeSpec struct { } type MeshPlatformTypeCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"-"` - Kind string `json:"kind" tfsdk:"-"` - Metadata MeshPlatformTypeCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` + Metadata MeshPlatformTypeCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` } type MeshPlatformTypeCreateMetadata struct { diff --git a/project.go b/project.go index d8a2fba8..51e7477c 100644 --- a/project.go +++ b/project.go @@ -7,10 +7,8 @@ import ( ) type MeshProject struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshProjectMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshProjectSpec `json:"spec" tfsdk:"spec"` + Metadata MeshProjectMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshProjectSpec `json:"spec" tfsdk:"spec"` } type MeshProjectMetadata struct { @@ -37,23 +35,31 @@ type MeshProjectCreateMetadata struct { OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } -type MeshProjectClient struct { +type MeshProjectClient interface { + Read(ctx context.Context, workspace string, name string) (*MeshProject, error) + List(ctx context.Context, workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) + Create(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) + Update(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) + Delete(ctx context.Context, workspace string, name string) error +} + +type meshProjectClient struct { meshObject internal.MeshObjectClient[MeshProject] } func newProjectClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectClient { - return MeshProjectClient{internal.NewMeshObjectClient[MeshProject](ctx, httpClient, "v2")} + return meshProjectClient{internal.NewMeshObjectClient[MeshProject](ctx, httpClient, "v2")} } -func (c MeshProjectClient) projectId(workspace string, name string) string { +func (c meshProjectClient) projectId(workspace string, name string) string { return workspace + "." + name } -func (c MeshProjectClient) Read(ctx context.Context, workspace string, name string) (*MeshProject, error) { +func (c meshProjectClient) Read(ctx context.Context, workspace string, name string) (*MeshProject, error) { return c.meshObject.Get(ctx, c.projectId(workspace, name)) } -func (c MeshProjectClient) List(ctx context.Context, workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { +func (c meshProjectClient) List(ctx context.Context, workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { options := []internal.RequestOption{ internal.WithUrlQuery("workspaceIdentifier", workspaceIdentifier), } @@ -63,14 +69,14 @@ func (c MeshProjectClient) List(ctx context.Context, workspaceIdentifier string, return c.meshObject.List(ctx, options...) } -func (c MeshProjectClient) Create(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) { +func (c meshProjectClient) Create(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) { return c.meshObject.Post(ctx, project) } -func (c MeshProjectClient) Update(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) { +func (c meshProjectClient) Update(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) { return c.meshObject.Put(ctx, c.projectId(project.Metadata.OwnedByWorkspace, project.Metadata.Name), project) } -func (c MeshProjectClient) Delete(ctx context.Context, workspace string, name string) error { +func (c meshProjectClient) Delete(ctx context.Context, workspace string, name string) error { return c.meshObject.Delete(ctx, c.projectId(workspace, name)) } diff --git a/project_binding.go b/project_binding.go index 96e8b69f..4178b68a 100644 --- a/project_binding.go +++ b/project_binding.go @@ -1,12 +1,10 @@ package client type MeshProjectBinding struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshProjectBindingMetadata `json:"metadata" tfsdk:"metadata"` - RoleRef MeshProjectRoleRef `json:"roleRef" tfsdk:"role_ref"` - TargetRef MeshProjectTargetRef `json:"targetRef" tfsdk:"target_ref"` - Subject MeshSubject `json:"subject" tfsdk:"subject"` + Metadata MeshProjectBindingMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshProjectRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshProjectTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshSubject `json:"subject" tfsdk:"subject"` } type MeshProjectBindingMetadata struct { diff --git a/project_group_binding.go b/project_group_binding.go index 916d0e61..a19f9705 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -10,22 +10,28 @@ type MeshProjectGroupBinding struct { MeshProjectBinding } -type MeshProjectGroupBindingClient struct { +type MeshProjectGroupBindingClient interface { + Read(ctx context.Context, name string) (*MeshProjectGroupBinding, error) + Create(ctx context.Context, binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) + Delete(ctx context.Context, name string) error +} + +type meshProjectGroupBindingClient struct { meshObject internal.MeshObjectClient[MeshProjectGroupBinding] } func newProjectGroupBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectGroupBindingClient { - return MeshProjectGroupBindingClient{internal.NewMeshObjectClient[MeshProjectGroupBinding](ctx, httpClient, "v3", "meshprojectbindings", "groupbindings")} + return meshProjectGroupBindingClient{internal.NewMeshObjectClient[MeshProjectGroupBinding](ctx, httpClient, "v3", "meshprojectbindings", "groupbindings")} } -func (c MeshProjectGroupBindingClient) Read(ctx context.Context, name string) (*MeshProjectGroupBinding, error) { +func (c meshProjectGroupBindingClient) Read(ctx context.Context, name string) (*MeshProjectGroupBinding, error) { return c.meshObject.Get(ctx, name) } -func (c MeshProjectGroupBindingClient) Create(ctx context.Context, binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { +func (c meshProjectGroupBindingClient) Create(ctx context.Context, binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { return c.meshObject.Post(ctx, binding) } -func (c MeshProjectGroupBindingClient) Delete(ctx context.Context, name string) error { +func (c meshProjectGroupBindingClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } diff --git a/project_user_binding.go b/project_user_binding.go index 2ddcd6be..75c828fd 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -10,22 +10,28 @@ type MeshProjectUserBinding struct { MeshProjectBinding } -type MeshProjectUserBindingClient struct { +type MeshProjectUserBindingClient interface { + Read(ctx context.Context, name string) (*MeshProjectUserBinding, error) + Create(ctx context.Context, binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) + Delete(ctx context.Context, name string) error +} + +type meshProjectUserBindingClient struct { meshObject internal.MeshObjectClient[MeshProjectUserBinding] } func newProjectUserBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectUserBindingClient { - return MeshProjectUserBindingClient{internal.NewMeshObjectClient[MeshProjectUserBinding](ctx, httpClient, "v3", "meshprojectbindings", "userbindings")} + return meshProjectUserBindingClient{internal.NewMeshObjectClient[MeshProjectUserBinding](ctx, httpClient, "v3", "meshprojectbindings", "userbindings")} } -func (c MeshProjectUserBindingClient) Read(ctx context.Context, name string) (*MeshProjectUserBinding, error) { +func (c meshProjectUserBindingClient) Read(ctx context.Context, name string) (*MeshProjectUserBinding, error) { return c.meshObject.Get(ctx, name) } -func (c MeshProjectUserBindingClient) Create(ctx context.Context, binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { +func (c meshProjectUserBindingClient) Create(ctx context.Context, binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { return c.meshObject.Post(ctx, binding) } -func (c MeshProjectUserBindingClient) Delete(ctx context.Context, name string) error { +func (c meshProjectUserBindingClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } diff --git a/service_instance.go b/service_instance.go index 3312b612..2cd537be 100644 --- a/service_instance.go +++ b/service_instance.go @@ -8,10 +8,8 @@ import ( ) type MeshServiceInstance struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshServiceInstanceMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshServiceInstanceSpec `json:"spec" tfsdk:"spec"` + Metadata MeshServiceInstanceMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshServiceInstanceSpec `json:"spec" tfsdk:"spec"` } type MeshServiceInstanceMetadata struct { diff --git a/tag_definition.go b/tag_definition.go index dfc63008..61b11bd6 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -9,10 +9,8 @@ import ( const API_VERSION_TAG_DEFINITION = "v1" type MeshTagDefinition struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshTagDefinitionMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshTagDefinitionSpec `json:"spec" tfsdk:"spec"` + Metadata MeshTagDefinitionMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTagDefinitionSpec `json:"spec" tfsdk:"spec"` } type MeshTagDefinitionMetadata struct { diff --git a/tenant.go b/tenant.go index c68e9fba..d7929d36 100644 --- a/tenant.go +++ b/tenant.go @@ -7,10 +7,8 @@ import ( ) type MeshTenant struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshTenantMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshTenantSpec `json:"spec" tfsdk:"spec"` + Metadata MeshTenantMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantSpec `json:"spec" tfsdk:"spec"` } type MeshTenantMetadata struct { @@ -49,26 +47,32 @@ type MeshTenantCreateSpec struct { Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` } -type MeshTenantClient struct { +type MeshTenantClient interface { + Read(ctx context.Context, workspace string, project string, platform string) (*MeshTenant, error) + Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) + Delete(ctx context.Context, workspace string, project string, platform string) error +} + +type meshTenantClient struct { meshObject internal.MeshObjectClient[MeshTenant] } func newTenantClient(ctx context.Context, httpClient *internal.HttpClient) MeshTenantClient { - return MeshTenantClient{internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v3")} + return meshTenantClient{internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v3")} } -func (c MeshTenantClient) tenantId(workspace string, project string, platform string) string { +func (c meshTenantClient) tenantId(workspace string, project string, platform string) string { return workspace + "." + project + "." + platform } -func (c MeshTenantClient) Read(ctx context.Context, workspace string, project string, platform string) (*MeshTenant, error) { +func (c meshTenantClient) Read(ctx context.Context, workspace string, project string, platform string) (*MeshTenant, error) { return c.meshObject.Get(ctx, c.tenantId(workspace, project, platform)) } -func (c MeshTenantClient) Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) { +func (c meshTenantClient) Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) { return c.meshObject.Post(ctx, tenant) } -func (c MeshTenantClient) Delete(ctx context.Context, workspace string, project string, platform string) error { +func (c meshTenantClient) Delete(ctx context.Context, workspace string, project string, platform string) error { return c.meshObject.Delete(ctx, c.tenantId(workspace, project, platform)) } diff --git a/tenant_v4.go b/tenant_v4.go index 4d6f3170..5e1bfc87 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -8,11 +8,9 @@ import ( ) type MeshTenantV4 struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshTenantV4Metadata `json:"metadata" tfsdk:"metadata"` - Spec MeshTenantV4Spec `json:"spec" tfsdk:"spec"` - Status MeshTenantV4Status `json:"status" tfsdk:"status"` + Metadata MeshTenantV4Metadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantV4Spec `json:"spec" tfsdk:"spec"` + Status MeshTenantV4Status `json:"status" tfsdk:"status"` } type MeshTenantV4Metadata struct { @@ -55,29 +53,36 @@ type MeshTenantV4CreateSpec struct { Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` } -type MeshTenantV4Client struct { +type MeshTenantV4Client interface { + Read(ctx context.Context, uuid string) (*MeshTenantV4, error) + ReadFunc(uuid string) func(ctx context.Context) (*MeshTenantV4, error) + Create(ctx context.Context, tenant *MeshTenantV4Create) (*MeshTenantV4, error) + Delete(ctx context.Context, uuid string) error +} + +type meshTenantV4Client struct { meshObject internal.MeshObjectClient[MeshTenantV4] } func newTenantV4Client(ctx context.Context, httpClient *internal.HttpClient) MeshTenantV4Client { - return MeshTenantV4Client{internal.NewMeshObjectClient[MeshTenantV4](ctx, httpClient, "v4-preview")} + return meshTenantV4Client{internal.NewMeshObjectClient[MeshTenantV4](ctx, httpClient, "v4-preview")} } -func (c MeshTenantV4Client) Read(ctx context.Context, uuid string) (*MeshTenantV4, error) { +func (c meshTenantV4Client) Read(ctx context.Context, uuid string) (*MeshTenantV4, error) { return c.ReadFunc(uuid)(ctx) } -func (c MeshTenantV4Client) ReadFunc(uuid string) func(ctx context.Context) (*MeshTenantV4, error) { +func (c meshTenantV4Client) ReadFunc(uuid string) func(ctx context.Context) (*MeshTenantV4, error) { return func(ctx context.Context) (*MeshTenantV4, error) { return c.meshObject.Get(ctx, uuid) } } -func (c MeshTenantV4Client) Create(ctx context.Context, tenant *MeshTenantV4Create) (*MeshTenantV4, error) { +func (c meshTenantV4Client) Create(ctx context.Context, tenant *MeshTenantV4Create) (*MeshTenantV4, error) { return c.meshObject.Post(ctx, tenant) } -func (c MeshTenantV4Client) Delete(ctx context.Context, uuid string) error { +func (c meshTenantV4Client) Delete(ctx context.Context, uuid string) error { return c.meshObject.Delete(ctx, uuid) } diff --git a/workspace.go b/workspace.go index e0193081..1f47fa61 100644 --- a/workspace.go +++ b/workspace.go @@ -7,10 +7,8 @@ import ( ) type MeshWorkspace struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshWorkspaceMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshWorkspaceSpec `json:"spec" tfsdk:"spec"` + Metadata MeshWorkspaceMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshWorkspaceSpec `json:"spec" tfsdk:"spec"` } type MeshWorkspaceMetadata struct { @@ -26,35 +24,41 @@ type MeshWorkspaceSpec struct { } type MeshWorkspaceCreate struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Metadata MeshWorkspaceCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshWorkspaceSpec `json:"spec" tfsdk:"spec"` + Metadata MeshWorkspaceCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshWorkspaceSpec `json:"spec" tfsdk:"spec"` } type MeshWorkspaceCreateMetadata struct { Name string `json:"name" tfsdk:"name"` Tags map[string][]string `json:"tags" tfsdk:"tags"` } -type MeshWorkspaceClient struct { +type MeshWorkspaceClient interface { + Read(ctx context.Context, name string) (*MeshWorkspace, error) + Create(ctx context.Context, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) + Update(ctx context.Context, name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) + Delete(ctx context.Context, name string) error +} + +type meshWorkspaceClient struct { meshObject internal.MeshObjectClient[MeshWorkspace] } -func newWorkspaceClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceClient { - return MeshWorkspaceClient{internal.NewMeshObjectClient[MeshWorkspace](ctx, httpClient, "v2")} +func newWorkspaceClient(ctx context.Context, httpClient *internal.HttpClient) meshWorkspaceClient { + return meshWorkspaceClient{internal.NewMeshObjectClient[MeshWorkspace](ctx, httpClient, "v2")} } -func (c MeshWorkspaceClient) Read(ctx context.Context, name string) (*MeshWorkspace, error) { +func (c meshWorkspaceClient) Read(ctx context.Context, name string) (*MeshWorkspace, error) { return c.meshObject.Get(ctx, name) } -func (c MeshWorkspaceClient) Create(ctx context.Context, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { +func (c meshWorkspaceClient) Create(ctx context.Context, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { return c.meshObject.Post(ctx, workspace) } -func (c MeshWorkspaceClient) Update(ctx context.Context, name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { +func (c meshWorkspaceClient) Update(ctx context.Context, name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) { return c.meshObject.Put(ctx, name, workspace) } -func (c MeshWorkspaceClient) Delete(ctx context.Context, name string) error { +func (c meshWorkspaceClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } diff --git a/workspace_binding.go b/workspace_binding.go index 8732b7c6..fc6a253c 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -1,12 +1,10 @@ package client type MeshWorkspaceBinding struct { - ApiVersion string `json:"apiVersion" tfsdk:"api_version"` - Kind string `json:"kind" tfsdk:"kind"` - Metadata MeshWorkspaceBindingMetadata `json:"metadata" tfsdk:"metadata"` - RoleRef MeshWorkspaceRoleRef `json:"roleRef" tfsdk:"role_ref"` - TargetRef MeshWorkspaceTargetRef `json:"targetRef" tfsdk:"target_ref"` - Subject MeshWorkspaceSubject `json:"subject" tfsdk:"subject"` + Metadata MeshWorkspaceBindingMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshWorkspaceRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshWorkspaceTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshWorkspaceSubject `json:"subject" tfsdk:"subject"` } type MeshWorkspaceBindingMetadata struct { diff --git a/workspace_group_binding.go b/workspace_group_binding.go index e4404a6c..027afb0f 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -10,22 +10,28 @@ type MeshWorkspaceGroupBinding struct { MeshWorkspaceBinding } -type MeshWorkspaceGroupBindingClient struct { +type MeshWorkspaceGroupBindingClient interface { + Read(ctx context.Context, name string) (*MeshWorkspaceGroupBinding, error) + Create(ctx context.Context, binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) + Delete(ctx context.Context, name string) error +} + +type meshWorkspaceGroupBindingClient struct { meshObject internal.MeshObjectClient[MeshWorkspaceGroupBinding] } func newWorkspaceGroupBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceGroupBindingClient { - return MeshWorkspaceGroupBindingClient{internal.NewMeshObjectClient[MeshWorkspaceGroupBinding](ctx, httpClient, "v2", "meshworkspacebindings", "groupbindings")} + return meshWorkspaceGroupBindingClient{internal.NewMeshObjectClient[MeshWorkspaceGroupBinding](ctx, httpClient, "v2", "meshworkspacebindings", "groupbindings")} } -func (c MeshWorkspaceGroupBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceGroupBinding, error) { +func (c meshWorkspaceGroupBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceGroupBinding, error) { return c.meshObject.Get(ctx, name) } -func (c MeshWorkspaceGroupBindingClient) Create(ctx context.Context, binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { +func (c meshWorkspaceGroupBindingClient) Create(ctx context.Context, binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { return c.meshObject.Post(ctx, binding) } -func (c MeshWorkspaceGroupBindingClient) Delete(ctx context.Context, name string) error { +func (c meshWorkspaceGroupBindingClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } diff --git a/workspace_user_binding.go b/workspace_user_binding.go index eb552d18..501f4ee2 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -10,22 +10,28 @@ type MeshWorkspaceUserBinding struct { MeshWorkspaceBinding } -type MeshWorkspaceUserBindingClient struct { +type MeshWorkspaceUserBindingClient interface { + Read(ctx context.Context, name string) (*MeshWorkspaceUserBinding, error) + Create(ctx context.Context, binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) + Delete(ctx context.Context, name string) error +} + +type meshWorkspaceUserBindingClient struct { meshObject internal.MeshObjectClient[MeshWorkspaceUserBinding] } func newWorkspaceUserBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceUserBindingClient { - return MeshWorkspaceUserBindingClient{internal.NewMeshObjectClient[MeshWorkspaceUserBinding](ctx, httpClient, "v2", "meshworkspacebindings", "userbindings")} + return meshWorkspaceUserBindingClient{internal.NewMeshObjectClient[MeshWorkspaceUserBinding](ctx, httpClient, "v2", "meshworkspacebindings", "userbindings")} } -func (c MeshWorkspaceUserBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceUserBinding, error) { +func (c meshWorkspaceUserBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceUserBinding, error) { return c.meshObject.Get(ctx, name) } -func (c MeshWorkspaceUserBindingClient) Create(ctx context.Context, binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { +func (c meshWorkspaceUserBindingClient) Create(ctx context.Context, binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { return c.meshObject.Post(ctx, binding) } -func (c MeshWorkspaceUserBindingClient) Delete(ctx context.Context, name string) error { +func (c meshWorkspaceUserBindingClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } From 2094fae685af3aa09a9cbfd873044cdbfdab60b7 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 9 Apr 2026 16:28:43 +0200 Subject: [PATCH 125/215] feat: add meshstack_tenants data source FEATURES: - New meshstack_tenants data source for listing tenants with optional workspace/project/platform filters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tenant_v4.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tenant_v4.go b/tenant_v4.go index 5e1bfc87..8625136d 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -53,9 +53,19 @@ type MeshTenantV4CreateSpec struct { Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` } +type MeshTenantV4Query struct { + Workspace string + Project *string + Platform *string + PlatformType *string + LandingZone *string + PlatformTenant *string +} + type MeshTenantV4Client interface { Read(ctx context.Context, uuid string) (*MeshTenantV4, error) ReadFunc(uuid string) func(ctx context.Context) (*MeshTenantV4, error) + List(ctx context.Context, query *MeshTenantV4Query) ([]MeshTenantV4, error) Create(ctx context.Context, tenant *MeshTenantV4Create) (*MeshTenantV4, error) Delete(ctx context.Context, uuid string) error } @@ -82,6 +92,28 @@ func (c meshTenantV4Client) Create(ctx context.Context, tenant *MeshTenantV4Crea return c.meshObject.Post(ctx, tenant) } +func (c meshTenantV4Client) List(ctx context.Context, query *MeshTenantV4Query) ([]MeshTenantV4, error) { + options := []internal.RequestOption{ + internal.WithUrlQuery("workspaceIdentifier", query.Workspace), + } + if query.Project != nil { + options = append(options, internal.WithUrlQuery("projectIdentifier", *query.Project)) + } + if query.Platform != nil { + options = append(options, internal.WithUrlQuery("platformIdentifier", *query.Platform)) + } + if query.PlatformType != nil { + options = append(options, internal.WithUrlQuery("platformTypeIdentifier", *query.PlatformType)) + } + if query.LandingZone != nil { + options = append(options, internal.WithUrlQuery("landingZoneIdentifier", *query.LandingZone)) + } + if query.PlatformTenant != nil { + options = append(options, internal.WithUrlQuery("platformTenantId", *query.PlatformTenant)) + } + return c.meshObject.List(ctx, options...) +} + func (c meshTenantV4Client) Delete(ctx context.Context, uuid string) error { return c.meshObject.Delete(ctx, uuid) } From 030131479e74369626ae1ae12448e814c20d8ceb Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 14 Apr 2026 07:56:08 +0200 Subject: [PATCH 126/215] feat: expose platform access_information in resource and data source Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- platform.go | 1 + 1 file changed, 1 insertion(+) diff --git a/platform.go b/platform.go index c276a8d3..08623eaa 100644 --- a/platform.go +++ b/platform.go @@ -24,6 +24,7 @@ type MeshPlatformSpec struct { Endpoint string `json:"endpoint" tfsdk:"endpoint"` SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + AccessInformation *string `json:"accessInformation,omitempty" tfsdk:"access_information"` LocationRef LocationRef `json:"locationRef" tfsdk:"location_ref"` ContributingWorkspaces types.Set[string] `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` Availability PlatformAvailability `json:"availability" tfsdk:"availability"` From 88732b604446ac9ce1b7538d750417c7d67f5c11 Mon Sep 17 00:00:00 2001 From: Stefan Tomm Date: Thu, 2 Apr 2026 08:59:51 +0200 Subject: [PATCH 127/215] feat: add refName property to AzureDevOps Building Block Definition implementation --- buildingblock_definition_version_implementation.go | 1 + 1 file changed, 1 insertion(+) diff --git a/buildingblock_definition_version_implementation.go b/buildingblock_definition_version_implementation.go index 9b67e9f3..c16c3971 100644 --- a/buildingblock_definition_version_implementation.go +++ b/buildingblock_definition_version_implementation.go @@ -61,6 +61,7 @@ type MeshBuildingBlockDefinitionGitLabPipelineImplementation struct { type MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation struct { Project string `json:"project" tfsdk:"project"` PipelineID string `json:"pipelineId" tfsdk:"pipeline_id"` + RefName *string `json:"refName,omitempty" tfsdk:"ref_name"` Async bool `json:"async" tfsdk:"async"` IntegrationRef MeshIntegrationRef `json:"integrationRef" tfsdk:"integration_ref"` } From 536cadd28098da521ac7da8879adecfbf647e2b5 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 23 Apr 2026 10:42:57 +0200 Subject: [PATCH 128/215] refactor: use Go 1.26 new(expression) feature --- buildingblock_definition_version_test.go | 3 +-- types/clienttypes_test.go | 4 +--- types/enum/enum.go | 4 +--- types/ptr/pointer.go | 5 ----- 4 files changed, 3 insertions(+), 13 deletions(-) delete mode 100644 types/ptr/pointer.go diff --git a/buildingblock_definition_version_test.go b/buildingblock_definition_version_test.go index 884a3ce3..302b8c1f 100644 --- a/buildingblock_definition_version_test.go +++ b/buildingblock_definition_version_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/meshcloud/terraform-provider-meshstack/client/types" - "github.com/meshcloud/terraform-provider-meshstack/client/types/ptr" ) var ( @@ -29,7 +28,7 @@ func TestMeshBuildingBlockDefinitionInput_UnmarshalJSON(t *testing.T) { {"empty", false, types.SecretOrAny{}, types.SecretOrAny{}, assert.NoError}, {"not_sensitive", false, types.SecretOrAny{Y: true}, types.SecretOrAny{Y: "some-string"}, assert.NoError}, {"not_sensitive_but_hash", false, types.SecretOrAny{Y: map[string]any{"hash": "some-hash-looks-like-secret"}}, types.SecretOrAny{}, assert.NoError}, - {"sensitive", true, types.SecretOrAny{}, types.SecretOrAny{X: types.Secret{Hash: ptr.To("some-hash")}}, assert.NoError}, + {"sensitive", true, types.SecretOrAny{}, types.SecretOrAny{X: types.Secret{Hash: new("some-hash")}}, assert.NoError}, {"sensitive_but_no_hash", true, types.SecretOrAny{Y: map[string]any{}}, types.SecretOrAny{}, func(t assert.TestingT, err error, msgAndArgs ...any) bool { return assert.ErrorContains(t, err, "got sensitive argument or default_value but variant Y is set instead") }}, diff --git a/types/clienttypes_test.go b/types/clienttypes_test.go index 8c4e7f33..569bf723 100644 --- a/types/clienttypes_test.go +++ b/types/clienttypes_test.go @@ -7,8 +7,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/meshcloud/terraform-provider-meshstack/client/types/ptr" ) func TestSecretOrAny(t *testing.T) { @@ -21,7 +19,7 @@ func TestSecretOrAny(t *testing.T) { } tests := []testCase{ {"empty", `null`, SecretOrAny{}, false, false}, - {"X plaintext", `{"plaintext":"some-secret"}`, SecretOrAny{X: Secret{Plaintext: ptr.To("some-secret")}}, true, false}, + {"X plaintext", `{"plaintext":"some-secret"}`, SecretOrAny{X: Secret{Plaintext: new("some-secret")}}, true, false}, {"Y string", `"some-string"`, SecretOrAny{Y: "some-string"}, false, true}, {"Y bool", `true`, SecretOrAny{Y: true}, false, true}, {"Y number", `1.23123`, SecretOrAny{Y: 1.23123}, false, true}, diff --git a/types/enum/enum.go b/types/enum/enum.go index 4934aa72..0f07fef5 100644 --- a/types/enum/enum.go +++ b/types/enum/enum.go @@ -3,8 +3,6 @@ package enum import ( "fmt" "strings" - - "github.com/meshcloud/terraform-provider-meshstack/client/types/ptr" ) func Of[T ~string](entries ...Entry[T]) Enum[T] { @@ -37,7 +35,7 @@ func (e Enum[T]) Markdown() string { type Entry[T ~string] string func (ee Entry[T]) Ptr() *T { - return ptr.To(ee.Unwrap()) + return new(ee.Unwrap()) } func (ee Entry[T]) Unwrap() T { diff --git a/types/ptr/pointer.go b/types/ptr/pointer.go deleted file mode 100644 index 6c3ee9bd..00000000 --- a/types/ptr/pointer.go +++ /dev/null @@ -1,5 +0,0 @@ -package ptr - -func To[T any](v T) *T { - return &v -} From dbece1a8fbd316c40fce3746d941d959bc9aa63b Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Mon, 4 May 2026 17:42:11 +0200 Subject: [PATCH 129/215] feat: Removes Azure Blueprint and OpenShift template support These functionalities are deprecated and not used in the platform anymore. They are full removed from the backend and not supported anymore via the API. --- platform_config_azure.go | 2 -- platform_config_openshift.go | 1 - platform_properties_openshift.go | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/platform_config_azure.go b/platform_config_azure.go index 966e1bf2..c753b160 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -15,8 +15,6 @@ type AzureReplicationConfig struct { B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` SubscriptionNamePattern string `json:"subscriptionNamePattern" tfsdk:"subscription_name_pattern"` GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` - BlueprintServicePrincipal string `json:"blueprintServicePrincipal" tfsdk:"blueprint_service_principal"` - BlueprintLocation string `json:"blueprintLocation" tfsdk:"blueprint_location"` AzureRoleMappings types.Set[AzureRoleMapping] `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` diff --git a/platform_config_openshift.go b/platform_config_openshift.go index e974b95d..e5dfce75 100644 --- a/platform_config_openshift.go +++ b/platform_config_openshift.go @@ -13,7 +13,6 @@ type OpenShiftReplicationConfig struct { ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` - EnableTemplateInstantiation bool `json:"enableTemplateInstantiation" tfsdk:"enable_template_instantiation"` OpenshiftRoleMappings types.Set[OpenShiftPlatformRoleMapping] `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` IdentityProviderName string `json:"identityProviderName" tfsdk:"identity_provider_name"` TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` diff --git a/platform_properties_openshift.go b/platform_properties_openshift.go index 15d67521..68a1578f 100644 --- a/platform_properties_openshift.go +++ b/platform_properties_openshift.go @@ -1,5 +1,5 @@ package client type OpenShiftPlatformProperties struct { - OpenShiftTemplate *string `json:"openShiftTemplate,omitempty" tfsdk:"openshift_template"` + // Intentionally left empty, as OpenShift platform properties were removed from the meshStack API. } From c6cbc5a68db453a08dba96acbf3afc877030b49c Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 29 Apr 2026 21:36:21 +0200 Subject: [PATCH 130/215] feat: add meshstack_api_key resource --- api_key.go | 61 +++++++++ api_key_permissions.go | 232 +++++++++++++++++++++++++++++++++ api_permissions.go | 86 ------------ client.go | 44 ++++--- client_kind.go | 2 + client_kind_test.go | 1 + internal/mesh_object_client.go | 11 +- platform_config_openshift.go | 12 +- 8 files changed, 332 insertions(+), 117 deletions(-) create mode 100644 api_key.go create mode 100644 api_key_permissions.go delete mode 100644 api_permissions.go diff --git a/api_key.go b/api_key.go new file mode 100644 index 00000000..2747019b --- /dev/null +++ b/api_key.go @@ -0,0 +1,61 @@ +package client + +import ( + "context" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/terraform-provider-meshstack/client/types" +) + +type MeshApiKey struct { + Metadata MeshApiKeyMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshApiKeySpec `json:"spec" tfsdk:"spec"` + Status *MeshApiKeyStatus `json:"status,omitempty" tfsdk:"status"` +} + +type MeshApiKeyMetadata struct { + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshApiKeySpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + Permissions types.Set[ApiPermission] `json:"permissions" tfsdk:"permissions"` + ExpiresAt *string `json:"expiresAt,omitempty" tfsdk:"expires_at"` +} + +type MeshApiKeyStatus struct { + ClientId string `json:"clientId" tfsdk:"client_id"` + ClientSecret *string `json:"clientSecret,omitempty" tfsdk:"client_secret"` +} + +type MeshApiKeyClient interface { + Create(ctx context.Context, apiKey *MeshApiKey) (*MeshApiKey, error) + Read(ctx context.Context, uuid string) (*MeshApiKey, error) + Update(ctx context.Context, uuid string, apiKey *MeshApiKey) (*MeshApiKey, error) + Delete(ctx context.Context, uuid string) error +} + +type meshApiKeyClient struct { + meshObject internal.MeshObjectClient[MeshApiKey] +} + +func newApiKeyClient(ctx context.Context, httpClient *internal.HttpClient) MeshApiKeyClient { + return meshApiKeyClient{internal.NewMeshObjectClient[MeshApiKey](ctx, httpClient, "v1-preview")} +} + +func (c meshApiKeyClient) Create(ctx context.Context, apiKey *MeshApiKey) (*MeshApiKey, error) { + return c.meshObject.Post(ctx, apiKey) +} + +func (c meshApiKeyClient) Read(ctx context.Context, uuid string) (*MeshApiKey, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshApiKeyClient) Update(ctx context.Context, uuid string, apiKey *MeshApiKey) (*MeshApiKey, error) { + return c.meshObject.Put(ctx, uuid, apiKey) +} + +func (c meshApiKeyClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} diff --git a/api_key_permissions.go b/api_key_permissions.go new file mode 100644 index 00000000..492315b9 --- /dev/null +++ b/api_key_permissions.go @@ -0,0 +1,232 @@ +package client + +import "strings" + +// API Key Permissions aligned with Kotlin ApiKeyRightMetadataRegistry. +// See https://docs.meshcloud.io/api/authentication/api-permissions/ + +// ApiPermission is a permission shortcode string used for JSON serialization. +type ApiPermission string + +// ApiKeyPermissions is a 3D structure: +// - outer: groups (e.g. "Building Blocks", "Projects") +// - middle: suffix groups within a group (e.g. DELETE, LIST, SAVE variants together) +// - inner: scope variants (e.g. [TENANT_DELETE, ADM_TENANT_DELETE]) +// +// Each permission is listed exactly as it appears in the API, no prefix derivation. +type ApiKeyPermissions [][][]ApiPermission + +// AllCodes returns all valid API key permission shortcodes (flattened). +func (p ApiKeyPermissions) AllCodes() []string { + var codes []string + for _, group := range p { + for _, suffixGroup := range group { + for _, code := range suffixGroup { + codes = append(codes, string(code)) + } + } + } + return codes +} + +// WorkspaceCodes returns only non-ADM_ permission shortcodes (workspace + platform builder scoped). +func (p ApiKeyPermissions) WorkspaceCodes() []string { + var codes []string + for _, group := range p { + for _, suffixGroup := range group { + for _, code := range suffixGroup { + if !strings.HasPrefix(string(code), "ADM_") { + codes = append(codes, string(code)) + } + } + } + } + return codes +} + +// MarkdownString returns an unordered markdown list of all permissions grouped by resource. +// Each bullet shows workspace codes, then MANAGED_ codes, then ADM_ codes separated by " and ". +func (p ApiKeyPermissions) MarkdownString() string { + var lines []string + for _, group := range p { + var workspace, managed, admin []string + for _, suffixGroup := range group { + for _, code := range suffixGroup { + s := string(code) + switch { + case strings.HasPrefix(s, "ADM_"): + admin = append(admin, "`"+s+"`") + case strings.HasPrefix(s, "MANAGED_"): + managed = append(managed, "`"+s+"`") + default: + workspace = append(workspace, "`"+s+"`") + } + } + } + + var parts []string + if len(workspace) > 0 { + parts = append(parts, strings.Join(workspace, "/")) + } + if len(managed) > 0 { + parts = append(parts, strings.Join(managed, "/")) + } + if len(admin) > 0 { + parts = append(parts, strings.Join(admin, "/")) + } + lines = append(lines, " - "+strings.Join(parts, " and ")) + } + return "\n" + strings.Join(lines, "\n") + "\n" +} + +// Permissions is the complete registry of API key permissions, +// aligned 1:1 with the Kotlin ApiKeyRightMetadataRegistry. +var Permissions = ApiKeyPermissions{ + // API Keys + { + {"APIKEY_DELETE", "ADM_APIKEY_DELETE"}, + {"APIKEY_LIST", "ADM_APIKEY_LIST"}, + {"APIKEY_SAVE", "ADM_APIKEY_SAVE"}, + }, + // Building Blocks + { + {"BUILDINGBLOCK_DELETE", "ADM_BUILDINGBLOCK_DELETE"}, + {"BUILDINGBLOCK_LIST", "ADM_BUILDINGBLOCK_LIST", "MANAGED_BUILDINGBLOCK_LIST"}, + {"BUILDINGBLOCK_SAVE", "ADM_BUILDINGBLOCK_SAVE"}, + }, + // Building Block Definitions + { + {"BUILDINGBLOCKDEFINITION_DELETE", "ADM_BUILDINGBLOCKDEFINITION_DELETE"}, + {"BUILDINGBLOCKDEFINITION_LIST", "ADM_BUILDINGBLOCKDEFINITION_LIST"}, + {"BUILDINGBLOCKDEFINITION_SAVE", "ADM_BUILDINGBLOCKDEFINITION_SAVE"}, + {"ADM_REVIEW_PUBLICATION"}, + }, + // Building Block Runs + { + {"MANAGED_BUILDINGBLOCKRUN_LIST", "ADM_BUILDINGBLOCKRUN_LIST"}, + {"MANAGED_BUILDINGBLOCKRUN_SAVE", "ADM_BUILDINGBLOCKRUN_SAVE"}, + {"MANAGED_BUILDINGBLOCKRUNSOURCE_SAVE", "ADM_BUILDINGBLOCKRUNSOURCE_SAVE"}, + }, + // Building Block Runners + { + {"BUILDINGBLOCKRUNNER_DELETE", "ADM_BUILDINGBLOCKRUNNER_DELETE"}, + {"BUILDINGBLOCKRUNNER_LIST", "ADM_BUILDINGBLOCKRUNNER_LIST"}, + {"BUILDINGBLOCKRUNNER_SAVE", "ADM_BUILDINGBLOCKRUNNER_SAVE"}, + }, + // Communication Definitions + { + {"COMMUNICATIONDEFINITION_DELETE", "ADM_COMMUNICATIONDEFINITION_DELETE"}, + {"COMMUNICATIONDEFINITION_LIST", "ADM_COMMUNICATIONDEFINITION_LIST"}, + {"COMMUNICATIONDEFINITION_SAVE", "ADM_COMMUNICATIONDEFINITION_SAVE"}, + }, + // Communications + { + {"COMMUNICATION_DELETE", "ADM_COMMUNICATION_DELETE"}, + {"COMMUNICATION_LIST", "ADM_COMMUNICATION_LIST"}, + {"COMMUNICATION_SAVE", "ADM_COMMUNICATION_SAVE"}, + }, + // Event Logs + { + {"EVENTLOG_LIST", "ADM_EVENTLOG_LIST"}, + }, + // Integrations + { + {"INTEGRATION_DELETE", "ADM_INTEGRATION_DELETE"}, + {"INTEGRATION_LIST", "ADM_INTEGRATION_LIST"}, + {"INTEGRATION_SAVE", "ADM_INTEGRATION_SAVE"}, + }, + // Landing Zones + { + {"LANDINGZONE_DELETE", "ADM_LANDINGZONE_DELETE"}, + {"LANDINGZONE_LIST", "ADM_LANDINGZONE_LIST"}, + {"LANDINGZONE_SAVE", "ADM_LANDINGZONE_SAVE"}, + }, + // Payment Methods + { + {"ADM_PAYMENTMETHOD_DELETE"}, + {"PAYMENTMETHOD_LIST", "ADM_PAYMENTMETHOD_LIST"}, + {"ADM_PAYMENTMETHOD_SAVE"}, + }, + // Platform Instances, Platform Types, Locations + { + {"PLATFORMINSTANCE_DELETE", "ADM_PLATFORMINSTANCE_DELETE"}, + {"PLATFORMINSTANCE_LIST", "ADM_PLATFORMINSTANCE_LIST"}, + {"PLATFORMINSTANCE_SAVE", "ADM_PLATFORMINSTANCE_SAVE"}, + }, + // Project Role Bindings + { + {"PROJECTPRINCIPALROLE_DELETE", "ADM_PROJECTPRINCIPALROLE_DELETE"}, + {"PROJECTPRINCIPALROLE_LIST", "ADM_PROJECTPRINCIPALROLE_LIST"}, + {"PROJECTPRINCIPALROLE_SAVE", "ADM_PROJECTPRINCIPALROLE_SAVE"}, + }, + // Project Roles + { + {"ADM_PROJECTROLE_DELETE"}, + {"ADM_PROJECTROLE_SAVE"}, + }, + // Projects + { + {"PROJECT_DELETE", "ADM_PROJECT_DELETE"}, + {"PROJECT_LIST", "ADM_PROJECT_LIST"}, + {"PROJECT_SAVE", "ADM_PROJECT_SAVE"}, + }, + // Service Instances + { + {"SERVICEINSTANCE_DELETE", "ADM_SERVICEINSTANCE_DELETE"}, + {"SERVICEINSTANCE_LIST", "ADM_SERVICEINSTANCE_LIST"}, + {"SERVICEINSTANCE_SAVE", "ADM_SERVICEINSTANCE_SAVE"}, + }, + // Tag Definitions + { + {"ADM_TAGDEFINITION_DELETE"}, + {"ADM_TAGDEFINITION_LIST"}, + {"ADM_TAGDEFINITION_SAVE"}, + }, + // Tenants + { + {"TENANT_DELETE", "ADM_TENANT_DELETE"}, + {"MANAGED_TENANT_IMPORT", "ADM_TENANT_IMPORT"}, + {"TENANT_LIST", "ADM_TENANT_LIST"}, + {"TENANT_SAVE", "ADM_TENANT_SAVE"}, + }, + // Terraform States + { + {"TFSTATE_DELETE", "ADM_TFSTATE_DELETE", "MANAGED_TFSTATE_DELETE"}, + {"TFSTATE_LIST", "ADM_TFSTATE_LIST", "MANAGED_TFSTATE_LIST"}, + {"TFSTATE_SAVE", "ADM_TFSTATE_SAVE", "MANAGED_TFSTATE_SAVE"}, + }, + // Users + { + {"ADM_USER_DELETE"}, + {"ADM_USER_LIST"}, + {"ADM_USER_SAVE"}, + }, + // Workspace Role Bindings + { + {"WORKSPACEPRINCIPALBINDING_DELETE", "ADM_WORKSPACEPRINCIPALBINDING_DELETE"}, + {"WORKSPACEPRINCIPALBINDING_LIST", "ADM_WORKSPACEPRINCIPALBINDING_LIST"}, + {"WORKSPACEPRINCIPALBINDING_SAVE", "ADM_WORKSPACEPRINCIPALBINDING_SAVE"}, + }, + // Workspace User Groups + { + {"WORKSPACEUSERGROUP_LIST", "ADM_WORKSPACEUSERGROUP_LIST"}, + }, + // Workspaces + { + {"WORKSPACE_DELETE", "ADM_WORKSPACE_DELETE"}, + {"WORKSPACE_LIST", "ADM_WORKSPACE_LIST"}, + {"WORKSPACE_SAVE", "ADM_WORKSPACE_SAVE"}, + }, +} + +// Convenience functions used by consumers. + +// AllApiKeyPermissions returns all valid API key permission shortcodes. +func AllApiKeyPermissions() []string { + return Permissions.AllCodes() +} + +// WorkspacePermissionCodes returns only workspace-scoped permission shortcodes. +func WorkspacePermissionCodes() []string { + return Permissions.WorkspaceCodes() +} diff --git a/api_permissions.go b/api_permissions.go deleted file mode 100644 index e7e89ed9..00000000 --- a/api_permissions.go +++ /dev/null @@ -1,86 +0,0 @@ -package client - -import ( - "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" -) - -// API Permissions as defined in https://docs.meshcloud.io/api/authentication/api-permissions/ - -type ApiPermission string - -// Workspace Permissions (non-admin). -var ( - WorkspacePermissions = enum.Enum[ApiPermission]{} - - PermissionBuildingBlockDefinitionDelete = WorkspacePermissions.Entry("BUILDINGBLOCKDEFINITION_DELETE") - PermissionBuildingBlockDefinitionList = WorkspacePermissions.Entry("BUILDINGBLOCKDEFINITION_LIST") - PermissionBuildingBlockDefinitionSave = WorkspacePermissions.Entry("BUILDINGBLOCKDEFINITION_SAVE") - - PermissionBuildingBlockRunnerDelete = WorkspacePermissions.Entry("BUILDINGBLOCKRUNNER_DELETE") - PermissionBuildingBlockRunnerList = WorkspacePermissions.Entry("BUILDINGBLOCKRUNNER_LIST") - PermissionBuildingBlockRunnerSave = WorkspacePermissions.Entry("BUILDINGBLOCKRUNNER_SAVE") - - PermissionBuildingBlockDelete = WorkspacePermissions.Entry("BUILDINGBLOCK_DELETE") - PermissionBuildingBlockList = WorkspacePermissions.Entry("BUILDINGBLOCK_LIST") - PermissionBuildingBlockSave = WorkspacePermissions.Entry("BUILDINGBLOCK_SAVE") - - PermissionCommunicationDefinitionDelete = WorkspacePermissions.Entry("COMMUNICATIONDEFINITION_DELETE") - PermissionCommunicationDefinitionList = WorkspacePermissions.Entry("COMMUNICATIONDEFINITION_LIST") - PermissionCommunicationDefinitionSave = WorkspacePermissions.Entry("COMMUNICATIONDEFINITION_SAVE") - - PermissionCommunicationDelete = WorkspacePermissions.Entry("COMMUNICATION_DELETE") - PermissionCommunicationList = WorkspacePermissions.Entry("COMMUNICATION_LIST") - PermissionCommunicationSave = WorkspacePermissions.Entry("COMMUNICATION_SAVE") - - PermissionEventLogList = WorkspacePermissions.Entry("EVENTLOG_LIST") - - PermissionIntegrationDelete = WorkspacePermissions.Entry("INTEGRATION_DELETE") - PermissionIntegrationList = WorkspacePermissions.Entry("INTEGRATION_LIST") - PermissionIntegrationSave = WorkspacePermissions.Entry("INTEGRATION_SAVE") - - PermissionLandingZoneDelete = WorkspacePermissions.Entry("LANDINGZONE_DELETE") - PermissionLandingZoneList = WorkspacePermissions.Entry("LANDINGZONE_LIST") - PermissionLandingZoneSave = WorkspacePermissions.Entry("LANDINGZONE_SAVE") - - PermissionManagedBuildingBlockRunSourceSave = WorkspacePermissions.Entry("MANAGED_BUILDINGBLOCKRUNSOURCE_SAVE") - PermissionManagedBuildingBlockRunList = WorkspacePermissions.Entry("MANAGED_BUILDINGBLOCKRUN_LIST") - PermissionManagedBuildingBlockRunSave = WorkspacePermissions.Entry("MANAGED_BUILDINGBLOCKRUN_SAVE") - PermissionManagedBuildingBlockList = WorkspacePermissions.Entry("MANAGED_BUILDINGBLOCK_LIST") - PermissionManagedTenantImport = WorkspacePermissions.Entry("MANAGED_TENANT_IMPORT") - - PermissionPaymentMethodList = WorkspacePermissions.Entry("PAYMENTMETHOD_LIST") - - PermissionPlatformInstanceDelete = WorkspacePermissions.Entry("PLATFORMINSTANCE_DELETE") - PermissionPlatformInstanceList = WorkspacePermissions.Entry("PLATFORMINSTANCE_LIST") - PermissionPlatformInstanceSave = WorkspacePermissions.Entry("PLATFORMINSTANCE_SAVE") - - PermissionProjectPrincipalRoleDelete = WorkspacePermissions.Entry("PROJECTPRINCIPALROLE_DELETE") - PermissionProjectPrincipalRoleList = WorkspacePermissions.Entry("PROJECTPRINCIPALROLE_LIST") - PermissionProjectPrincipalRoleSave = WorkspacePermissions.Entry("PROJECTPRINCIPALROLE_SAVE") - - PermissionProjectDelete = WorkspacePermissions.Entry("PROJECT_DELETE") - PermissionProjectList = WorkspacePermissions.Entry("PROJECT_LIST") - PermissionProjectSave = WorkspacePermissions.Entry("PROJECT_SAVE") - - PermissionServiceInstanceDelete = WorkspacePermissions.Entry("SERVICEINSTANCE_DELETE") - PermissionServiceInstanceList = WorkspacePermissions.Entry("SERVICEINSTANCE_LIST") - PermissionServiceInstanceSave = WorkspacePermissions.Entry("SERVICEINSTANCE_SAVE") - - PermissionTenantDelete = WorkspacePermissions.Entry("TENANT_DELETE") - PermissionTenantList = WorkspacePermissions.Entry("TENANT_LIST") - PermissionTenantSave = WorkspacePermissions.Entry("TENANT_SAVE") - - PermissionTfStateDelete = WorkspacePermissions.Entry("TFSTATE_DELETE") - PermissionTfStateList = WorkspacePermissions.Entry("TFSTATE_LIST") - PermissionTfStateSave = WorkspacePermissions.Entry("TFSTATE_SAVE") - - PermissionWorkspacePrincipalBindingDelete = WorkspacePermissions.Entry("WORKSPACEPRINCIPALBINDING_DELETE") - PermissionWorkspacePrincipalBindingList = WorkspacePermissions.Entry("WORKSPACEPRINCIPALBINDING_LIST") - PermissionWorkspacePrincipalBindingSave = WorkspacePermissions.Entry("WORKSPACEPRINCIPALBINDING_SAVE") - - PermissionWorkspaceUserGroupList = WorkspacePermissions.Entry("WORKSPACEUSERGROUP_LIST") - - PermissionWorkspaceDelete = WorkspacePermissions.Entry("WORKSPACE_DELETE") - PermissionWorkspaceList = WorkspacePermissions.Entry("WORKSPACE_LIST") - PermissionWorkspaceSave = WorkspacePermissions.Entry("WORKSPACE_SAVE") -) diff --git a/client.go b/client.go index a3c8eef0..010c1eb9 100644 --- a/client.go +++ b/client.go @@ -17,6 +17,7 @@ import ( var MinMeshStackVersion = version.MustParse("2026.10.0") type Client struct { + ApiKey MeshApiKeyClient BuildingBlock MeshBuildingBlockClient BuildingBlockV2 MeshBuildingBlockV2Client BuildingBlockDefinition MeshBuildingBlockDefinitionClient @@ -26,6 +27,7 @@ type Client struct { Location MeshLocationClient PaymentMethod MeshPaymentMethodClient Platform MeshPlatformClient + PlatformType MeshPlatformTypeClient Project MeshProjectClient ProjectGroupBinding MeshProjectGroupBindingClient ProjectUserBinding MeshProjectUserBindingClient @@ -36,7 +38,6 @@ type Client struct { Workspace MeshWorkspaceClient WorkspaceGroupBinding MeshWorkspaceGroupBindingClient WorkspaceUserBinding MeshWorkspaceUserBindingClient - PlatformType MeshPlatformTypeClient } func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret string, apiToken string) (Client, error) { @@ -70,26 +71,27 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret str } return Client{ - newBuildingBlockClient(ctx, httpClient), - newBuildingBlockV2Client(ctx, httpClient), - newBuildingBlockDefinitionClient(ctx, httpClient), - newBuildingBlockDefinitionVersionClient(ctx, httpClient), - newIntegrationClient(ctx, httpClient), - newLandingZoneClient(ctx, httpClient), - newLocationClient(ctx, httpClient), - newPaymentMethodClient(ctx, httpClient), - newPlatformClient(ctx, httpClient), - newProjectClient(ctx, httpClient), - newProjectGroupBindingClient(ctx, httpClient), - newProjectUserBindingClient(ctx, httpClient), - newServiceInstanceClient(ctx, httpClient), - newTagDefinitionClient(ctx, httpClient), - newTenantClient(ctx, httpClient), - newTenantV4Client(ctx, httpClient), - newWorkspaceClient(ctx, httpClient), - newWorkspaceGroupBindingClient(ctx, httpClient), - newWorkspaceUserBindingClient(ctx, httpClient), - newPlatformTypeClient(ctx, httpClient), + ApiKey: newApiKeyClient(ctx, httpClient), + BuildingBlock: newBuildingBlockClient(ctx, httpClient), + BuildingBlockV2: newBuildingBlockV2Client(ctx, httpClient), + BuildingBlockDefinition: newBuildingBlockDefinitionClient(ctx, httpClient), + BuildingBlockDefinitionVersion: newBuildingBlockDefinitionVersionClient(ctx, httpClient), + Integration: newIntegrationClient(ctx, httpClient), + LandingZone: newLandingZoneClient(ctx, httpClient), + Location: newLocationClient(ctx, httpClient), + PaymentMethod: newPaymentMethodClient(ctx, httpClient), + Platform: newPlatformClient(ctx, httpClient), + PlatformType: newPlatformTypeClient(ctx, httpClient), + Project: newProjectClient(ctx, httpClient), + ProjectGroupBinding: newProjectGroupBindingClient(ctx, httpClient), + ProjectUserBinding: newProjectUserBindingClient(ctx, httpClient), + ServiceInstance: newServiceInstanceClient(ctx, httpClient), + TagDefinition: newTagDefinitionClient(ctx, httpClient), + Tenant: newTenantClient(ctx, httpClient), + TenantV4: newTenantV4Client(ctx, httpClient), + Workspace: newWorkspaceClient(ctx, httpClient), + WorkspaceGroupBinding: newWorkspaceGroupBindingClient(ctx, httpClient), + WorkspaceUserBinding: newWorkspaceUserBindingClient(ctx, httpClient), }, nil } diff --git a/client_kind.go b/client_kind.go index 340a02f3..6bc91fad 100644 --- a/client_kind.go +++ b/client_kind.go @@ -2,6 +2,7 @@ package client // meshObjectKind provides typed constants for meshObject kind strings used across the provider. type meshObjectKind struct { + ApiKey string BuildingBlock string BuildingBlockDefinition string BuildingBlockDefinitionVersion string @@ -25,6 +26,7 @@ type meshObjectKind struct { } var MeshObjectKind = meshObjectKind{ + ApiKey: "meshApiKey", BuildingBlock: "meshBuildingBlock", BuildingBlockDefinition: "meshBuildingBlockDefinition", BuildingBlockDefinitionVersion: "meshBuildingBlockDefinitionVersion", diff --git a/client_kind_test.go b/client_kind_test.go index f011b00d..328acad3 100644 --- a/client_kind_test.go +++ b/client_kind_test.go @@ -10,6 +10,7 @@ import ( func TestKind(t *testing.T) { // verify hardcoded kind strings match InferKind for all client types + assert.Equal(t, internal.InferKind[MeshApiKey](), MeshObjectKind.ApiKey) assert.Equal(t, internal.InferKind[MeshBuildingBlock](), MeshObjectKind.BuildingBlock) assert.Equal(t, internal.InferKind[MeshBuildingBlockV2](), MeshObjectKind.BuildingBlock) assert.Equal(t, internal.InferKind[MeshBuildingBlockDefinition](), MeshObjectKind.BuildingBlockDefinition) diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index dcd65b82..2a6ff1d3 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -59,12 +59,15 @@ func InferKind[M any]() string { return versionSuffixRe.ReplaceAllString(kind, "") } +var pluralExceptions = map[string]string{ + // Add exceptions here as needed, e.g. "meshPolicy": "meshPolicies" +} + func pluralizeKind(kind string) string { - if strings.HasSuffix(kind, "y") { - // this is ok, as we don't have meshObjects ending in 'y' yet, so take this shortcut - panic(fmt.Sprintf("Correctly pluralizing meshObject kind '%s' is not supported yet", kind)) + if plural, ok := pluralExceptions[kind]; ok { + return plural } - return fmt.Sprintf("%ss", kind) + return kind + "s" } func (c MeshObjectClient[M]) meshObjectMimeType() string { diff --git a/platform_config_openshift.go b/platform_config_openshift.go index e5dfce75..49587d75 100644 --- a/platform_config_openshift.go +++ b/platform_config_openshift.go @@ -10,12 +10,12 @@ type OpenShiftPlatformConfig struct { } type OpenShiftReplicationConfig struct { - ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` - WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` - ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` - OpenshiftRoleMappings types.Set[OpenShiftPlatformRoleMapping] `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` - IdentityProviderName string `json:"identityProviderName" tfsdk:"identity_provider_name"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` + WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` + ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` + OpenshiftRoleMappings types.Set[OpenShiftPlatformRoleMapping] `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` + IdentityProviderName string `json:"identityProviderName" tfsdk:"identity_provider_name"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` } type OpenShiftMeteringConfig struct { From a68437456f3115acf7cceaa304386335e37196bc Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 5 May 2026 12:42:55 +0200 Subject: [PATCH 131/215] test: add cross-workspace BBD listing test --- buildingblock_definition.go | 3 ++- client.go | 4 ++++ internal/http_client.go | 36 ++++++++++++++++++++++------------ internal/mesh_object_client.go | 2 +- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/buildingblock_definition.go b/buildingblock_definition.go index 0262f38a..e69ba985 100644 --- a/buildingblock_definition.go +++ b/buildingblock_definition.go @@ -82,8 +82,9 @@ func newBuildingBlockDefinitionClient(ctx context.Context, httpClient *internal. func (c meshBuildingBlockDefinitionClient) List(ctx context.Context, workspaceIdentifier *string) ([]MeshBuildingBlockDefinition, error) { var options []internal.RequestOption + options = append(options, internal.WithUrlQuery("includeAllPublished", "true")) if workspaceIdentifier != nil { - options = append(options, internal.WithUrlQuery("workspaceIdentifier", *workspaceIdentifier)) + options = append(options, internal.WithUrlQuery("ownedByWorkspace", *workspaceIdentifier)) } return c.meshObject.List(ctx, options...) } diff --git a/client.go b/client.go index 010c1eb9..dc67f410 100644 --- a/client.go +++ b/client.go @@ -16,6 +16,10 @@ import ( var MinMeshStackVersion = version.MustParse("2026.10.0") +// HttpError represents an HTTP error response with status code. +// This error is returned when an HTTP request fails with a non-2XX status code. +type HttpError = internal.HttpError + type Client struct { ApiKey MeshApiKeyClient BuildingBlock MeshBuildingBlockClient diff --git a/internal/http_client.go b/internal/http_client.go index 0fee9d54..eeb1e4f3 100644 --- a/internal/http_client.go +++ b/internal/http_client.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -15,9 +14,26 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var ( - errNotFound = errors.New("request failed with status Not Found (404)") -) +// HttpError represents an HTTP error response with status code. +// This error is returned when an HTTP request fails with a non-2XX status code. +type HttpError struct { + StatusCode int + Message string +} + +func (e HttpError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message) +} + +// IsForbidden returns true if the error is a 403 Forbidden response. +func (e HttpError) IsForbidden() bool { + return e.StatusCode == http.StatusForbidden +} + +// IsNotFound returns true if the error is a 404 Not Found response. +func (e HttpError) IsNotFound() bool { + return e.StatusCode == http.StatusNotFound +} // HttpClient wraps [http.Client] with convenient request handling thanks to RequestOption. type HttpClient struct { @@ -63,15 +79,11 @@ func (c *HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Resp if res.StatusCode >= 200 && res.StatusCode <= 299 { return responseBody, nil } - var errs []error - if res.StatusCode == http.StatusNotFound { - errs = append(errs, errNotFound) + + return responseBody, HttpError{ + StatusCode: res.StatusCode, + Message: string(responseBody), } - errs = append(errs, - fmt.Errorf("request failed with status %d (not 2XX successful)", res.StatusCode), - fmt.Errorf("error response: %s", string(responseBody)), - ) - return responseBody, errors.Join(errs...) } func (c *HttpClient) buildRequest(ctx context.Context, method string, url url.URL, opts requestOptions) (*http.Request, error) { diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index 2a6ff1d3..92f027c9 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -77,7 +77,7 @@ func (c MeshObjectClient[M]) meshObjectMimeType() string { // Get retrieves a meshObject by ID. Returns nil if not found. func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (*M, error) { body, err := c.doAuthorizedRequest(ctx, http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) - if errors.Is(err, errNotFound) { + if httpErr, ok := errors.AsType[HttpError](err); ok && httpErr.IsNotFound() { return nil, nil } return unmarshalBody[M](body, err) From afbfed3cc1aa9b889640ed9563940fc6f6d6dd3f Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 8 May 2026 12:50:05 +0200 Subject: [PATCH 132/215] refactor: use Authoriztion.Header abstraction, improve error message --- api_key.go | 2 +- buildingblock.go | 2 +- buildingblock_definition.go | 2 +- buildingblock_definition_version.go | 2 +- buildingblock_v2.go | 2 +- client.go | 59 ++++++------------------ client_test.go | 69 ---------------------------- integration.go | 4 +- internal/auth.go | 71 +++++++++++++++++++++++++++++ internal/http_client.go | 54 ++++++++-------------- internal/http_error.go | 27 +++++++++++ internal/mesh_object_client.go | 61 ++++--------------------- landingzone.go | 2 +- location.go | 2 +- payment_method.go | 2 +- platform.go | 2 +- platform_type.go | 2 +- project.go | 2 +- project_group_binding.go | 2 +- project_user_binding.go | 2 +- service_instance.go | 2 +- tag_definition.go | 2 +- tenant.go | 2 +- tenant_v4.go | 2 +- workspace.go | 2 +- workspace_group_binding.go | 2 +- workspace_user_binding.go | 2 +- 27 files changed, 162 insertions(+), 223 deletions(-) delete mode 100644 client_test.go create mode 100644 internal/auth.go create mode 100644 internal/http_error.go diff --git a/api_key.go b/api_key.go index 2747019b..ec998904 100644 --- a/api_key.go +++ b/api_key.go @@ -40,7 +40,7 @@ type meshApiKeyClient struct { meshObject internal.MeshObjectClient[MeshApiKey] } -func newApiKeyClient(ctx context.Context, httpClient *internal.HttpClient) MeshApiKeyClient { +func newApiKeyClient(ctx context.Context, httpClient internal.HttpClient) MeshApiKeyClient { return meshApiKeyClient{internal.NewMeshObjectClient[MeshApiKey](ctx, httpClient, "v1-preview")} } diff --git a/buildingblock.go b/buildingblock.go index 97ecff7d..fa93b54c 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -82,7 +82,7 @@ type meshBuildingBlockClient struct { meshObject internal.MeshObjectClient[MeshBuildingBlock] } -func newBuildingBlockClient(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockClient { +func newBuildingBlockClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockClient { return meshBuildingBlockClient{internal.NewMeshObjectClient[MeshBuildingBlock](ctx, httpClient, "v1")} } diff --git a/buildingblock_definition.go b/buildingblock_definition.go index e69ba985..667f5fbf 100644 --- a/buildingblock_definition.go +++ b/buildingblock_definition.go @@ -74,7 +74,7 @@ type meshBuildingBlockDefinitionClient struct { meshObject internal.MeshObjectClient[MeshBuildingBlockDefinition] } -func newBuildingBlockDefinitionClient(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockDefinitionClient { +func newBuildingBlockDefinitionClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockDefinitionClient { return meshBuildingBlockDefinitionClient{ meshObject: internal.NewMeshObjectClient[MeshBuildingBlockDefinition](ctx, httpClient, "v1-preview"), } diff --git a/buildingblock_definition_version.go b/buildingblock_definition_version.go index 77bd0ed7..72c38aaf 100644 --- a/buildingblock_definition_version.go +++ b/buildingblock_definition_version.go @@ -197,7 +197,7 @@ type meshBuildingBlockDefinitionVersionClient struct { meshObject internal.MeshObjectClient[MeshBuildingBlockDefinitionVersion] } -func newBuildingBlockDefinitionVersionClient(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockDefinitionVersionClient { +func newBuildingBlockDefinitionVersionClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockDefinitionVersionClient { return meshBuildingBlockDefinitionVersionClient{ meshObject: internal.NewMeshObjectClient[MeshBuildingBlockDefinitionVersion](ctx, httpClient, "v1-preview"), } diff --git a/buildingblock_v2.go b/buildingblock_v2.go index db09c735..66ba7ae3 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -71,7 +71,7 @@ type meshBuildingBlockV2Client struct { meshObject internal.MeshObjectClient[MeshBuildingBlockV2] } -func newBuildingBlockV2Client(ctx context.Context, httpClient *internal.HttpClient) MeshBuildingBlockV2Client { +func newBuildingBlockV2Client(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockV2Client { return meshBuildingBlockV2Client{internal.NewMeshObjectClient[MeshBuildingBlockV2](ctx, httpClient, "v2-preview")} } diff --git a/client.go b/client.go index dc67f410..aabacccf 100644 --- a/client.go +++ b/client.go @@ -2,12 +2,9 @@ package client import ( "context" - "encoding/base64" - "encoding/json" "fmt" "net/http" "net/url" - "strings" "time" "github.com/meshcloud/terraform-provider-meshstack/client/internal" @@ -44,27 +41,22 @@ type Client struct { WorkspaceUserBinding MeshWorkspaceUserBindingClient } -func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret string, apiToken string) (Client, error) { - httpClient := &internal.HttpClient{ - Client: http.Client{Timeout: 5 * time.Minute}, - RootUrl: rootUrl, - UserAgent: userAgent, +type Authorization = internal.Authorization - // Putting authentication with meshStack API into HttpClient - // saves use from passing ApiKey/ApiSecret down to client factory methods below. - ApiKey: apiKey, - ApiSecret: apiSecret, - } +func NewApiTokenAuthorization(apiToken string) Authorization { + return internal.BearerTokenAuthorization{Token: apiToken} +} - if apiToken != "" { - httpClient.Authorization = "Bearer " + apiToken +func NewApiKeyAuthorization(apiKey, apiSecret string) Authorization { + return internal.NewClientSecretAuthorization("api/login", apiKey, apiSecret) +} - if expiresAt, err := parseTokenExpiration(apiToken); err == nil { - httpClient.AuthorizationExpiresAt = expiresAt - } else { - // If token has no expiration we assume it is valid for the default duration. - httpClient.AuthorizationExpiresAt = time.Now().Add(6 * time.Hour) - } +func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authorization) (Client, error) { + httpClient := internal.HttpClient{ + Client: &http.Client{Timeout: 5 * time.Minute}, + RootUrl: rootUrl, + UserAgent: userAgent, + Authorization: auth, } // Check meshStack version compatibility @@ -98,28 +90,3 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent, apiKey, apiSecret str WorkspaceUserBinding: newWorkspaceUserBindingClient(ctx, httpClient), }, nil } - -func parseTokenExpiration(token string) (time.Time, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return time.Time{}, fmt.Errorf("invalid token format") - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return time.Time{}, err - } - - var claims struct { - Exp int64 `json:"exp"` - } - if err := json.Unmarshal(payload, &claims); err != nil { - return time.Time{}, err - } - - if claims.Exp == 0 { - return time.Time{}, fmt.Errorf("expiration claim missing") - } - - return time.Unix(claims.Exp, 0), nil -} diff --git a/client_test.go b/client_test.go deleted file mode 100644 index 0fed1521..00000000 --- a/client_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package client - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseTokenExpiration(t *testing.T) { - // Helper to create a dummy JWT with a specific expiration time - createToken := func(expTime time.Time) string { - header := `{"alg":"HS256","typ":"JWT"}` - payload := map[string]any{ - "sub": "1234567890", - "name": "John Doe", - "exp": expTime.Unix(), - } - - payloadBytes, _ := json.Marshal(payload) - - encodedHeader := base64.RawURLEncoding.EncodeToString([]byte(header)) - encodedPayload := base64.RawURLEncoding.EncodeToString(payloadBytes) - signature := "dummy_signature" - - return fmt.Sprintf("%s.%s.%s", encodedHeader, encodedPayload, signature) - } - - fixedBaseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) - - t.Run("Valid token", func(t *testing.T) { - expTime := fixedBaseTime - token := createToken(expTime) - - parsedTime, err := parseTokenExpiration(token) - - require.NoError(t, err) - assert.Equal(t, expTime.Unix(), parsedTime.Unix()) - }) - - t.Run("Invalid format - not enough parts", func(t *testing.T) { - token := "invalid.token" - _, err := parseTokenExpiration(token) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid token format") - }) - - t.Run("Invalid Base64 payload", func(t *testing.T) { - token := "header.invalid_base64$.signature" - _, err := parseTokenExpiration(token) - assert.Error(t, err) - }) - - t.Run("Missing exp claim", func(t *testing.T) { - header := `{"alg":"HS256","typ":"JWT"}` - payload := `{"sub":"1234567890"}` // No exp - encodedHeader := base64.RawURLEncoding.EncodeToString([]byte(header)) - encodedPayload := base64.RawURLEncoding.EncodeToString([]byte(payload)) - token := fmt.Sprintf("%s.%s.sig", encodedHeader, encodedPayload) - - _, err := parseTokenExpiration(token) - require.Error(t, err) - assert.Contains(t, err.Error(), "expiration claim missing") - }) -} diff --git a/integration.go b/integration.go index 78cbdbaf..17536475 100644 --- a/integration.go +++ b/integration.go @@ -56,8 +56,8 @@ type meshIntegrationClientImpl struct { meshObject internal.MeshObjectClient[MeshIntegration] } -func newIntegrationClient(ctx context.Context, httpClient *internal.HttpClient) MeshIntegrationClient { - return &meshIntegrationClientImpl{internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1")} +func newIntegrationClient(ctx context.Context, httpClient internal.HttpClient) MeshIntegrationClient { + return meshIntegrationClientImpl{internal.NewMeshObjectClient[MeshIntegration](ctx, httpClient, "v1")} } func (c meshIntegrationClientImpl) Create(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) { diff --git a/internal/auth.go b/internal/auth.go new file mode 100644 index 00000000..4e0db61e --- /dev/null +++ b/internal/auth.go @@ -0,0 +1,71 @@ +package internal + +import ( + "context" + "fmt" + "time" +) + +type Authorization interface { + Header(ctx context.Context, client HttpClient) (string, error) +} + +func NewClientSecretAuthorization(loginApiPath, clientId, clientSecret string) Authorization { + return &clientSecretAuthorization{ + BearerTokenAuthorization{}, // empty token initially, is refreshed on demand in ensureValidToken + loginApiPath, + clientId, clientSecret, + time.Time{}, // expiry also set in ensureValidToken + } +} + +type BearerTokenAuthorization struct { + Token string +} + +func (auth BearerTokenAuthorization) Header(_ context.Context, _ HttpClient) (string, error) { + return fmt.Sprintf("Bearer %s", auth.Token), nil +} + +type clientSecretAuthorization struct { + BearerTokenAuthorization + LoginApiPath string + ClientId string + ClientSecret string + ExpiresAt time.Time +} + +func (auth *clientSecretAuthorization) Header(ctx context.Context, client HttpClient) (string, error) { + if err := auth.ensureValidToken(ctx, client); err != nil { + return "", err + } + return auth.BearerTokenAuthorization.Header(ctx, client) +} + +func (auth *clientSecretAuthorization) ensureValidToken(ctx context.Context, client HttpClient) error { + if auth.Token != "" && time.Until(auth.ExpiresAt) > 30*time.Second { + return nil + } + + loginApiUrl := client.RootUrl.JoinPath(auth.LoginApiPath) + + type loginRequest struct { + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + } + + type loginResponse struct { + Token string `json:"access_token"` + ExpireSec int `json:"expires_in"` + } + + loginResult, err := unmarshalBody[loginResponse](client.doRequest(ctx, "POST", loginApiUrl, + withPayload(loginRequest{ClientId: auth.ClientId, ClientSecret: auth.ClientSecret}, "application/json")), + ) + if err != nil { + return fmt.Errorf("login at %s with client id '%s' failed: %w", loginApiUrl, auth.ClientId, err) + } + auth.Token = loginResult.Token + auth.ExpiresAt = time.Now().Add(time.Duration(loginResult.ExpireSec) * time.Second) + return nil +} diff --git a/internal/http_client.go b/internal/http_client.go index eeb1e4f3..d6c22b59 100644 --- a/internal/http_client.go +++ b/internal/http_client.go @@ -9,45 +9,19 @@ import ( "net/http" "net/url" "slices" - "time" "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -// HttpError represents an HTTP error response with status code. -// This error is returned when an HTTP request fails with a non-2XX status code. -type HttpError struct { - StatusCode int - Message string -} - -func (e HttpError) Error() string { - return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message) -} - -// IsForbidden returns true if the error is a 403 Forbidden response. -func (e HttpError) IsForbidden() bool { - return e.StatusCode == http.StatusForbidden -} - -// IsNotFound returns true if the error is a 404 Not Found response. -func (e HttpError) IsNotFound() bool { - return e.StatusCode == http.StatusNotFound -} - // HttpClient wraps [http.Client] with convenient request handling thanks to RequestOption. type HttpClient struct { - http.Client - RootUrl *url.URL - UserAgent string - - ApiKey string - ApiSecret string - Authorization string - AuthorizationExpiresAt time.Time + *http.Client + RootUrl *url.URL + UserAgent string + Authorization Authorization } -func (c *HttpClient) doRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) ([]byte, error) { +func (c HttpClient) doRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) ([]byte, error) { options = slices.Insert(options, 0, withHeader("User-Agent", c.UserAgent), ) @@ -69,7 +43,15 @@ func (c *HttpClient) doRequest(ctx context.Context, method string, url *url.URL, return c.readBodyAndCheckSuccess(ctx, res) } -func (c *HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Response) ([]byte, error) { +func (c HttpClient) doAuthorizedRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) ([]byte, error) { + authHeader, err := c.Authorization.Header(ctx, c) + if err != nil { + return nil, err + } + return c.doRequest(ctx, method, url, append(options, withHeader("Authorization", authHeader))...) +} + +func (c HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Response) ([]byte, error) { responseBody, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) @@ -86,7 +68,7 @@ func (c *HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Resp } } -func (c *HttpClient) buildRequest(ctx context.Context, method string, url url.URL, opts requestOptions) (*http.Request, error) { +func (c HttpClient) buildRequest(ctx context.Context, method string, url url.URL, opts requestOptions) (*http.Request, error) { if len(opts.urlQueryParams) > 0 { query := url.Query() for k, v := range opts.urlQueryParams { @@ -114,13 +96,15 @@ func (c *HttpClient) buildRequest(ctx context.Context, method string, url url.UR return req, err } +// unmarshalBody is a generic helper to unmarshal a JSON response. +// It intentionally takes err as second argument to match doAuthorizedRequest and doRequest signatures. func unmarshalBody[T any](body []byte, err error) (*T, error) { if err != nil { return nil, err } var target T if err := json.Unmarshal(body, &target); err != nil { - return nil, err + return nil, fmt.Errorf("cannot unmarshal body: %w", err) } return &target, nil } @@ -129,7 +113,7 @@ type MeshInfo struct { Version version.Version `json:"version"` } -func (c *HttpClient) GetMeshInfo(ctx context.Context) (*MeshInfo, error) { +func (c HttpClient) GetMeshInfo(ctx context.Context) (*MeshInfo, error) { meshInfoUrl := c.RootUrl.JoinPath("/mesh/info") return unmarshalBody[MeshInfo](c.doRequest(ctx, "GET", meshInfoUrl)) } diff --git a/internal/http_error.go b/internal/http_error.go new file mode 100644 index 00000000..7b5d3e85 --- /dev/null +++ b/internal/http_error.go @@ -0,0 +1,27 @@ +package internal + +import ( + "fmt" + "net/http" +) + +// HttpError represents an HTTP error response with status code. +// This error is returned when an HTTP request fails with a non-2XX status code. +type HttpError struct { + StatusCode int + Message string +} + +func (e HttpError) Error() string { + return fmt.Sprintf("http error %d: %s", e.StatusCode, e.Message) +} + +// IsForbidden returns true if the error is a 403 Forbidden response. +func (e HttpError) IsForbidden() bool { + return e.StatusCode == http.StatusForbidden +} + +// IsNotFound returns true if the error is a 404 Not Found response. +func (e HttpError) IsNotFound() bool { + return e.StatusCode == http.StatusNotFound +} diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index 92f027c9..024ab4ec 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -11,7 +11,6 @@ import ( "regexp" "slices" "strings" - "time" "unicode" ) @@ -21,7 +20,7 @@ import ( // Also handles authentication in doAuthorizedRequest using the ApiKey/ApiSecret values, // which are embedded in HttpClient for convenient construction with NewMeshObjectClient. type MeshObjectClient[M any] struct { - *HttpClient + HttpClient Kind string ApiVersion string ApiUrl *url.URL @@ -31,7 +30,7 @@ type MeshObjectClient[M any] struct { // The meshObject kind is inferred from type M. T // The API URL is constructed from explicitApiPathElems if provided, // otherwise the pluralized and lowercased kind is used as a single element. -func NewMeshObjectClient[M any](ctx context.Context, httpClient *HttpClient, apiVersion string, explicitApiPathElems ...string) MeshObjectClient[M] { +func NewMeshObjectClient[M any](ctx context.Context, httpClient HttpClient, apiVersion string, explicitApiPathElems ...string) MeshObjectClient[M] { kind := InferKind[M]() if len(explicitApiPathElems) == 0 { @@ -75,12 +74,12 @@ func (c MeshObjectClient[M]) meshObjectMimeType() string { } // Get retrieves a meshObject by ID. Returns nil if not found. -func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (*M, error) { - body, err := c.doAuthorizedRequest(ctx, http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) +func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (resp *M, err error) { + resp, err = unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType()))) if httpErr, ok := errors.AsType[HttpError](err); ok && httpErr.IsNotFound() { return nil, nil } - return unmarshalBody[M](body, err) + return } // Post creates a new meshObject with the given payload. @@ -132,13 +131,6 @@ func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) pageNumber := 0 for { - body, err := c.doAuthorizedRequest(ctx, http.MethodGet, c.ApiUrl, append(options, - withAccept(c.meshObjectMimeType()), - WithUrlQuery("page", pageNumber), - )...) - if err != nil { - return result, fmt.Errorf("cannot fetch page %d: %w", pageNumber, err) - } type paginatedResponse struct { Embedded map[string][]M `json:"_embedded"` Page struct { @@ -146,9 +138,12 @@ func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) Number int `json:"number"` } `json:"page"` } - response, err := unmarshalBody[paginatedResponse](body, err) + response, err := unmarshalBody[paginatedResponse](c.doAuthorizedRequest(ctx, http.MethodGet, c.ApiUrl, append(options, + withAccept(c.meshObjectMimeType()), + WithUrlQuery("page", pageNumber), + )...)) if err != nil { - return result, fmt.Errorf("cannot unmarshal paginated response, page %d: %w", pageNumber, err) + return result, fmt.Errorf("error getting page %d: %w", pageNumber, err) } else if items, ok := response.Embedded[embeddedKey]; !ok { return result, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey) } else { @@ -160,39 +155,3 @@ func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) pageNumber++ } } - -func (c MeshObjectClient[M]) doAuthorizedRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) ([]byte, error) { - if err := c.ensureAuthorization(ctx); err != nil { - return nil, err - } - return c.doRequest(ctx, method, url, append(options, withHeader("Authorization", c.Authorization))...) -} - -func (c MeshObjectClient[M]) ensureAuthorization(ctx context.Context) error { - if c.Authorization != "" && time.Until(c.AuthorizationExpiresAt) > 30*time.Second { - return nil - } - - loginApiUrl := c.RootUrl.JoinPath("/api/login") - - type loginRequest struct { - ClientId string `json:"clientId"` - ClientSecret string `json:"clientSecret"` - } - - type loginResponse struct { - Token string `json:"access_token"` - ExpireSec int `json:"expires_in"` - } - - loginResult, err := unmarshalBody[loginResponse](c.doRequest(ctx, "POST", loginApiUrl, - withPayload(loginRequest{ClientId: c.ApiKey, ClientSecret: c.ApiSecret}, "application/json")), - ) - if err != nil { - return fmt.Errorf("login request to %s with API Key '%s' failed: %w", loginApiUrl, c.ApiKey, err) - } - - c.Authorization = fmt.Sprintf("Bearer %s", loginResult.Token) - c.AuthorizationExpiresAt = time.Now().Add(time.Duration(loginResult.ExpireSec) * time.Second) - return nil -} diff --git a/landingzone.go b/landingzone.go index 157d1649..bf7ac676 100644 --- a/landingzone.go +++ b/landingzone.go @@ -74,7 +74,7 @@ type meshLandingZoneClient struct { meshObject internal.MeshObjectClient[MeshLandingZone] } -func newLandingZoneClient(ctx context.Context, httpClient *internal.HttpClient) MeshLandingZoneClient { +func newLandingZoneClient(ctx context.Context, httpClient internal.HttpClient) MeshLandingZoneClient { return meshLandingZoneClient{internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1")} } diff --git a/location.go b/location.go index 33bf0a84..d3e3e0a6 100644 --- a/location.go +++ b/location.go @@ -48,7 +48,7 @@ type meshLocationClient struct { meshObject internal.MeshObjectClient[MeshLocation] } -func newLocationClient(ctx context.Context, httpClient *internal.HttpClient) MeshLocationClient { +func newLocationClient(ctx context.Context, httpClient internal.HttpClient) MeshLocationClient { return meshLocationClient{internal.NewMeshObjectClient[MeshLocation](ctx, httpClient, "v1")} } diff --git a/payment_method.go b/payment_method.go index 68670535..f32ffc11 100644 --- a/payment_method.go +++ b/payment_method.go @@ -46,7 +46,7 @@ type meshPaymentMethodClient struct { meshObject internal.MeshObjectClient[MeshPaymentMethod] } -func newPaymentMethodClient(ctx context.Context, httpClient *internal.HttpClient) MeshPaymentMethodClient { +func newPaymentMethodClient(ctx context.Context, httpClient internal.HttpClient) MeshPaymentMethodClient { return meshPaymentMethodClient{internal.NewMeshObjectClient[MeshPaymentMethod](ctx, httpClient, "v2")} } diff --git a/platform.go b/platform.go index 08623eaa..5de34b39 100644 --- a/platform.go +++ b/platform.go @@ -91,7 +91,7 @@ type meshPlatformClient struct { meshObject internal.MeshObjectClient[MeshPlatform] } -func newPlatformClient(ctx context.Context, httpClient *internal.HttpClient) MeshPlatformClient { +func newPlatformClient(ctx context.Context, httpClient internal.HttpClient) MeshPlatformClient { return meshPlatformClient{internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2")} } diff --git a/platform_type.go b/platform_type.go index 6160b32d..a13a77fe 100644 --- a/platform_type.go +++ b/platform_type.go @@ -55,7 +55,7 @@ type meshPlatformTypeClient struct { meshObject internal.MeshObjectClient[MeshPlatformType] } -func newPlatformTypeClient(ctx context.Context, httpClient *internal.HttpClient) MeshPlatformTypeClient { +func newPlatformTypeClient(ctx context.Context, httpClient internal.HttpClient) MeshPlatformTypeClient { return meshPlatformTypeClient{internal.NewMeshObjectClient[MeshPlatformType](ctx, httpClient, "v1")} } diff --git a/project.go b/project.go index 51e7477c..2d4f60db 100644 --- a/project.go +++ b/project.go @@ -47,7 +47,7 @@ type meshProjectClient struct { meshObject internal.MeshObjectClient[MeshProject] } -func newProjectClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectClient { +func newProjectClient(ctx context.Context, httpClient internal.HttpClient) MeshProjectClient { return meshProjectClient{internal.NewMeshObjectClient[MeshProject](ctx, httpClient, "v2")} } diff --git a/project_group_binding.go b/project_group_binding.go index a19f9705..90eda95e 100644 --- a/project_group_binding.go +++ b/project_group_binding.go @@ -20,7 +20,7 @@ type meshProjectGroupBindingClient struct { meshObject internal.MeshObjectClient[MeshProjectGroupBinding] } -func newProjectGroupBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectGroupBindingClient { +func newProjectGroupBindingClient(ctx context.Context, httpClient internal.HttpClient) MeshProjectGroupBindingClient { return meshProjectGroupBindingClient{internal.NewMeshObjectClient[MeshProjectGroupBinding](ctx, httpClient, "v3", "meshprojectbindings", "groupbindings")} } diff --git a/project_user_binding.go b/project_user_binding.go index 75c828fd..d6ed6ca6 100644 --- a/project_user_binding.go +++ b/project_user_binding.go @@ -20,7 +20,7 @@ type meshProjectUserBindingClient struct { meshObject internal.MeshObjectClient[MeshProjectUserBinding] } -func newProjectUserBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshProjectUserBindingClient { +func newProjectUserBindingClient(ctx context.Context, httpClient internal.HttpClient) MeshProjectUserBindingClient { return meshProjectUserBindingClient{internal.NewMeshObjectClient[MeshProjectUserBinding](ctx, httpClient, "v3", "meshprojectbindings", "userbindings")} } diff --git a/service_instance.go b/service_instance.go index 2cd537be..50c7dbb7 100644 --- a/service_instance.go +++ b/service_instance.go @@ -44,7 +44,7 @@ type MeshServiceInstanceFilter struct { PlanIdentifier *string } -func newServiceInstanceClient(ctx context.Context, httpClient *internal.HttpClient) MeshServiceInstanceClient { +func newServiceInstanceClient(ctx context.Context, httpClient internal.HttpClient) MeshServiceInstanceClient { return meshServiceInstanceClient{internal.NewMeshObjectClient[MeshServiceInstance](ctx, httpClient, "v2")} } diff --git a/tag_definition.go b/tag_definition.go index 61b11bd6..2844d0cd 100644 --- a/tag_definition.go +++ b/tag_definition.go @@ -79,7 +79,7 @@ type meshTagDefinitionClient struct { meshObject internal.MeshObjectClient[MeshTagDefinition] } -func newTagDefinitionClient(ctx context.Context, httpClient *internal.HttpClient) MeshTagDefinitionClient { +func newTagDefinitionClient(ctx context.Context, httpClient internal.HttpClient) MeshTagDefinitionClient { return meshTagDefinitionClient{internal.NewMeshObjectClient[MeshTagDefinition](ctx, httpClient, "v1")} } diff --git a/tenant.go b/tenant.go index d7929d36..3aa49374 100644 --- a/tenant.go +++ b/tenant.go @@ -57,7 +57,7 @@ type meshTenantClient struct { meshObject internal.MeshObjectClient[MeshTenant] } -func newTenantClient(ctx context.Context, httpClient *internal.HttpClient) MeshTenantClient { +func newTenantClient(ctx context.Context, httpClient internal.HttpClient) MeshTenantClient { return meshTenantClient{internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v3")} } diff --git a/tenant_v4.go b/tenant_v4.go index 8625136d..1968a100 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -74,7 +74,7 @@ type meshTenantV4Client struct { meshObject internal.MeshObjectClient[MeshTenantV4] } -func newTenantV4Client(ctx context.Context, httpClient *internal.HttpClient) MeshTenantV4Client { +func newTenantV4Client(ctx context.Context, httpClient internal.HttpClient) MeshTenantV4Client { return meshTenantV4Client{internal.NewMeshObjectClient[MeshTenantV4](ctx, httpClient, "v4-preview")} } diff --git a/workspace.go b/workspace.go index 1f47fa61..1950b504 100644 --- a/workspace.go +++ b/workspace.go @@ -43,7 +43,7 @@ type meshWorkspaceClient struct { meshObject internal.MeshObjectClient[MeshWorkspace] } -func newWorkspaceClient(ctx context.Context, httpClient *internal.HttpClient) meshWorkspaceClient { +func newWorkspaceClient(ctx context.Context, httpClient internal.HttpClient) meshWorkspaceClient { return meshWorkspaceClient{internal.NewMeshObjectClient[MeshWorkspace](ctx, httpClient, "v2")} } diff --git a/workspace_group_binding.go b/workspace_group_binding.go index 027afb0f..1ff9739e 100644 --- a/workspace_group_binding.go +++ b/workspace_group_binding.go @@ -20,7 +20,7 @@ type meshWorkspaceGroupBindingClient struct { meshObject internal.MeshObjectClient[MeshWorkspaceGroupBinding] } -func newWorkspaceGroupBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceGroupBindingClient { +func newWorkspaceGroupBindingClient(ctx context.Context, httpClient internal.HttpClient) MeshWorkspaceGroupBindingClient { return meshWorkspaceGroupBindingClient{internal.NewMeshObjectClient[MeshWorkspaceGroupBinding](ctx, httpClient, "v2", "meshworkspacebindings", "groupbindings")} } diff --git a/workspace_user_binding.go b/workspace_user_binding.go index 501f4ee2..13d3ebb7 100644 --- a/workspace_user_binding.go +++ b/workspace_user_binding.go @@ -20,7 +20,7 @@ type meshWorkspaceUserBindingClient struct { meshObject internal.MeshObjectClient[MeshWorkspaceUserBinding] } -func newWorkspaceUserBindingClient(ctx context.Context, httpClient *internal.HttpClient) MeshWorkspaceUserBindingClient { +func newWorkspaceUserBindingClient(ctx context.Context, httpClient internal.HttpClient) MeshWorkspaceUserBindingClient { return meshWorkspaceUserBindingClient{internal.NewMeshObjectClient[MeshWorkspaceUserBinding](ctx, httpClient, "v2", "meshworkspacebindings", "userbindings")} } From 02a757c54b2e610c3a7f6ec1fb3065d770e0bc66 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 8 May 2026 23:53:57 +0200 Subject: [PATCH 133/215] feat: retry GET/PUT requests and POST login and add internal.HttpClient unit test --- client.go | 19 ++- internal/auth.go | 18 ++- internal/http_client.go | 13 +- internal/http_client_test.go | 306 +++++++++++++++++++++++++++++++++++ internal/http_error.go | 6 +- internal/logging.go | 12 +- internal/retry.go | 267 ++++++++++++++++++++++++++++++ internal/retry_test.go | 64 ++++++++ 8 files changed, 683 insertions(+), 22 deletions(-) create mode 100644 internal/http_client_test.go create mode 100644 internal/retry.go create mode 100644 internal/retry_test.go diff --git a/client.go b/client.go index aabacccf..35616799 100644 --- a/client.go +++ b/client.go @@ -3,7 +3,6 @@ package client import ( "context" "fmt" - "net/http" "net/url" "time" @@ -47,17 +46,21 @@ func NewApiTokenAuthorization(apiToken string) Authorization { return internal.BearerTokenAuthorization{Token: apiToken} } +const apiLoginPath = "/api/login" + func NewApiKeyAuthorization(apiKey, apiSecret string) Authorization { - return internal.NewClientSecretAuthorization("api/login", apiKey, apiSecret) + return internal.NewClientSecretAuthorization(apiLoginPath, apiKey, apiSecret) } func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authorization) (Client, error) { - httpClient := internal.HttpClient{ - Client: &http.Client{Timeout: 5 * time.Minute}, - RootUrl: rootUrl, - UserAgent: userAgent, - Authorization: auth, - } + httpClient := internal.WithRetry( + internal.NewHttpClient(rootUrl, userAgent, auth), + internal.RetryOptions{ + MaxRetries: 10, + Backoff: internal.ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 10 * time.Second}, + WhitelistedPaths: map[string][]string{"POST": {apiLoginPath}}, + }, + ) // Check meshStack version compatibility if meshInfo, err := httpClient.GetMeshInfo(ctx); err != nil { diff --git a/internal/auth.go b/internal/auth.go index 4e0db61e..dccc658c 100644 --- a/internal/auth.go +++ b/internal/auth.go @@ -3,6 +3,8 @@ package internal import ( "context" "fmt" + "net/http" + "sync" "time" ) @@ -12,10 +14,9 @@ type Authorization interface { func NewClientSecretAuthorization(loginApiPath, clientId, clientSecret string) Authorization { return &clientSecretAuthorization{ - BearerTokenAuthorization{}, // empty token initially, is refreshed on demand in ensureValidToken - loginApiPath, - clientId, clientSecret, - time.Time{}, // expiry also set in ensureValidToken + LoginApiPath: loginApiPath, + ClientId: clientId, + ClientSecret: clientSecret, } } @@ -33,9 +34,12 @@ type clientSecretAuthorization struct { ClientId string ClientSecret string ExpiresAt time.Time + mu sync.Mutex } func (auth *clientSecretAuthorization) Header(ctx context.Context, client HttpClient) (string, error) { + auth.mu.Lock() + defer auth.mu.Unlock() if err := auth.ensureValidToken(ctx, client); err != nil { return "", err } @@ -43,7 +47,8 @@ func (auth *clientSecretAuthorization) Header(ctx context.Context, client HttpCl } func (auth *clientSecretAuthorization) ensureValidToken(ctx context.Context, client HttpClient) error { - if auth.Token != "" && time.Until(auth.ExpiresAt) > 30*time.Second { + const minimumTokenLifetime = 30 * time.Second + if auth.Token != "" && time.Until(auth.ExpiresAt) > minimumTokenLifetime { return nil } @@ -59,7 +64,7 @@ func (auth *clientSecretAuthorization) ensureValidToken(ctx context.Context, cli ExpireSec int `json:"expires_in"` } - loginResult, err := unmarshalBody[loginResponse](client.doRequest(ctx, "POST", loginApiUrl, + loginResult, err := unmarshalBody[loginResponse](client.doRequest(ctx, http.MethodPost, loginApiUrl, withPayload(loginRequest{ClientId: auth.ClientId, ClientSecret: auth.ClientSecret}, "application/json")), ) if err != nil { @@ -67,5 +72,6 @@ func (auth *clientSecretAuthorization) ensureValidToken(ctx context.Context, cli } auth.Token = loginResult.Token auth.ExpiresAt = time.Now().Add(time.Duration(loginResult.ExpireSec) * time.Second) + Log.Debug(ctx, "login successful", "url", loginApiUrl, "clientId", auth.ClientId, "expiresAt", auth.ExpiresAt) return nil } diff --git a/internal/http_client.go b/internal/http_client.go index d6c22b59..7b8c1492 100644 --- a/internal/http_client.go +++ b/internal/http_client.go @@ -9,10 +9,16 @@ import ( "net/http" "net/url" "slices" + "time" "github.com/meshcloud/terraform-provider-meshstack/client/version" ) +// NewHttpClient creates a new client with an underlying http.Client being a pointer to be modified by WithRetry. +func NewHttpClient(rootUrl *url.URL, userAgent string, auth Authorization) HttpClient { + return HttpClient{&http.Client{Timeout: 5 * time.Minute}, rootUrl, userAgent, auth} +} + // HttpClient wraps [http.Client] with convenient request handling thanks to RequestOption. type HttpClient struct { *http.Client @@ -44,6 +50,9 @@ func (c HttpClient) doRequest(ctx context.Context, method string, url *url.URL, } func (c HttpClient) doAuthorizedRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) ([]byte, error) { + if c.Authorization == nil { + return nil, fmt.Errorf("authorization is not configured") + } authHeader, err := c.Authorization.Header(ctx, c) if err != nil { return nil, err @@ -63,8 +72,8 @@ func (c HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Respo } return responseBody, HttpError{ - StatusCode: res.StatusCode, - Message: string(responseBody), + StatusCode: res.StatusCode, + ResponseBody: responseBody, } } diff --git a/internal/http_client_test.go b/internal/http_client_test.go new file mode 100644 index 00000000..b14d6252 --- /dev/null +++ b/internal/http_client_test.go @@ -0,0 +1,306 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/terraform-provider-meshstack/client/version" +) + +func TestHttpClient(t *testing.T) { + t.Run("GetMeshInfo success", func(t *testing.T) { + testLogger := installTestLogger(t) + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write([]byte(`{"version": "2026.10.0"}`)) + + assert.Equal(t, "/mesh/info", req.URL.Path) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "test-agent", req.Header.Get("User-Agent")) + }) + info, err := client.GetMeshInfo(t.Context()) + require.NoError(t, err) + assert.Equal(t, &MeshInfo{Version: version.Version{Major: 2026, Minor: 10}}, info) + assert.Equal(t, []string{ + fmt.Sprintf("request [url %s/mesh/info method GET headers User-Agent=test-agent body ]", client.RootUrl), + "response [status 200 body {\n \"version\": \"2026.10.0\"\n}]", + }, testLogger.Debugs) + assert.Empty(t, testLogger.Warns) + }) + + t.Run("GetMeshInfo with successful retry", func(t *testing.T) { + for _, retryableStatusCode := range []int{429, 502, 503, 504} { + t.Run(fmt.Sprintf("after code %d", retryableStatusCode), func(t *testing.T) { + nowUTC := mockTimeNowAsUTC(t) + + testLogger := installTestLogger(t) + retryTestBackoff := retryTestBackoff{WaitTime: 1 * time.Second} + retried := false + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + if !retried { + if retryableStatusCode == 429 { + resp.Header().Set("Retry-After", nowUTC.Add(1*time.Second).Format(http.TimeFormat)) + } + resp.WriteHeader(retryableStatusCode) + retried = true + return + } + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write([]byte(`{}`)) + }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff}) + + _, err := client.GetMeshInfo(t.Context()) + require.NoError(t, err) + if retryableStatusCode == 429 { + assert.Equal(t, 0, retryTestBackoff.Called) + } else { + assert.Equal(t, 1, retryTestBackoff.Called) + } + assert.Equal(t, []string{ + fmt.Sprintf("retrying request [status %d method GET path /mesh/info attempt 1/3 waitTime 1s]", retryableStatusCode), + }, testLogger.Warns) + }) + } + }) + + t.Run("GetMeshInfo with 2 retries exhausted", func(t *testing.T) { + testLogger := installTestLogger(t) + retryTestBackoff := retryTestBackoff{} + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(502) + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff}) + _, err := client.GetMeshInfo(t.Context()) + var httpErr HttpError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, 502, httpErr.StatusCode) + assert.Equal(t, 2, retryTestBackoff.Called) + assert.Equal(t, []string{ + "retrying request [status 502 method GET path /mesh/info attempt 1/2 waitTime 0s]", + "retrying request [status 502 method GET path /mesh/info attempt 2/2 waitTime 0s]", + }, testLogger.Warns) + assert.Equal(t, []string{ + fmt.Sprintf("request [url %s/mesh/info method GET headers User-Agent=test-agent body ]", client.RootUrl), + "response [status 502 body ]", + }, testLogger.Debugs) + + }) + + t.Run("GetMeshInfo with context cancelled during backoff", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(502) + cancel() // cancel context so the backoff wait is interrupted + }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) + _, err := client.GetMeshInfo(ctx) + require.ErrorIs(t, err, context.Canceled) + }) + + t.Run("doRequest with PATCH (not retried)", func(t *testing.T) { + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(200) + }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) + _, err := client.doRequest(t.Context(), http.MethodPatch, client.RootUrl) + require.NoError(t, err) + }) + + t.Run("doRequest with PUT replays body on retry", func(t *testing.T) { + attempt := 0 + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + assert.JSONEq(t, `{"key":"value"}`, string(body)) + attempt++ + if attempt == 1 { + resp.WriteHeader(502) + return + } + resp.WriteHeader(200) + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff{}}) + _, err := client.doRequest(t.Context(), http.MethodPut, client.RootUrl, withPayload(map[string]string{"key": "value"}, "application/json")) + require.NoError(t, err) + assert.Equal(t, 2, attempt) + }) + + t.Run("doAuthorizedRequest with BearerTokenAuthorization", func(t *testing.T) { + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + assert.Equal(t, "Bearer my-static-token", req.Header.Get("Authorization")) + resp.WriteHeader(http.StatusAccepted) + }) + client.Authorization = BearerTokenAuthorization{Token: "my-static-token"} + _, err := client.doAuthorizedRequest(t.Context(), http.MethodPost, client.RootUrl.JoinPath("create"), withPayload("content", "text/plain")) + require.NoError(t, err) + }) + + t.Run("doAuthorizedRequest with clientSecretAuthorization and retries", func(t *testing.T) { + t.Run("succeeds after second attempt", func(t *testing.T) { + retryTestBackoff := retryTestBackoff{} + requestsSeen := map[string]int{} // key is request path + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + defer func() { + requestsSeen[req.URL.Path]++ + }() + if requestsSeen[req.URL.Path] == 0 { + resp.WriteHeader(502) + return + } + switch req.URL.Path { + case "/login": + resp.WriteHeader(http.StatusOK) + // expires_in must be less than minimumTokenLifetime to trigger relogin on second doAuthorizedRequest call + _, _ = resp.Write([]byte(`{"access_token":"some-token", "expires_in": 10}`)) + case "/edit": + assert.Equal(t, "Bearer some-token", req.Header.Get("Authorization")) + resp.WriteHeader(http.StatusAccepted) + default: + t.Fatal("unexpected request", req.URL.Path) + } + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) + client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") + resp, err := client.doAuthorizedRequest(t.Context(), http.MethodPut, client.RootUrl.JoinPath("edit")) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, map[string]int{ + "/login": 2, + "/edit": 2, + }, requestsSeen) + + t.Run("expired token is refreshed with relogin", func(t *testing.T) { + _, err := client.doAuthorizedRequest(t.Context(), http.MethodPut, client.RootUrl.JoinPath("edit")) + require.NoError(t, err) + assert.Equal(t, 2, retryTestBackoff.Called) + assert.Equal(t, map[string]int{ + "/login": 3, + "/edit": 3, + }, requestsSeen) + }) + + // two different paths with one retry each, so backoff called twice in total + assert.Equal(t, 2, retryTestBackoff.Called) + }) + + t.Run("succeeds after redirect and retries", func(t *testing.T) { + retryTestBackoff := retryTestBackoff{} + requestsSeen := map[string]int{} + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + defer func() { + requestsSeen[req.URL.Path]++ + }() + if requestsSeen[req.URL.Path] == 0 { + resp.WriteHeader(502) + return + } + switch req.URL.Path { + case "/login": + body, _ := io.ReadAll(req.Body) + assert.JSONEq(t, `{"clientId":"test-client","clientSecret":"test-client-secret"}`, string(body)) + http.Redirect(resp, req, "/login-target", http.StatusTemporaryRedirect) + case "/login-target": + body, _ := io.ReadAll(req.Body) + assert.JSONEq(t, `{"clientId":"test-client","clientSecret":"test-client-secret"}`, string(body)) + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write([]byte(`{"access_token":"redirected-token", "expires_in": 3600}`)) + case "/edit": + assert.Equal(t, "Bearer redirected-token", req.Header.Get("Authorization")) + resp.WriteHeader(http.StatusAccepted) + default: + t.Fatal("unexpected request", req.URL.Path) + } + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) + client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") + _, err := client.doAuthorizedRequest(t.Context(), http.MethodPut, client.RootUrl.JoinPath("edit")) + require.NoError(t, err) + assert.Equal(t, map[string]int{ + "/login": 2, // 1st: 502, 2nd: 307 redirect + "/login-target": 2, // 1st: 502, 2nd: 200 + "/edit": 2, // 1st: 502, 2nd: 202 + }, requestsSeen) + assert.Equal(t, 3, retryTestBackoff.Called) // one retry each for /login, /login-target, /edit + }) + + t.Run("fails constantly at login", func(t *testing.T) { + retryTestBackoff := retryTestBackoff{} + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, r *http.Request) { + resp.WriteHeader(503) + }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) + client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") + _, err := client.doAuthorizedRequest(t.Context(), http.MethodPut, client.RootUrl.JoinPath("edit")) + require.ErrorContains(t, err, fmt.Sprintf("login at %s/login with client id 'test-client' failed", client.RootUrl)) + var httpErr HttpError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, 503, httpErr.StatusCode) + assert.Equal(t, 2, retryTestBackoff.Called) + }) + + }) +} + +func mockTimeNowAsUTC(t *testing.T) time.Time { + t.Helper() + now := time.Now().UTC().Truncate(time.Second) + timeNow = func() time.Time { return now } + t.Cleanup(func() { + timeNow = time.Now + }) + return now +} + +func newTestClientWithServer(t *testing.T, handlerFunc http.HandlerFunc) HttpClient { + t.Helper() + server := httptest.NewServer(handlerFunc) + t.Cleanup(server.Close) + rootUrl, err := url.Parse(server.URL) + require.NoError(t, err) + client := server.Client() + return HttpClient{ + Client: client, + RootUrl: rootUrl, + UserAgent: "test-agent", + } +} + +func installTestLogger(t *testing.T) *testLogger { + t.Helper() + testLogger := &testLogger{} + previousLog := Log + Log = testLogger + t.Cleanup(func() { + Log = previousLog + }) + return testLogger +} + +type testLogger struct { + Debugs []string + Infos []string + Warns []string +} + +func (c *testLogger) Debug(_ context.Context, msg string, args ...any) { + c.Debugs = append(c.Debugs, fmt.Sprintf("%s %v", msg, args)) +} + +func (c *testLogger) Info(_ context.Context, msg string, args ...any) { + c.Infos = append(c.Infos, fmt.Sprintf("%s %v", msg, args)) +} + +func (c *testLogger) Warn(_ context.Context, msg string, args ...any) { + c.Warns = append(c.Warns, fmt.Sprintf("%s %v", msg, args)) +} + +type retryTestBackoff struct { + WaitTime time.Duration + Called int +} + +func (b *retryTestBackoff) Calculate(int) time.Duration { + b.Called++ + return b.WaitTime +} diff --git a/internal/http_error.go b/internal/http_error.go index 7b5d3e85..92030fc0 100644 --- a/internal/http_error.go +++ b/internal/http_error.go @@ -8,12 +8,12 @@ import ( // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. type HttpError struct { - StatusCode int - Message string + StatusCode int + ResponseBody []byte } func (e HttpError) Error() string { - return fmt.Sprintf("http error %d: %s", e.StatusCode, e.Message) + return fmt.Sprintf("http error %d, response '%s'", e.StatusCode, string(e.ResponseBody)) } // IsForbidden returns true if the error is a 403 Forbidden response. diff --git a/internal/logging.go b/internal/logging.go index b45315e7..d82cda89 100644 --- a/internal/logging.go +++ b/internal/logging.go @@ -14,19 +14,25 @@ import ( var Log Logger = noopLogger{} -// Logger only supports Debug and Info log levels. +// Logger supports Debug, Info, and Warn log levels. +// Note that msg is a short, descriptive statement what is logged, and args are key value pairs (values are string or implement fmt.Stringer). type Logger interface { - Info(ctx context.Context, msg string, args ...any) Debug(ctx context.Context, msg string, args ...any) + Info(ctx context.Context, msg string, args ...any) + Warn(ctx context.Context, msg string, args ...any) } type noopLogger struct{} +func (n noopLogger) Debug(context.Context, string, ...any) { + // do nothing +} + func (n noopLogger) Info(context.Context, string, ...any) { // do nothing } -func (n noopLogger) Debug(context.Context, string, ...any) { +func (n noopLogger) Warn(context.Context, string, ...any) { // do nothing } diff --git a/internal/retry.go b/internal/retry.go new file mode 100644 index 00000000..6906422d --- /dev/null +++ b/internal/retry.go @@ -0,0 +1,267 @@ +package internal + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "strconv" + "sync" + "time" +) + +// WithRetry sets up the given client to retry certain requests. +// GET and PUT are retried by default, POST only if the path is explicitly whitelisted. +// See RetryOptions. +func WithRetry(c HttpClient, options RetryOptions) HttpClient { + next := http.DefaultTransport + if c.Transport != nil { + next = c.Transport + } + whitelistedByMethodAndUrl := func() (m map[string]*sync.Map) { + m = make(map[string]*sync.Map) + for method, paths := range options.WhitelistedPaths { + m[method] = new(sync.Map) + for _, path := range paths { + m[method].Store(c.RootUrl.JoinPath(path).String(), nil) + } + } + return + }() + c.Transport = &retryRoundTripper{ + Next: next, + MaxRetries: options.MaxRetries, + // ShouldRetryRequest checks if the request method/path is eligible for retry. + ShouldRetryRequest: func(req *http.Request) (retry bool) { + if options.Backoff == nil { + return false + } + switch req.Method { + case http.MethodGet, http.MethodPut: + return true + } + if whitelisted, found := whitelistedByMethodAndUrl[req.Method]; found { + _, retry = whitelisted.Load(req.URL.String()) + } + return + }, + // ShouldRetryResponse returns the backoff policy if the response/error indicates a retryable condition, + // otherwise nil is returned to indicate no retry. + ShouldRetryResponse: func(resp *http.Response, err error) RetryBackoff { + if err != nil { + return options.Backoff + } + switch resp.StatusCode { + case http.StatusTooManyRequests, http.StatusServiceUnavailable: + return retryAfterBackoff{Response: resp, Fallback: options.Backoff} + case http.StatusBadGateway, http.StatusGatewayTimeout: + return options.Backoff + case http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + if locationRedirectUrl, _ := resp.Request.URL.Parse(resp.Header.Get("Location")); locationRedirectUrl != nil { + if whitelisted, found := whitelistedByMethodAndUrl[resp.Request.Method]; found { + whitelisted.Store(locationRedirectUrl.String(), nil) + } + } + return nil + default: + return nil + } + }, + } + return c // for fluent API +} + +// RetryOptions configure WithRetry. +type RetryOptions struct { + // MaxRetries limits the attempts to retries. If zero, retries will never be attempted. + MaxRetries int + // Backoff to use when retrying. If nil, retries will never be attempted. + Backoff RetryBackoff + // WhitelistedPaths allow methods beyond GET and PUT to be retried as well, see WithRetry. + WhitelistedPaths map[string][]string +} + +// RetryBackoff calculates the duration to wait before the next retry attempt. +type RetryBackoff interface { + Calculate(attempt int) time.Duration +} + +// ExponentialBackoff increases the backoff exponentially: minWait * 2^(attempt-1). +type ExponentialBackoff struct { + MinWait, MaxWait time.Duration +} + +func (b ExponentialBackoff) Calculate(attempt int) time.Duration { + nextWait := time.Duration(math.Pow(2, float64(attempt-1))) * b.MinWait + if b.MaxWait > 0 && nextWait > b.MaxWait { + return b.MaxWait + } + return nextWait +} + +var timeNow = time.Now + +type retryAfterBackoff struct { + Response *http.Response + Fallback RetryBackoff +} + +func (b retryAfterBackoff) Calculate(attempt int) (waitTime time.Duration) { + defer func() { + const maxRetryAfterWaitTime = 5 * time.Minute + if waitTime < 0 { + waitTime = b.Fallback.Calculate(attempt) + } else if waitTime > maxRetryAfterWaitTime { + waitTime = maxRetryAfterWaitTime + } + }() + + // Parse the Retry-After header from a response. + // It supports both delay-seconds and HTTP-date formats (RFC 7231 §7.1.3). + + header := b.Response.Header.Get("Retry-After") + if header == "" { + return -1 + } + + // Try as delay-seconds first. + if seconds, err := strconv.ParseInt(header, 10, 64); err == nil { + return time.Duration(seconds) * time.Second + } + + // Try as HTTP-date (RFC 7231). + if date, err := http.ParseTime(header); err == nil { + return date.Sub(timeNow()) + } + return -1 +} + +// retryRoundTripper wraps an http.RoundTripper to retry failed requests. +// See WithRetry for which methods are retried. +type retryRoundTripper struct { + Next http.RoundTripper + MaxRetries int + ShouldRetryRequest func(req *http.Request) bool + ShouldRetryResponse func(resp *http.Response, err error) RetryBackoff +} + +func (r *retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if !r.ShouldRetryRequest(req) { + return r.Next.RoundTrip(req) + } + req = makeRequestBodyRetryable(req) + for attempt := 1; ; attempt++ { + resp, err := r.Next.RoundTrip(req) + if errors.Is(err, errRetryableBodyClose) { + return resp, err + } + backoff := r.ShouldRetryResponse(resp, err) + // No retry needed or no more retries left — return as-is. + if backoff == nil || attempt > r.MaxRetries { + return resp, err + } + drainAndCloseResponseBody(req.Context(), resp) + if req.GetBody != nil { + if body, err := req.GetBody(); err != nil { + return nil, err + } else { + req.Body = body + } + } + waitTime := backoff.Calculate(attempt) + Log.Warn(req.Context(), "retrying request", append( + func() []any { + if err != nil { + return []any{"error", err.Error()} + } + return []any{"status", resp.StatusCode} + }(), + "method", req.Method, + "path", req.URL.Path, + "attempt", fmt.Sprintf("%d/%d", attempt, r.MaxRetries), + "waitTime", waitTime, + )...) + timer := time.NewTimer(waitTime) + select { + case <-req.Context().Done(): + timer.Stop() + return nil, req.Context().Err() + case <-timer.C: + } + } +} + +func makeRequestBodyRetryable(req *http.Request) *http.Request { + if req.Body == nil { + return req + } + // If GetBody already returns independent readers (e.g. set by http.NewRequestWithContext + // for *bytes.Buffer, *bytes.Reader, *strings.Reader), use it as-is for retries. + if req.GetBody != nil { + return req + } + body := retryableBody{Closer: req.Body} + body.Reader = io.TeeReader(req.Body, &body.Buffer) + result := req.Clone(req.Context()) + result.Body = &body + result.GetBody = nil + return result +} + +// retryableBody lazily captures request body bytes on the first read and replays them on retries. +// Buffer is filled via TeeReader as the transport reads during the first request. On Close, the +// source is released and subsequent reads replay from Buffer via bytes.NewReader. +type retryableBody struct { + io.Reader + io.Closer + Buffer appendWriter +} + +var errRetryableBodyClose = errors.New("retryableBody failed to close") + +func (b *retryableBody) Close() error { + // Drain remaining bytes through the TeeReader to ensure Buffer captures the full body, + // even if the transport only partially read it (e.g. connection reset mid-write). + if _, err := io.Copy(io.Discard, b.Reader); err != nil { + return errors.Join(err, errRetryableBodyClose) + } + // On first close, close the Body and use the b.Buffer from now on + if b.Closer != nil { + if err := b.Closer.Close(); err != nil { + return errors.Join(err, errRetryableBodyClose) + } + } + b.Closer = nil + b.Reader = bytes.NewReader(b.Buffer) + return nil +} + +// appendWriter is an io.Writer that appends to a []byte slice. +// Helper for retryableBody.Buffer. +type appendWriter []byte + +func (w *appendWriter) Write(p []byte) (int, error) { + *w = append(*w, p...) + return len(p), nil +} + +// drainAndCloseResponseBody reads up to maxBytes from the response body before closing it. +// Draining enables Go's http.Transport to reuse the underlying TCP connection for +// subsequent requests. The maxBytes limit prevents getting stuck on large or slow +// responses — if the body exceeds this limit, the connection won't be reused, but +// we won't block indefinitely either. +func drainAndCloseResponseBody(ctx context.Context, resp *http.Response) { + const maxBytes = 16 * 1024 + if resp != nil && resp.Body != nil { + drainedBytes, err := io.CopyN(io.Discard, resp.Body, maxBytes) + if err != nil && !errors.Is(err, io.EOF) { + Log.Debug(ctx, fmt.Sprintf("failed to drain response body: %s", err.Error())) + } + if err := resp.Body.Close(); err != nil { + Log.Debug(ctx, fmt.Sprintf("failed to close response body after draining %d bytes: %s", drainedBytes, err.Error())) + } + } +} diff --git a/internal/retry_test.go b/internal/retry_test.go new file mode 100644 index 00000000..a643eddf --- /dev/null +++ b/internal/retry_test.go @@ -0,0 +1,64 @@ +package internal + +import ( + "fmt" + "net/http" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestExponentialBackoff_Calculate(t *testing.T) { + tests := []struct { + attempt int + want time.Duration + }{ + {1, 1 * time.Second}, + {2, 2 * time.Second}, + {3, 4 * time.Second}, + {4, 5 * time.Second}, + {5, 5 * time.Second}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("attempt %d", tt.attempt), func(t *testing.T) { + b := ExponentialBackoff{ + MinWait: 1 * time.Second, + MaxWait: 5 * time.Second, + } + assert.Equalf(t, tt.want, b.Calculate(tt.attempt), "Calculate(%v)", tt.attempt) + }) + } +} + +func TestRetryAfterBackoff(t *testing.T) { + // synctest bubble starts at 2000-01-01T00:00:00Z + bubbleStart := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + fallback := ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 10 * time.Second} + + tests := []struct { + name string + header string + want time.Duration + }{ + {"delay-seconds", "30", 30 * time.Second}, + {"zero seconds", "0", 0}, // RFC: retry immediately + {"capped at 5 minutes", "600", 5 * time.Minute}, // capped + {"empty header", "", 1 * time.Second}, // falls back + {"unparseable header", "not-a-number-or-date", 1 * time.Second}, // falls back + {"HTTP-date in the past", bubbleStart.Add(-10 * time.Second).Format(http.TimeFormat), 1 * time.Second}, // falls back + {"HTTP-date in the future", bubbleStart.Add(45 * time.Second).Format(http.TimeFormat), 45 * time.Second}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + b := retryAfterBackoff{ + Response: &http.Response{Header: http.Header{"Retry-After": {tt.header}}}, + Fallback: fallback, + } + assert.Equal(t, tt.want, b.Calculate(1)) + }) + }) + } +} From 91c85f4237e2163221e7edea8b14c05e3c579133 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Wed, 6 May 2026 09:24:57 +0200 Subject: [PATCH 134/215] feat: adapt building_block_v2 to moved createdOn field in upstream API The meshStack API has moved the building block creation timestamp from metadata.createdOn to status.lifecycle.createdOn. This change updates the Terraform provider to reflect the new API structure by: Note: Existing Terraform state will need a refresh after upgrading the provider to migrate from the old path to the new one. --- buildingblock_v2.go | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 66ba7ae3..42a73478 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -24,11 +24,8 @@ type MeshBuildingBlockV2 struct { } type MeshBuildingBlockV2Metadata struct { - Uuid string `json:"uuid" tfsdk:"uuid"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - CreatedOn string `json:"createdOn" tfsdk:"created_on"` - MarkedForDeletionOn *string `json:"markedForDeletionOn" tfsdk:"marked_for_deletion_on"` - MarkedForDeletionBy *string `json:"markedForDeletionBy" tfsdk:"marked_for_deletion_by"` + Uuid string `json:"uuid" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } type MeshBuildingBlockV2Spec struct { @@ -54,10 +51,18 @@ type MeshBuildingBlockV2Create struct { Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` } +type MeshBuildingBlockV2Lifecycle struct { + State string `json:"state" tfsdk:"state"` + CreatedOn string `json:"createdOn" tfsdk:"created_on"` + MarkedForDeletionOn *string `json:"markedForDeletionOn" tfsdk:"marked_for_deletion_on"` + MarkedForDeletionBy *string `json:"markedForDeletionBy" tfsdk:"marked_for_deletion_by"` +} + type MeshBuildingBlockV2Status struct { - Status string `json:"status" tfsdk:"status"` - Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` - ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + Status string `json:"status" tfsdk:"status"` + Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` } type MeshBuildingBlockV2Client interface { From 135a43b0f05f45788e659baca2d061386e28553c Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Thu, 7 May 2026 17:21:03 +0200 Subject: [PATCH 135/215] fix: adapt building block definition after changes in upstream API --- buildingblock_definition.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/buildingblock_definition.go b/buildingblock_definition.go index 667f5fbf..5259cc14 100644 --- a/buildingblock_definition.go +++ b/buildingblock_definition.go @@ -22,7 +22,10 @@ type MeshBuildingBlockDefinitionMetadata struct { Tags map[string][]string `json:"tags" tfsdk:"tags"` } -type BuildingBlockDefinitionSupportedPlatform string +type BuildingBlockDefinitionSupportedPlatform struct { + Kind string `json:"kind" tfsdk:"kind"` + Name string `json:"name" tfsdk:"name"` +} type MeshBuildingBlockDefinitionSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` @@ -34,11 +37,9 @@ type MeshBuildingBlockDefinitionSpec struct { SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` // NotificationSubscribers can also specify emails with prefix 'email:', so it's not only usernames (as the JSON field name suggests)! - NotificationSubscribers types.Set[string] `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` - Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` - // SupportedPlatforms are currently platform types only. Specifying single platforms is currently unsupported. - // Have this list of string with a dedicated type, to convert it to/from Platform Type refs. - SupportedPlatforms types.Set[BuildingBlockDefinitionSupportedPlatform] `json:"supportedPlatforms" tfsdk:"supported_platforms"` + NotificationSubscribers types.Set[string] `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` + Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` + SupportedPlatforms types.Set[BuildingBlockDefinitionSupportedPlatform] `json:"supportedPlatforms" tfsdk:"supported_platforms"` } type MeshBuildingBlockDefinitionStatusVersion struct { From 686a3bcda0f9ba0ab902fc121af8e14e8f4ec222 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 12 May 2026 11:31:02 +0200 Subject: [PATCH 136/215] fix: do not expose lifecycle in BB schema (resource/datasource) see 6b5b41dce00a9c4240b59c04d73779141e00e1cc --- buildingblock_v2.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 42a73478..d80d48a6 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -51,18 +51,10 @@ type MeshBuildingBlockV2Create struct { Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` } -type MeshBuildingBlockV2Lifecycle struct { - State string `json:"state" tfsdk:"state"` - CreatedOn string `json:"createdOn" tfsdk:"created_on"` - MarkedForDeletionOn *string `json:"markedForDeletionOn" tfsdk:"marked_for_deletion_on"` - MarkedForDeletionBy *string `json:"markedForDeletionBy" tfsdk:"marked_for_deletion_by"` -} - type MeshBuildingBlockV2Status struct { - Status string `json:"status" tfsdk:"status"` - Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` - ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` - Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` + Status string `json:"status" tfsdk:"status"` + Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` } type MeshBuildingBlockV2Client interface { From c9fcd8905872796af6896a4cf16fa9d4f206e77f Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Mon, 18 May 2026 15:39:49 +0200 Subject: [PATCH 137/215] fix: add lifecycle state tracking to building_block_v2 - Add MeshBuildingBlockV2Lifecycle struct to capture lifecycle.state from API - Expose status.lifecycle in resource and data source schemas - Update DeletionSuccessful() to recognize DELETED lifecycle state as done - Remove resource from state when lifecycle.state == DELETED - Fix missing force_purge attribute in resource state assignment --- buildingblock_v2.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index d80d48a6..6379d62b 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -15,6 +15,7 @@ const ( BUILDING_BLOCK_STATUS_IN_PROGRESS = "IN_PROGRESS" BUILDING_BLOCK_STATUS_SUCCEEDED = "SUCCEEDED" BUILDING_BLOCK_STATUS_FAILED = "FAILED" + BUILDING_BLOCK_LIFECYCLE_STATE_DELETED = "DELETED" ) type MeshBuildingBlockV2 struct { @@ -51,10 +52,15 @@ type MeshBuildingBlockV2Create struct { Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` } +type MeshBuildingBlockV2Lifecycle struct { + State string `json:"state" tfsdk:"state"` +} + type MeshBuildingBlockV2Status struct { - Status string `json:"status" tfsdk:"status"` - Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` - ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + Status string `json:"status" tfsdk:"status"` + Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` } type MeshBuildingBlockV2Client interface { @@ -106,6 +112,8 @@ func (bb *MeshBuildingBlockV2) DeletionSuccessful() (done bool, err error) { switch { case bb == nil: done = true + case bb.Status.Lifecycle.State == BUILDING_BLOCK_LIFECYCLE_STATE_DELETED: + done = true case bb.Status.Status == BUILDING_BLOCK_STATUS_FAILED: err = fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", bb.Metadata.Uuid) } From 23b2ddc5095a12ea238d72da09e31a5fdcd45795 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Mon, 18 May 2026 16:10:53 +0200 Subject: [PATCH 138/215] chore: document all lifecycle states for building_block_v2 --- buildingblock_v2.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 6379d62b..95a54417 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -9,13 +9,15 @@ import ( const ( // Building Block Status Constants. - BUILDING_BLOCK_STATUS_WAITING_FOR_DEPENDENT_INPUT = "WAITING_FOR_DEPENDENT_INPUT" - BUILDING_BLOCK_STATUS_WAITING_FOR_OPERATOR_INPUT = "WAITING_FOR_OPERATOR_INPUT" - BUILDING_BLOCK_STATUS_PENDING = "PENDING" - BUILDING_BLOCK_STATUS_IN_PROGRESS = "IN_PROGRESS" - BUILDING_BLOCK_STATUS_SUCCEEDED = "SUCCEEDED" - BUILDING_BLOCK_STATUS_FAILED = "FAILED" - BUILDING_BLOCK_LIFECYCLE_STATE_DELETED = "DELETED" + BUILDING_BLOCK_STATUS_WAITING_FOR_DEPENDENT_INPUT = "WAITING_FOR_DEPENDENT_INPUT" + BUILDING_BLOCK_STATUS_WAITING_FOR_OPERATOR_INPUT = "WAITING_FOR_OPERATOR_INPUT" + BUILDING_BLOCK_STATUS_PENDING = "PENDING" + BUILDING_BLOCK_STATUS_IN_PROGRESS = "IN_PROGRESS" + BUILDING_BLOCK_STATUS_SUCCEEDED = "SUCCEEDED" + BUILDING_BLOCK_STATUS_FAILED = "FAILED" + BUILDING_BLOCK_LIFECYCLE_STATE_ACTIVE = "ACTIVE" + BUILDING_BLOCK_LIFECYCLE_STATE_MARKED_FOR_DELETION = "MARKED_FOR_DELETION" + BUILDING_BLOCK_LIFECYCLE_STATE_DELETED = "DELETED" ) type MeshBuildingBlockV2 struct { @@ -111,6 +113,8 @@ func (bb *MeshBuildingBlockV2) CreateSuccessful() (done bool, err error) { func (bb *MeshBuildingBlockV2) DeletionSuccessful() (done bool, err error) { switch { case bb == nil: + // Expected when receiving a 404 (hard deletion), default behavior until meshStack v2026.20.0. + // For versions higher than that, we get a building block back with a lifecycle state to inspect. done = true case bb.Status.Lifecycle.State == BUILDING_BLOCK_LIFECYCLE_STATE_DELETED: done = true From dddf8ffaad541b24f616064e014f24b93544240d Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Tue, 19 May 2026 09:56:12 +0200 Subject: [PATCH 139/215] chore: add test for BB deletion Follow-up on https://github.com/meshcloud/terraform-provider-meshstack/pull/172/changes#r3264364813 --- buildingblock_v2_test.go | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 buildingblock_v2_test.go diff --git a/buildingblock_v2_test.go b/buildingblock_v2_test.go new file mode 100644 index 00000000..312887d2 --- /dev/null +++ b/buildingblock_v2_test.go @@ -0,0 +1,67 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { + tests := []struct { + name string + bb *MeshBuildingBlockV2 + wantDone bool + wantErr bool + }{ + { + name: "nil (hard deletion / 404)", + bb: nil, + wantDone: true, + wantErr: false, + }, + { + name: "lifecycle state DELETED", + bb: &MeshBuildingBlockV2{ + Status: MeshBuildingBlockV2Status{ + Lifecycle: MeshBuildingBlockV2Lifecycle{State: BUILDING_BLOCK_LIFECYCLE_STATE_DELETED}, + }, + }, + wantDone: true, + wantErr: false, + }, + { + name: "status FAILED during deletion", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: "test-uuid"}, + Status: MeshBuildingBlockV2Status{ + Status: BUILDING_BLOCK_STATUS_FAILED, + }, + }, + wantDone: false, + wantErr: true, + }, + { + name: "still in progress (MARKED_FOR_DELETION lifecycle, non-failed status)", + bb: &MeshBuildingBlockV2{ + Status: MeshBuildingBlockV2Status{ + Status: BUILDING_BLOCK_STATUS_IN_PROGRESS, + Lifecycle: MeshBuildingBlockV2Lifecycle{State: BUILDING_BLOCK_LIFECYCLE_STATE_MARKED_FOR_DELETION}, + }, + }, + wantDone: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done, err := tt.bb.DeletionSuccessful() + assert.Equal(t, tt.wantDone, done) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} From dfc7983dc85b90061728bd66015a8240e31e0efb Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Fri, 15 May 2026 16:34:30 +0200 Subject: [PATCH 140/215] fix: migrate building block target references CU-86c9uebcz --- buildingblock_v2.go | 6 +++--- client.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 95a54417..7a6b8bc9 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -45,9 +45,9 @@ type MeshBuildingBlockV2DefinitionVersionRef struct { } type MeshBuildingBlockV2TargetRef struct { - Kind string `json:"kind" tfsdk:"kind"` - Uuid *string `json:"uuid" tfsdk:"uuid"` - Identifier *string `json:"identifier" tfsdk:"identifier"` + Kind string `json:"kind" tfsdk:"kind"` + Uuid *string `json:"uuid" tfsdk:"uuid"` + Name *string `json:"name" tfsdk:"name"` } type MeshBuildingBlockV2Create struct { diff --git a/client.go b/client.go index 35616799..ed6ff801 100644 --- a/client.go +++ b/client.go @@ -10,7 +10,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.10.0") +var MinMeshStackVersion = version.MustParse("2026.22.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. From abd332c33c896aa233782321bf21638ef5160ef8 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Thu, 21 May 2026 17:07:29 +0200 Subject: [PATCH 141/215] feat: introduce MESHSTACK_SKIP_VERSION_CHECK to skip version check --- client.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index ed6ff801..0616f45c 100644 --- a/client.go +++ b/client.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "os" "time" "github.com/meshcloud/terraform-provider-meshstack/client/internal" @@ -66,7 +67,11 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza if meshInfo, err := httpClient.GetMeshInfo(ctx); err != nil { return Client{}, fmt.Errorf("failed to retrieve meshStack version information from /mesh/info endpoint: %w", err) } else if meshInfo.Version.Less(MinMeshStackVersion) { - return Client{}, fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshInfo.Version, MinMeshStackVersion) + skipVersionCheck := os.Getenv("MESHSTACK_SKIP_VERSION_CHECK") == "true" + + if !skipVersionCheck { + return Client{}, fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshInfo.Version, MinMeshStackVersion) + } } return Client{ From 58adb1b9887267321781854c0cef5f51278123c1 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Thu, 28 May 2026 09:50:42 +0200 Subject: [PATCH 142/215] fix: change input/output structure from array to map CU-86c9p4kcd --- buildingblock_v2.go | 26 ++++++++++++++++++++------ client.go | 2 +- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 7a6b8bc9..9bef5a5a 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -36,8 +36,22 @@ type MeshBuildingBlockV2Spec struct { TargetRef MeshBuildingBlockV2TargetRef `json:"targetRef" tfsdk:"target_ref"` DisplayName string `json:"displayName" tfsdk:"display_name"` - Inputs []MeshBuildingBlockIO `json:"inputs" tfsdk:"inputs"` - ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` + Inputs map[string]MeshBuildingBlockV2Input `json:"inputs" tfsdk:"-"` + ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` +} + +type MeshBuildingBlockV2Input struct { + Value any `json:"value"` + ValueType string `json:"valueType"` + IsSensitive bool `json:"isSensitive"` + AssignmentType *string `json:"assignmentType"` + UpdateableByConsumer bool `json:"updateableByConsumer"` +} + +type MeshBuildingBlockV2Output struct { + Value any `json:"value"` + ValueType string `json:"valueType"` + AssignmentType *string `json:"assignmentType"` } type MeshBuildingBlockV2DefinitionVersionRef struct { @@ -59,10 +73,10 @@ type MeshBuildingBlockV2Lifecycle struct { } type MeshBuildingBlockV2Status struct { - Status string `json:"status" tfsdk:"status"` - Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` - ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` - Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` + Status string `json:"status" tfsdk:"status"` + Outputs map[string]MeshBuildingBlockV2Output `json:"outputs" tfsdk:"-"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` } type MeshBuildingBlockV2Client interface { diff --git a/client.go b/client.go index 0616f45c..f818ca4b 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.22.0") +var MinMeshStackVersion = version.MustParse("2026.23.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. From 34926e5ad873e1cd6b4f30c6e6b70a2caa877872 Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Tue, 26 May 2026 11:40:25 +0200 Subject: [PATCH 143/215] feat: add meshstack_building_block_runner resource --- buildingblock_runner.go | 94 +++++++++++++++++++++++++++++++++++++++++ client.go | 2 + 2 files changed, 96 insertions(+) diff --git a/buildingblock_runner.go b/buildingblock_runner.go index e73e102c..e14bbaaf 100644 --- a/buildingblock_runner.go +++ b/buildingblock_runner.go @@ -1,6 +1,100 @@ package client +import ( + "context" + "fmt" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + +type MeshBuildingBlockRunnerImplementationType string + +const ( + MeshBuildingBlockRunnerImplementationTypeTerraform MeshBuildingBlockRunnerImplementationType = "TERRAFORM" + MeshBuildingBlockRunnerImplementationTypeGithubWorkflow MeshBuildingBlockRunnerImplementationType = "GITHUB_WORKFLOW" + MeshBuildingBlockRunnerImplementationTypeGitlabPipeline MeshBuildingBlockRunnerImplementationType = "GITLAB_PIPELINE" + MeshBuildingBlockRunnerImplementationTypeAzureDevopsPipeline MeshBuildingBlockRunnerImplementationType = "AZURE_DEVOPS_PIPELINE" + MeshBuildingBlockRunnerImplementationTypeManual MeshBuildingBlockRunnerImplementationType = "MANUAL" +) + +var MeshBuildingBlockRunnerImplementationTypes = []string{ + string(MeshBuildingBlockRunnerImplementationTypeTerraform), + string(MeshBuildingBlockRunnerImplementationTypeGithubWorkflow), + string(MeshBuildingBlockRunnerImplementationTypeGitlabPipeline), + string(MeshBuildingBlockRunnerImplementationTypeAzureDevopsPipeline), + string(MeshBuildingBlockRunnerImplementationTypeManual), +} + type BuildingBlockRunnerRef struct { Uuid string `json:"uuid" tfsdk:"uuid"` Kind string `json:"kind" tfsdk:"kind"` } + +type MeshBuildingBlockRunner struct { + Metadata MeshBuildingBlockRunnerMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshBuildingBlockRunnerSpec `json:"spec" tfsdk:"spec"` +} + +type MeshBuildingBlockRunnerMetadata struct { + Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + CreatedOn *string `json:"createdOn,omitempty" tfsdk:"created_on"` + LastSeen *string `json:"lastSeen,omitempty" tfsdk:"last_seen"` +} + +type MeshBuildingBlockRunnerSpec struct { + DisplayName string `json:"displayName" tfsdk:"display_name"` + PublicKey string `json:"publicKey" tfsdk:"public_key"` + ImplementationType string `json:"implementationType" tfsdk:"implementation_type"` + Restriction *string `json:"restriction,omitempty" tfsdk:"restriction"` + IsSelfHosted *bool `json:"isSelfHosted,omitempty" tfsdk:"is_self_hosted"` + WorkloadIdentityFederation *MeshRunnerWorkloadIdentityFed `json:"workloadIdentityFederation,omitempty" tfsdk:"workload_identity_federation"` +} + +type MeshRunnerWorkloadIdentityFed struct { + Subject *string `json:"subject,omitempty" tfsdk:"subject"` + Issuer *string `json:"issuer,omitempty" tfsdk:"issuer"` + Gcp *MeshRunnerWifProviderConfig `json:"gcp,omitempty" tfsdk:"gcp"` + Aws *MeshRunnerWifProviderConfig `json:"aws,omitempty" tfsdk:"aws"` + Azure *MeshRunnerWifProviderConfig `json:"azure,omitempty" tfsdk:"azure"` +} + +type MeshRunnerWifProviderConfig struct { + Audience string `json:"audience" tfsdk:"audience"` + TokenPath string `json:"tokenPath" tfsdk:"token_path"` +} + +type MeshBuildingBlockRunnerClient interface { + Create(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) + Read(ctx context.Context, uuid string) (*MeshBuildingBlockRunner, error) + Update(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) + Delete(ctx context.Context, uuid string) error +} + +type meshBuildingBlockRunnerClient struct { + meshObject internal.MeshObjectClient[MeshBuildingBlockRunner] +} + +func newBuildingBlockRunnerClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockRunnerClient { + return meshBuildingBlockRunnerClient{internal.NewMeshObjectClient[MeshBuildingBlockRunner](ctx, httpClient, "v1-preview")} +} + +func (c meshBuildingBlockRunnerClient) Create(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) { + return c.meshObject.Post(ctx, runner) +} + +func (c meshBuildingBlockRunnerClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlockRunner, error) { + return c.meshObject.Get(ctx, uuid) +} + +func (c meshBuildingBlockRunnerClient) Update(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) { + if runner.Metadata.Uuid == nil || *runner.Metadata.Uuid == "" { + return nil, fmt.Errorf("missing metadata.uuid") + } + + return c.meshObject.Put(ctx, *runner.Metadata.Uuid, runner) +} + +func (c meshBuildingBlockRunnerClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} diff --git a/client.go b/client.go index f818ca4b..9a1f3668 100644 --- a/client.go +++ b/client.go @@ -23,6 +23,7 @@ type Client struct { BuildingBlockV2 MeshBuildingBlockV2Client BuildingBlockDefinition MeshBuildingBlockDefinitionClient BuildingBlockDefinitionVersion MeshBuildingBlockDefinitionVersionClient + BuildingBlockRunner MeshBuildingBlockRunnerClient Integration MeshIntegrationClient LandingZone MeshLandingZoneClient Location MeshLocationClient @@ -80,6 +81,7 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza BuildingBlockV2: newBuildingBlockV2Client(ctx, httpClient), BuildingBlockDefinition: newBuildingBlockDefinitionClient(ctx, httpClient), BuildingBlockDefinitionVersion: newBuildingBlockDefinitionVersionClient(ctx, httpClient), + BuildingBlockRunner: newBuildingBlockRunnerClient(ctx, httpClient), Integration: newIntegrationClient(ctx, httpClient), LandingZone: newLandingZoneClient(ctx, httpClient), Location: newLocationClient(ctx, httpClient), From 92bffdac30d08e4f453a550439984f00307bd2b9 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Fri, 29 May 2026 15:50:38 +0200 Subject: [PATCH 144/215] fix: revert "change input/output structure from array to map" This reverts commit e0f63173a173e6faada285838a92357402f14076. --- buildingblock_v2.go | 26 ++++++-------------------- client.go | 2 +- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 9bef5a5a..7a6b8bc9 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -36,22 +36,8 @@ type MeshBuildingBlockV2Spec struct { TargetRef MeshBuildingBlockV2TargetRef `json:"targetRef" tfsdk:"target_ref"` DisplayName string `json:"displayName" tfsdk:"display_name"` - Inputs map[string]MeshBuildingBlockV2Input `json:"inputs" tfsdk:"-"` - ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` -} - -type MeshBuildingBlockV2Input struct { - Value any `json:"value"` - ValueType string `json:"valueType"` - IsSensitive bool `json:"isSensitive"` - AssignmentType *string `json:"assignmentType"` - UpdateableByConsumer bool `json:"updateableByConsumer"` -} - -type MeshBuildingBlockV2Output struct { - Value any `json:"value"` - ValueType string `json:"valueType"` - AssignmentType *string `json:"assignmentType"` + Inputs []MeshBuildingBlockIO `json:"inputs" tfsdk:"inputs"` + ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` } type MeshBuildingBlockV2DefinitionVersionRef struct { @@ -73,10 +59,10 @@ type MeshBuildingBlockV2Lifecycle struct { } type MeshBuildingBlockV2Status struct { - Status string `json:"status" tfsdk:"status"` - Outputs map[string]MeshBuildingBlockV2Output `json:"outputs" tfsdk:"-"` - ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` - Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` + Status string `json:"status" tfsdk:"status"` + Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` } type MeshBuildingBlockV2Client interface { diff --git a/client.go b/client.go index 9a1f3668..f9982ac6 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.23.0") +var MinMeshStackVersion = version.MustParse("2026.22.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. From 4d82399cb893dd7e2a966c9e1233dce839237099 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Thu, 28 May 2026 09:50:42 +0200 Subject: [PATCH 145/215] fix: change input/output structure from array to map CU-86c9p4kcd --- buildingblock_v2.go | 26 ++++++++++++++++++++------ client.go | 2 +- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 7a6b8bc9..9bef5a5a 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -36,8 +36,22 @@ type MeshBuildingBlockV2Spec struct { TargetRef MeshBuildingBlockV2TargetRef `json:"targetRef" tfsdk:"target_ref"` DisplayName string `json:"displayName" tfsdk:"display_name"` - Inputs []MeshBuildingBlockIO `json:"inputs" tfsdk:"inputs"` - ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` + Inputs map[string]MeshBuildingBlockV2Input `json:"inputs" tfsdk:"-"` + ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` +} + +type MeshBuildingBlockV2Input struct { + Value any `json:"value"` + ValueType string `json:"valueType"` + IsSensitive bool `json:"isSensitive"` + AssignmentType *string `json:"assignmentType"` + UpdateableByConsumer bool `json:"updateableByConsumer"` +} + +type MeshBuildingBlockV2Output struct { + Value any `json:"value"` + ValueType string `json:"valueType"` + AssignmentType *string `json:"assignmentType"` } type MeshBuildingBlockV2DefinitionVersionRef struct { @@ -59,10 +73,10 @@ type MeshBuildingBlockV2Lifecycle struct { } type MeshBuildingBlockV2Status struct { - Status string `json:"status" tfsdk:"status"` - Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` - ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` - Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` + Status string `json:"status" tfsdk:"status"` + Outputs map[string]MeshBuildingBlockV2Output `json:"outputs" tfsdk:"-"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` } type MeshBuildingBlockV2Client interface { diff --git a/client.go b/client.go index f9982ac6..9a1f3668 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.22.0") +var MinMeshStackVersion = version.MustParse("2026.23.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. From 9ac5638f9297764ce33def13a38f689d5f514e5a Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 2 Jun 2026 12:04:05 +0200 Subject: [PATCH 146/215] feat: add purge_on_delete support to meshstack_building_block_v2 Adds a new optional `purge_on_delete` attribute (default `false`) that sends DELETE to the `/purge` endpoint, bypassing the building block's configured deletion run. Useful when a building block is stuck in a non-final state. Deletion now always polls for completion so dependent resources can be cleaned up safely. Co-Authored-By: Claude Sonnet 4.6 --- buildingblock_v2.go | 7 +++++-- internal/mesh_object_client.go | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 9bef5a5a..d1d8ab99 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -83,7 +83,7 @@ type MeshBuildingBlockV2Client interface { Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) Create(ctx context.Context, bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) - Delete(ctx context.Context, uuid string) error + Delete(ctx context.Context, uuid string, purge bool) error } type meshBuildingBlockV2Client struct { @@ -108,7 +108,10 @@ func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingB return c.meshObject.Post(ctx, bb) } -func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string) error { +func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string, purge bool) error { + if purge { + return c.meshObject.Purge(ctx, uuid) + } return c.meshObject.Delete(ctx, uuid) } diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index 024ab4ec..934e8ae2 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -123,6 +123,12 @@ func (c MeshObjectClient[M]) Delete(ctx context.Context, id string) (err error) return } +// Purge removes a meshObject by ID without running any cloud-side cleanup, by calling DELETE /{id}/purge. +func (c MeshObjectClient[M]) Purge(ctx context.Context, id string) (err error) { + _, err = c.doAuthorizedRequest(ctx, http.MethodDelete, c.ApiUrl.JoinPath(id, "purge"), withAccept(c.meshObjectMimeType())) + return +} + // List retrieves all meshObjects with automatic pagination handling. // Accepts optional [RequestOption] parameters for filtering and querying. func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) ([]M, error) { From 24bc91c90988530c0945fdb6ba1b24a457f4359e Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 2 Jun 2026 12:04:26 +0200 Subject: [PATCH 147/215] fix: align BB v2 client with embedded-secret sensitive inputs The meshbuildingblock v2-preview API now returns sensitive inputs as embedded secrets ({"hash":"sha256:..."} with isSensitive:true). Previously the value deserialized to a map and was silently dropped in combined_inputs. This fix adds an UnmarshalJSON to MeshBuildingBlockV2Input that routes sensitive values into the SecretOrAny X-branch and surfaces the hash in state via toResourceModelV2Input. An acceptance test using a STATIC sensitive STRING input covers the fix end-to-end. Co-Authored-By: Claude Sonnet 4.6 --- buildingblock_v2.go | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index d1d8ab99..784bc033 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -2,9 +2,11 @@ package client import ( "context" + "encoding/json" "fmt" "github.com/meshcloud/terraform-provider-meshstack/client/internal" + types "github.com/meshcloud/terraform-provider-meshstack/client/types" ) const ( @@ -41,11 +43,31 @@ type MeshBuildingBlockV2Spec struct { } type MeshBuildingBlockV2Input struct { - Value any `json:"value"` - ValueType string `json:"valueType"` - IsSensitive bool `json:"isSensitive"` - AssignmentType *string `json:"assignmentType"` - UpdateableByConsumer bool `json:"updateableByConsumer"` + Value types.SecretOrAny `json:"value"` + ValueType string `json:"valueType"` + IsSensitive bool `json:"isSensitive"` + AssignmentType *string `json:"assignmentType"` + UpdateableByConsumer bool `json:"updateableByConsumer"` +} + +func (m *MeshBuildingBlockV2Input) UnmarshalJSON(bytes []byte) error { + type wrapped MeshBuildingBlockV2Input + var target wrapped + if err := json.Unmarshal(bytes, &target); err != nil { + return err + } + *m = MeshBuildingBlockV2Input(target) + // Non-sensitive values must live in the Variant's Y branch; the Variant prefers X and + // types.Secret fields are omitempty, so move any accidental X match to Y when not sensitive. + if !m.IsSensitive && m.Value.HasX() { + xJson, err := json.Marshal(m.Value.X) + if err != nil { + return err + } + m.Value.X = types.Secret{} + return json.Unmarshal(xJson, &m.Value.Y) + } + return nil } type MeshBuildingBlockV2Output struct { From 359aaaba21854594d2b0d4b43e9c5fae7405034e Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Wed, 3 Jun 2026 16:46:05 +0200 Subject: [PATCH 148/215] feat: add ALL capability to building block runner implementation types The meshStack backend now models runner capabilities and building block types as separate enums, with runners gaining an additional ALL value that allows a single runner to handle building blocks of any implementation type. Co-Authored-By: Claude Sonnet 4.6 --- buildingblock_runner.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/buildingblock_runner.go b/buildingblock_runner.go index e14bbaaf..ba63140f 100644 --- a/buildingblock_runner.go +++ b/buildingblock_runner.go @@ -15,6 +15,7 @@ const ( MeshBuildingBlockRunnerImplementationTypeGitlabPipeline MeshBuildingBlockRunnerImplementationType = "GITLAB_PIPELINE" MeshBuildingBlockRunnerImplementationTypeAzureDevopsPipeline MeshBuildingBlockRunnerImplementationType = "AZURE_DEVOPS_PIPELINE" MeshBuildingBlockRunnerImplementationTypeManual MeshBuildingBlockRunnerImplementationType = "MANUAL" + MeshBuildingBlockRunnerImplementationTypeAll MeshBuildingBlockRunnerImplementationType = "ALL" ) var MeshBuildingBlockRunnerImplementationTypes = []string{ @@ -23,6 +24,7 @@ var MeshBuildingBlockRunnerImplementationTypes = []string{ string(MeshBuildingBlockRunnerImplementationTypeGitlabPipeline), string(MeshBuildingBlockRunnerImplementationTypeAzureDevopsPipeline), string(MeshBuildingBlockRunnerImplementationTypeManual), + string(MeshBuildingBlockRunnerImplementationTypeAll), } type BuildingBlockRunnerRef struct { From 0e53c9f074dd8495133c776aab1aed646bdddcb0 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 10 Jun 2026 08:26:45 +0200 Subject: [PATCH 149/215] chore: bump to v0.22.0 release, require newest meshstack --- client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client.go b/client.go index 9a1f3668..956b5aaf 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.23.0") +var MinMeshStackVersion = version.MustParse("2026.24.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. From fa000baf7893427fd7bec54c933c290788c2a6ef Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 16 Jun 2026 10:13:41 +0200 Subject: [PATCH 150/215] feat: allow MANAGED_BUILDINGBLOCK_SAVE on building block permissions Add MANAGED_BUILDINGBLOCK_SAVE to the BUILDINGBLOCK_SAVE permission row so the client-side permission validator accepts it when configuring API key permissions. This matches the backend, which now allows assigning this platform-operator authority to API keys (e.g. for setting operator inputs on building blocks across workspaces). BD-2463 Co-Authored-By: Claude Opus 4.8 (1M context) --- api_key_permissions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api_key_permissions.go b/api_key_permissions.go index 492315b9..c2433d9a 100644 --- a/api_key_permissions.go +++ b/api_key_permissions.go @@ -92,7 +92,7 @@ var Permissions = ApiKeyPermissions{ { {"BUILDINGBLOCK_DELETE", "ADM_BUILDINGBLOCK_DELETE"}, {"BUILDINGBLOCK_LIST", "ADM_BUILDINGBLOCK_LIST", "MANAGED_BUILDINGBLOCK_LIST"}, - {"BUILDINGBLOCK_SAVE", "ADM_BUILDINGBLOCK_SAVE"}, + {"BUILDINGBLOCK_SAVE", "ADM_BUILDINGBLOCK_SAVE", "MANAGED_BUILDINGBLOCK_SAVE"}, }, // Building Block Definitions { From 745865e0c1fc834fa5e2f3bbe34f27c792a07222 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Mon, 15 Jun 2026 12:34:00 +0200 Subject: [PATCH 151/215] feat: add support for EntraId meshIntegrations --- integration_config.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/integration_config.go b/integration_config.go index 5b5b6801..e88677fe 100644 --- a/integration_config.go +++ b/integration_config.go @@ -16,6 +16,7 @@ var ( MeshIntegrationConfigTypeGithub = MeshIntegrationConfigTypes.Entry("github") MeshIntegrationConfigTypeGitlab = MeshIntegrationConfigTypes.Entry("gitlab") MeshIntegrationConfigTypeAzureDevops = MeshIntegrationConfigTypes.Entry("azuredevops") + MeshIntegrationConfigTypeEntraId = MeshIntegrationConfigTypes.Entry("entraid") ) type MeshIntegrationGithubConfig struct { @@ -38,11 +39,19 @@ type MeshIntegrationAzureDevopsConfig struct { RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` } +type MeshIntegrationEntraIdConfig struct { + TenantId string `json:"tenantId" tfsdk:"tenant_id"` + ClientId string `json:"clientId" tfsdk:"client_id"` + ClientSecret types.Secret `json:"clientSecret" tfsdk:"client_secret"` + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` +} + type MeshIntegrationConfig struct { Type enum.Entry[MeshIntegrationConfigType] `json:"type" tfsdk:"-"` Github *MeshIntegrationGithubConfig `json:"github,omitempty" tfsdk:"github"` Gitlab *MeshIntegrationGitlabConfig `json:"gitlab,omitempty" tfsdk:"gitlab"` AzureDevops *MeshIntegrationAzureDevopsConfig `json:"azuredevops,omitempty" tfsdk:"azuredevops"` + EntraId *MeshIntegrationEntraIdConfig `json:"entraid,omitempty" tfsdk:"entraid"` } func (m MeshIntegrationConfig) InferTypeFromNonNilField() (result enum.Entry[MeshIntegrationConfigType]) { @@ -57,6 +66,7 @@ func (m MeshIntegrationConfig) InferTypeFromNonNilField() (result enum.Entry[Mes setResultIfNotNil(MeshIntegrationConfigTypeGithub, m.Github) setResultIfNotNil(MeshIntegrationConfigTypeGitlab, m.Gitlab) setResultIfNotNil(MeshIntegrationConfigTypeAzureDevops, m.AzureDevops) + setResultIfNotNil(MeshIntegrationConfigTypeEntraId, m.EntraId) if len(result) == 0 { panic("cannot infer config type") } From 5d89105f3eb859c209e89ec22a740bdfa881bc45 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 1 Jun 2026 14:45:53 +0200 Subject: [PATCH 152/215] refactor: generic DoRequest/DoAuthorizedRequest http client API Replaces doRequest/doAuthorizedRequest + GetMeshInfo/unmarshalBody helpers with a generic DoRequest[R]/DoAuthorizedRequest[R] API and adds WithPathElems JoinPath support. Strengthens the PATCH non-retry test to assert exactly one attempt against a retryable 502 response. Co-Authored-By: Claude Sonnet 4.6 --- buildingblock_v2.go | 5 +- client.go | 28 +++++++---- internal/auth.go | 4 +- internal/http_client.go | 74 +++++++++++++++-------------- internal/http_client_test.go | 85 ++++++++++++++++++++-------------- internal/mesh_object_client.go | 34 +++++++------- internal/options.go | 12 ++++- 7 files changed, 140 insertions(+), 102 deletions(-) diff --git a/buildingblock_v2.go b/buildingblock_v2.go index 784bc033..3a806a24 100644 --- a/buildingblock_v2.go +++ b/buildingblock_v2.go @@ -131,10 +131,11 @@ func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingB } func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string, purge bool) error { + var options []internal.RequestOption if purge { - return c.meshObject.Purge(ctx, uuid) + options = append(options, internal.WithPathElems("purge")) } - return c.meshObject.Delete(ctx, uuid) + return c.meshObject.Delete(ctx, uuid, options...) } func (bb *MeshBuildingBlockV2) CreateSuccessful() (done bool, err error) { diff --git a/client.go b/client.go index 956b5aaf..1c3c194e 100644 --- a/client.go +++ b/client.go @@ -64,15 +64,8 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza }, ) - // Check meshStack version compatibility - if meshInfo, err := httpClient.GetMeshInfo(ctx); err != nil { - return Client{}, fmt.Errorf("failed to retrieve meshStack version information from /mesh/info endpoint: %w", err) - } else if meshInfo.Version.Less(MinMeshStackVersion) { - skipVersionCheck := os.Getenv("MESHSTACK_SKIP_VERSION_CHECK") == "true" - - if !skipVersionCheck { - return Client{}, fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshInfo.Version, MinMeshStackVersion) - } + if err := checkMeshVersion(ctx, httpClient); err != nil { + return Client{}, err } return Client{ @@ -100,3 +93,20 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza WorkspaceUserBinding: newWorkspaceUserBindingClient(ctx, httpClient), }, nil } + +func checkMeshVersion(ctx context.Context, httpClient internal.HttpClient) error { + type MeshInfo struct { + Version version.Version `json:"version"` + } + + meshInfoEndpoint := httpClient.RootUrl.JoinPath("/mesh/info") + if meshInfo, err := internal.DoRequest[MeshInfo](ctx, httpClient, "GET", meshInfoEndpoint); err != nil { + return fmt.Errorf("failed to retrieve meshStack version information from %s endpoint: %w", meshInfoEndpoint, err) + } else if meshInfo.Version.Less(MinMeshStackVersion) { + if os.Getenv("MESHSTACK_SKIP_VERSION_CHECK") == "true" { + return nil + } + return fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshInfo.Version, MinMeshStackVersion) + } + return nil +} diff --git a/internal/auth.go b/internal/auth.go index dccc658c..86341000 100644 --- a/internal/auth.go +++ b/internal/auth.go @@ -64,8 +64,8 @@ func (auth *clientSecretAuthorization) ensureValidToken(ctx context.Context, cli ExpireSec int `json:"expires_in"` } - loginResult, err := unmarshalBody[loginResponse](client.doRequest(ctx, http.MethodPost, loginApiUrl, - withPayload(loginRequest{ClientId: auth.ClientId, ClientSecret: auth.ClientSecret}, "application/json")), + loginResult, err := DoRequest[loginResponse](ctx, client, http.MethodPost, loginApiUrl, + withPayload(loginRequest{ClientId: auth.ClientId, ClientSecret: auth.ClientSecret}, "application/json"), ) if err != nil { return fmt.Errorf("login at %s with client id '%s' failed: %w", loginApiUrl, auth.ClientId, err) diff --git a/internal/http_client.go b/internal/http_client.go index 7b8c1492..f4190b9e 100644 --- a/internal/http_client.go +++ b/internal/http_client.go @@ -8,10 +8,9 @@ import ( "io" "net/http" "net/url" + "reflect" "slices" "time" - - "github.com/meshcloud/terraform-provider-meshstack/client/version" ) // NewHttpClient creates a new client with an underlying http.Client being a pointer to be modified by WithRetry. @@ -27,7 +26,39 @@ type HttpClient struct { Authorization Authorization } -func (c HttpClient) doRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) ([]byte, error) { +func DoAuthorizedRequest[R any](ctx context.Context, c HttpClient, method string, url *url.URL, options ...RequestOption) (result R, err error) { + if c.Authorization == nil { + return result, fmt.Errorf("cannot do authorized request with unconfigured authorization") + } + authHeader, err := c.Authorization.Header(ctx, c) + if err != nil { + return result, err + } + return DoRequest[R](ctx, c, method, url, append(options, withHeader("Authorization", authHeader))...) +} + +func DoRequest[R any](ctx context.Context, c HttpClient, method string, url *url.URL, options ...RequestOption) (result R, err error) { + var body []byte + body, err = c.doRequest(ctx, method, url, options) + if err != nil { + return + } + if len(body) == 0 { + // An empty body is expected only for no-content calls, which are typed DoRequest[any] (e.g. + // trigger-run, delete) and ignore the result. For a call that expects an object (a pointer or a + // concrete struct), an empty 2xx body is unexpected — fail loudly instead of returning a nil/zero + // value that the caller would dereference or mistake for a 404/"not found". + if t := reflect.TypeFor[R](); t.Kind() == reflect.Interface && t.NumMethod() == 0 { + return + } + err = fmt.Errorf("unexpected empty response body from %s %s", method, url) + return + } + err = json.Unmarshal(body, &result) + return +} + +func (c HttpClient) doRequest(ctx context.Context, method string, url *url.URL, options []RequestOption) ([]byte, error) { options = slices.Insert(options, 0, withHeader("User-Agent", c.UserAgent), ) @@ -49,17 +80,6 @@ func (c HttpClient) doRequest(ctx context.Context, method string, url *url.URL, return c.readBodyAndCheckSuccess(ctx, res) } -func (c HttpClient) doAuthorizedRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) ([]byte, error) { - if c.Authorization == nil { - return nil, fmt.Errorf("authorization is not configured") - } - authHeader, err := c.Authorization.Header(ctx, c) - if err != nil { - return nil, err - } - return c.doRequest(ctx, method, url, append(options, withHeader("Authorization", authHeader))...) -} - func (c HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Response) ([]byte, error) { responseBody, err := io.ReadAll(res.Body) if err != nil { @@ -78,6 +98,10 @@ func (c HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Respo } func (c HttpClient) buildRequest(ctx context.Context, method string, url url.URL, opts requestOptions) (*http.Request, error) { + if len(opts.extraPathElems) > 0 { + url = *url.JoinPath(opts.extraPathElems...) + } + if len(opts.urlQueryParams) > 0 { query := url.Query() for k, v := range opts.urlQueryParams { @@ -104,25 +128,3 @@ func (c HttpClient) buildRequest(ctx context.Context, method string, url url.URL Log.Debug(ctx, "request", "url", req.URL.String(), "method", req.Method, "headers", loggedHeaders(req.Header), "body", loggedBody{requestBody}) return req, err } - -// unmarshalBody is a generic helper to unmarshal a JSON response. -// It intentionally takes err as second argument to match doAuthorizedRequest and doRequest signatures. -func unmarshalBody[T any](body []byte, err error) (*T, error) { - if err != nil { - return nil, err - } - var target T - if err := json.Unmarshal(body, &target); err != nil { - return nil, fmt.Errorf("cannot unmarshal body: %w", err) - } - return &target, nil -} - -type MeshInfo struct { - Version version.Version `json:"version"` -} - -func (c HttpClient) GetMeshInfo(ctx context.Context) (*MeshInfo, error) { - meshInfoUrl := c.RootUrl.JoinPath("/mesh/info") - return unmarshalBody[MeshInfo](c.doRequest(ctx, "GET", meshInfoUrl)) -} diff --git a/internal/http_client_test.go b/internal/http_client_test.go index b14d6252..ea38a0f9 100644 --- a/internal/http_client_test.go +++ b/internal/http_client_test.go @@ -12,32 +12,46 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/meshcloud/terraform-provider-meshstack/client/version" ) func TestHttpClient(t *testing.T) { - t.Run("GetMeshInfo success", func(t *testing.T) { + t.Run("DoRequest success", func(t *testing.T) { testLogger := installTestLogger(t) client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { resp.WriteHeader(http.StatusOK) - _, _ = resp.Write([]byte(`{"version": "2026.10.0"}`)) - - assert.Equal(t, "/mesh/info", req.URL.Path) + _, _ = resp.Write([]byte(`"some-answer"`)) + assert.Equal(t, "/get", req.URL.Path) assert.Equal(t, http.MethodGet, req.Method) assert.Equal(t, "test-agent", req.Header.Get("User-Agent")) }) - info, err := client.GetMeshInfo(t.Context()) + resp, err := DoRequest[string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) require.NoError(t, err) - assert.Equal(t, &MeshInfo{Version: version.Version{Major: 2026, Minor: 10}}, info) + assert.Equal(t, "some-answer", resp) assert.Equal(t, []string{ - fmt.Sprintf("request [url %s/mesh/info method GET headers User-Agent=test-agent body ]", client.RootUrl), - "response [status 200 body {\n \"version\": \"2026.10.0\"\n}]", + fmt.Sprintf("request [url %s/get method GET headers User-Agent=test-agent body ]", client.RootUrl), + `response [status 200 body "some-answer"]`, }, testLogger.Debugs) assert.Empty(t, testLogger.Warns) }) - t.Run("GetMeshInfo with successful retry", func(t *testing.T) { + t.Run("DoRequest object call with empty 2xx body errors", func(t *testing.T) { + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) // 200 with no body + }) + _, err := DoRequest[*string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) + require.Error(t, err) + assert.ErrorContains(t, err, "unexpected empty response body") + }) + + t.Run("DoRequest no-content call (any) tolerates an empty 2xx body", func(t *testing.T) { + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusAccepted) // e.g. trigger-run / delete: empty body by design + }) + _, err := DoRequest[any](t.Context(), client, http.MethodPost, client.RootUrl.JoinPath("trigger-run")) + require.NoError(t, err) + }) + + t.Run("DoRequest with successful retry", func(t *testing.T) { for _, retryableStatusCode := range []int{429, 502, 503, 504} { t.Run(fmt.Sprintf("after code %d", retryableStatusCode), func(t *testing.T) { nowUTC := mockTimeNowAsUTC(t) @@ -58,7 +72,7 @@ func TestHttpClient(t *testing.T) { _, _ = resp.Write([]byte(`{}`)) }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff}) - _, err := client.GetMeshInfo(t.Context()) + _, err := DoRequest[any](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) require.NoError(t, err) if retryableStatusCode == 429 { assert.Equal(t, 0, retryTestBackoff.Called) @@ -66,53 +80,56 @@ func TestHttpClient(t *testing.T) { assert.Equal(t, 1, retryTestBackoff.Called) } assert.Equal(t, []string{ - fmt.Sprintf("retrying request [status %d method GET path /mesh/info attempt 1/3 waitTime 1s]", retryableStatusCode), + fmt.Sprintf("retrying request [status %d method GET path /get attempt 1/3 waitTime 1s]", retryableStatusCode), }, testLogger.Warns) }) } }) - t.Run("GetMeshInfo with 2 retries exhausted", func(t *testing.T) { + t.Run("DoRequest with 2 retries exhausted", func(t *testing.T) { testLogger := installTestLogger(t) retryTestBackoff := retryTestBackoff{} client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { resp.WriteHeader(502) }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff}) - _, err := client.GetMeshInfo(t.Context()) + _, err := DoRequest[any](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) var httpErr HttpError require.ErrorAs(t, err, &httpErr) assert.Equal(t, 502, httpErr.StatusCode) assert.Equal(t, 2, retryTestBackoff.Called) assert.Equal(t, []string{ - "retrying request [status 502 method GET path /mesh/info attempt 1/2 waitTime 0s]", - "retrying request [status 502 method GET path /mesh/info attempt 2/2 waitTime 0s]", + "retrying request [status 502 method GET path /get attempt 1/2 waitTime 0s]", + "retrying request [status 502 method GET path /get attempt 2/2 waitTime 0s]", }, testLogger.Warns) assert.Equal(t, []string{ - fmt.Sprintf("request [url %s/mesh/info method GET headers User-Agent=test-agent body ]", client.RootUrl), + fmt.Sprintf("request [url %s/get method GET headers User-Agent=test-agent body ]", client.RootUrl), "response [status 502 body ]", }, testLogger.Debugs) }) - t.Run("GetMeshInfo with context cancelled during backoff", func(t *testing.T) { + t.Run("DoRequest with context cancelled during backoff", func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { resp.WriteHeader(502) cancel() // cancel context so the backoff wait is interrupted }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) - _, err := client.GetMeshInfo(ctx) + _, err := DoRequest[any](ctx, client, http.MethodGet, client.RootUrl.JoinPath("get")) require.ErrorIs(t, err, context.Canceled) }) - t.Run("doRequest with PATCH (not retried)", func(t *testing.T) { + t.Run("DoRequest with PATCH (not retried)", func(t *testing.T) { + attempts := 0 client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(200) + attempts++ + resp.WriteHeader(502) }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) - _, err := client.doRequest(t.Context(), http.MethodPatch, client.RootUrl) - require.NoError(t, err) + _, err := DoRequest[any](t.Context(), client, http.MethodPatch, client.RootUrl) + require.Error(t, err) + assert.Equal(t, 1, attempts, "PATCH must not be retried") }) - t.Run("doRequest with PUT replays body on retry", func(t *testing.T) { + t.Run("DoRequest with PUT replays body on retry", func(t *testing.T) { attempt := 0 client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { body, _ := io.ReadAll(req.Body) @@ -124,22 +141,22 @@ func TestHttpClient(t *testing.T) { } resp.WriteHeader(200) }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff{}}) - _, err := client.doRequest(t.Context(), http.MethodPut, client.RootUrl, withPayload(map[string]string{"key": "value"}, "application/json")) + _, err := DoRequest[any](t.Context(), client, http.MethodPut, client.RootUrl, withPayload(map[string]string{"key": "value"}, "application/json")) require.NoError(t, err) assert.Equal(t, 2, attempt) }) - t.Run("doAuthorizedRequest with BearerTokenAuthorization", func(t *testing.T) { + t.Run("DoAuthorizedRequest with BearerTokenAuthorization", func(t *testing.T) { client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { assert.Equal(t, "Bearer my-static-token", req.Header.Get("Authorization")) resp.WriteHeader(http.StatusAccepted) }) client.Authorization = BearerTokenAuthorization{Token: "my-static-token"} - _, err := client.doAuthorizedRequest(t.Context(), http.MethodPost, client.RootUrl.JoinPath("create"), withPayload("content", "text/plain")) + _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPost, client.RootUrl.JoinPath("create"), withPayload("content", "text/plain")) require.NoError(t, err) }) - t.Run("doAuthorizedRequest with clientSecretAuthorization and retries", func(t *testing.T) { + t.Run("DoAuthorizedRequest with clientSecretAuthorization and retries", func(t *testing.T) { t.Run("succeeds after second attempt", func(t *testing.T) { retryTestBackoff := retryTestBackoff{} requestsSeen := map[string]int{} // key is request path @@ -164,16 +181,16 @@ func TestHttpClient(t *testing.T) { } }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") - resp, err := client.doAuthorizedRequest(t.Context(), http.MethodPut, client.RootUrl.JoinPath("edit")) + resp, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) require.NoError(t, err) - require.NotNil(t, resp) + _ = resp assert.Equal(t, map[string]int{ "/login": 2, "/edit": 2, }, requestsSeen) t.Run("expired token is refreshed with relogin", func(t *testing.T) { - _, err := client.doAuthorizedRequest(t.Context(), http.MethodPut, client.RootUrl.JoinPath("edit")) + _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) require.NoError(t, err) assert.Equal(t, 2, retryTestBackoff.Called) assert.Equal(t, map[string]int{ @@ -215,7 +232,7 @@ func TestHttpClient(t *testing.T) { } }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") - _, err := client.doAuthorizedRequest(t.Context(), http.MethodPut, client.RootUrl.JoinPath("edit")) + _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) require.NoError(t, err) assert.Equal(t, map[string]int{ "/login": 2, // 1st: 502, 2nd: 307 redirect @@ -231,7 +248,7 @@ func TestHttpClient(t *testing.T) { resp.WriteHeader(503) }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") - _, err := client.doAuthorizedRequest(t.Context(), http.MethodPut, client.RootUrl.JoinPath("edit")) + _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) require.ErrorContains(t, err, fmt.Sprintf("login at %s/login with client id 'test-client' failed", client.RootUrl)) var httpErr HttpError require.ErrorAs(t, err, &httpErr) diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index 934e8ae2..0fc5b0b6 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -69,13 +69,13 @@ func pluralizeKind(kind string) string { return kind + "s" } -func (c MeshObjectClient[M]) meshObjectMimeType() string { +func (c MeshObjectClient[M]) MeshObjectMimeType() string { return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", c.Kind, c.ApiVersion) } // Get retrieves a meshObject by ID. Returns nil if not found. func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (resp *M, err error) { - resp, err = unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodGet, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType()))) + resp, err = DoAuthorizedRequest[*M](ctx, c.HttpClient, http.MethodGet, c.ApiUrl.JoinPath(id), WithAccept(c.MeshObjectMimeType())) if httpErr, ok := errors.AsType[HttpError](err); ok && httpErr.IsNotFound() { return nil, nil } @@ -84,14 +84,20 @@ func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (resp *M, err e // Post creates a new meshObject with the given payload. // Automatically injects apiVersion and kind into the JSON payload. -func (c MeshObjectClient[M]) Post(ctx context.Context, payload any) (*M, error) { - return unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodPost, c.ApiUrl, c.withMeshObjectPayload(payload))) +func (c MeshObjectClient[M]) Post(ctx context.Context, payload any, options ...RequestOption) (*M, error) { + return DoAuthorizedRequest[*M]( + ctx, + c.HttpClient, + http.MethodPost, + c.ApiUrl, + append(options, c.withMeshObjectPayload(payload))..., + ) } // Put updates an existing meshObject by ID with the given payload. // Automatically injects apiVersion and kind into the JSON payload. func (c MeshObjectClient[M]) Put(ctx context.Context, id string, payload any) (*M, error) { - return unmarshalBody[M](c.doAuthorizedRequest(ctx, http.MethodPut, c.ApiUrl.JoinPath(id), c.withMeshObjectPayload(payload))) + return DoAuthorizedRequest[*M](ctx, c.HttpClient, http.MethodPut, c.ApiUrl.JoinPath(id), c.withMeshObjectPayload(payload)) } // withMeshObjectPayload returns a RequestOption that sets the payload with apiVersion and kind injected, @@ -114,18 +120,12 @@ func (c MeshObjectClient[M]) withMeshObjectPayload(payload any) RequestOption { m["apiVersion"] = c.ApiVersion m["kind"] = c.Kind - return withPayload(m, c.meshObjectMimeType()) + return withPayload(m, c.MeshObjectMimeType()) } // Delete removes a meshObject by ID. -func (c MeshObjectClient[M]) Delete(ctx context.Context, id string) (err error) { - _, err = c.doAuthorizedRequest(ctx, http.MethodDelete, c.ApiUrl.JoinPath(id), withAccept(c.meshObjectMimeType())) - return -} - -// Purge removes a meshObject by ID without running any cloud-side cleanup, by calling DELETE /{id}/purge. -func (c MeshObjectClient[M]) Purge(ctx context.Context, id string) (err error) { - _, err = c.doAuthorizedRequest(ctx, http.MethodDelete, c.ApiUrl.JoinPath(id, "purge"), withAccept(c.meshObjectMimeType())) +func (c MeshObjectClient[M]) Delete(ctx context.Context, id string, options ...RequestOption) (err error) { + _, err = DoAuthorizedRequest[any](ctx, c.HttpClient, http.MethodDelete, c.ApiUrl.JoinPath(id), append(options, WithAccept(c.MeshObjectMimeType()))...) return } @@ -144,10 +144,10 @@ func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) Number int `json:"number"` } `json:"page"` } - response, err := unmarshalBody[paginatedResponse](c.doAuthorizedRequest(ctx, http.MethodGet, c.ApiUrl, append(options, - withAccept(c.meshObjectMimeType()), + response, err := DoAuthorizedRequest[paginatedResponse](ctx, c.HttpClient, http.MethodGet, c.ApiUrl, append(options, + WithAccept(c.MeshObjectMimeType()), WithUrlQuery("page", pageNumber), - )...)) + )...) if err != nil { return result, fmt.Errorf("error getting page %d: %w", pageNumber, err) } else if items, ok := response.Embedded[embeddedKey]; !ok { diff --git a/internal/options.go b/internal/options.go index 4726dde0..26d41cf2 100644 --- a/internal/options.go +++ b/internal/options.go @@ -11,6 +11,7 @@ type ( requestOptions struct { urlQueryParams map[string]string + extraPathElems []string requestPayload any requestModifiers []requestModifier } @@ -34,13 +35,20 @@ func WithUrlQuery(key string, value any) RequestOption { } } +// WithPathElems appends path elements to the request URL path. +func WithPathElems(pathElems ...string) RequestOption { + return func(opts *requestOptions) { + opts.extraPathElems = append(opts.extraPathElems, pathElems...) + } +} + func appendRequestModifier(modifier requestModifier) RequestOption { return func(opts *requestOptions) { opts.requestModifiers = append(opts.requestModifiers, modifier) } } -func withAccept(accept string) RequestOption { +func WithAccept(accept string) RequestOption { return withHeader("Accept", accept) } @@ -52,7 +60,7 @@ func withHeader(key, value string) RequestOption { func withPayload(payload any, contentType string) RequestOption { return func(opts *requestOptions) { - withAccept(contentType)(opts) + WithAccept(contentType)(opts) withHeader("Content-Type", contentType)(opts) opts.requestPayload = payload } From 4481ff511b009212de6b56e402fc63e54ffc5ba5 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 1 Jun 2026 14:46:23 +0200 Subject: [PATCH 153/215] =?UTF-8?q?refactor:=20normalize=20buildingblock?= =?UTF-8?q?=20=E2=86=92=20building=5Fblock=20file=20&=20symbol=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename all non-v1 building-block files from buildingblock_* to building_block_* to match the resource name meshstack_building_block. De-collide v1 symbols (NewBuildingblockResource / buildingblockResource) to free the CamelCase names for the new resource, and update the v1 deprecation message target from meshstack_building_block_v3 to meshstack_building_block. Rename-only; no behaviour change. Co-Authored-By: Claude Sonnet 4.6 --- ...lock_definition.go => building_block_definition.go | 0 ...version.go => building_block_definition_version.go | 0 ...uilding_block_definition_version_implementation.go | 0 ...st.go => building_block_definition_version_test.go | 0 buildingblock_runner.go => building_block_runner.go | 0 buildingblock_v2.go => building_block_v2.go | 0 buildingblock_v2_test.go => building_block_v2_test.go | 11 +++++------ 7 files changed, 5 insertions(+), 6 deletions(-) rename buildingblock_definition.go => building_block_definition.go (100%) rename buildingblock_definition_version.go => building_block_definition_version.go (100%) rename buildingblock_definition_version_implementation.go => building_block_definition_version_implementation.go (100%) rename buildingblock_definition_version_test.go => building_block_definition_version_test.go (100%) rename buildingblock_runner.go => building_block_runner.go (100%) rename buildingblock_v2.go => building_block_v2.go (100%) rename buildingblock_v2_test.go => building_block_v2_test.go (81%) diff --git a/buildingblock_definition.go b/building_block_definition.go similarity index 100% rename from buildingblock_definition.go rename to building_block_definition.go diff --git a/buildingblock_definition_version.go b/building_block_definition_version.go similarity index 100% rename from buildingblock_definition_version.go rename to building_block_definition_version.go diff --git a/buildingblock_definition_version_implementation.go b/building_block_definition_version_implementation.go similarity index 100% rename from buildingblock_definition_version_implementation.go rename to building_block_definition_version_implementation.go diff --git a/buildingblock_definition_version_test.go b/building_block_definition_version_test.go similarity index 100% rename from buildingblock_definition_version_test.go rename to building_block_definition_version_test.go diff --git a/buildingblock_runner.go b/building_block_runner.go similarity index 100% rename from buildingblock_runner.go rename to building_block_runner.go diff --git a/buildingblock_v2.go b/building_block_v2.go similarity index 100% rename from buildingblock_v2.go rename to building_block_v2.go diff --git a/buildingblock_v2_test.go b/building_block_v2_test.go similarity index 81% rename from buildingblock_v2_test.go rename to building_block_v2_test.go index 312887d2..dc221f94 100644 --- a/buildingblock_v2_test.go +++ b/building_block_v2_test.go @@ -22,7 +22,7 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { { name: "lifecycle state DELETED", bb: &MeshBuildingBlockV2{ - Status: MeshBuildingBlockV2Status{ + Status: &MeshBuildingBlockV2Status{ Lifecycle: MeshBuildingBlockV2Lifecycle{State: BUILDING_BLOCK_LIFECYCLE_STATE_DELETED}, }, }, @@ -32,9 +32,9 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { { name: "status FAILED during deletion", bb: &MeshBuildingBlockV2{ - Metadata: MeshBuildingBlockV2Metadata{Uuid: "test-uuid"}, - Status: MeshBuildingBlockV2Status{ - Status: BUILDING_BLOCK_STATUS_FAILED, + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{ + Status: BuildingBlockStatusFailed, }, }, wantDone: false, @@ -43,8 +43,7 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { { name: "still in progress (MARKED_FOR_DELETION lifecycle, non-failed status)", bb: &MeshBuildingBlockV2{ - Status: MeshBuildingBlockV2Status{ - Status: BUILDING_BLOCK_STATUS_IN_PROGRESS, + Status: &MeshBuildingBlockV2Status{ Lifecycle: MeshBuildingBlockV2Lifecycle{State: BUILDING_BLOCK_LIFECYCLE_STATE_MARKED_FOR_DELETION}, }, }, From cd3a815a15ae5908fbb441ced6d608565c970fb3 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 1 Jun 2026 14:47:35 +0200 Subject: [PATCH 154/215] feat: implement meshstack_building_block resource Adds the meshstack_building_block resource (v3), superseding v2 with: - In-place updates via PUT + explicit trigger-run; no destroy+recreate. Changes to content_hash, inputs, or parent_building_blocks trigger a rerun; in-place version upgrades are supported via PUT - Sensitive input support (value_string_sensitive/value_code_sensitive), preserving secrets across upgrades and avoiding phantom drift from null USER_INPUT rows - wait_for_completion: polls up to 30 min; WAITING_FOR_* states produce actionable warnings; preserved across reads/import (null vs false) - run-log diagnostics: on poll failure addRunFailureDiagnostics surfaces step-level logs as Terraform warnings - MoveState from meshstack_buildingblock (v1) and meshstack_building_block_v2 - target_ref validators (meshTenant requires uuid, meshWorkspace requires name) - Soft-delete-aware Read: a deleted building block is removed from state - Hardens the building_block_v2 client/mock/data-source: nil-pointer guards, ABORTED + nil Status handling in CreateSuccessful, sensitive input reads in the v2 data source, mock deep-copy fidelity - Fixes CHANGELOG, truncated all_inputs.value doc, purge description, operator-input test assertions, secret docs version reference - Regenerates provider docs (building_block.md replaces building_block_v3.md) Co-Authored-By: Claude Sonnet 4.6 --- building_block_run.go | 60 +++++++++++ building_block_v2.go | 206 +++++++++++++++++++++++++++----------- building_block_v2_test.go | 116 ++++++++++++++++++++- client.go | 2 + client_kind.go | 2 + internal/http_error.go | 5 + types/clienttypes.go | 4 +- 7 files changed, 330 insertions(+), 65 deletions(-) create mode 100644 building_block_run.go diff --git a/building_block_run.go b/building_block_run.go new file mode 100644 index 00000000..2083188d --- /dev/null +++ b/building_block_run.go @@ -0,0 +1,60 @@ +package client + +import ( + "context" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + +type MeshBuildingBlockRun struct { + Metadata MeshBuildingBlockRunMetadata `json:"metadata"` + Spec MeshBuildingBlockRunSpec `json:"spec"` + Status string `json:"status"` +} + +type MeshBuildingBlockRunMetadata struct { + Uuid string `json:"uuid"` + CreatedOn string `json:"createdOn"` +} + +type MeshBuildingBlockRunSpec struct { + RunNumber int64 `json:"runNumber"` + Behavior string `json:"behavior"` +} + +// MeshBuildingBlockRunLogs is the response from the download-logs actions endpoint. +type MeshBuildingBlockRunLogs struct { + Steps []MeshBuildingBlockRunStepLog `json:"steps"` +} + +// MeshBuildingBlockRunStepLog represents a single step's log data. +type MeshBuildingBlockRunStepLog struct { + DisplayName string `json:"displayName"` + Status string `json:"status"` + UserMessage *string `json:"userMessage"` + SystemMessage *string `json:"systemMessage"` +} + +type MeshBuildingBlockRunClient interface { + GetLogs(ctx context.Context, runUuid string) (MeshBuildingBlockRunLogs, error) +} + +type meshBuildingBlockRunClient struct { + meshObject internal.MeshObjectClient[MeshBuildingBlockRun] +} + +func newBuildingBlockRunClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockRunClient { + return meshBuildingBlockRunClient{ + meshObject: internal.NewMeshObjectClient[MeshBuildingBlockRun](ctx, httpClient, "v1"), + } +} + +func (c meshBuildingBlockRunClient) GetLogs(ctx context.Context, runUuid string) (MeshBuildingBlockRunLogs, error) { + return internal.DoAuthorizedRequest[MeshBuildingBlockRunLogs]( + ctx, + c.meshObject.HttpClient, + "GET", + c.meshObject.ApiUrl.JoinPath(runUuid, "logs"), + internal.WithAccept(c.meshObject.MeshObjectMimeType()), + ) +} diff --git a/building_block_v2.go b/building_block_v2.go index 3a806a24..8e0d4945 100644 --- a/building_block_v2.go +++ b/building_block_v2.go @@ -3,34 +3,47 @@ package client import ( "context" "encoding/json" + "errors" "fmt" + "slices" "github.com/meshcloud/terraform-provider-meshstack/client/internal" - types "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" ) -const ( - // Building Block Status Constants. - BUILDING_BLOCK_STATUS_WAITING_FOR_DEPENDENT_INPUT = "WAITING_FOR_DEPENDENT_INPUT" - BUILDING_BLOCK_STATUS_WAITING_FOR_OPERATOR_INPUT = "WAITING_FOR_OPERATOR_INPUT" - BUILDING_BLOCK_STATUS_PENDING = "PENDING" - BUILDING_BLOCK_STATUS_IN_PROGRESS = "IN_PROGRESS" - BUILDING_BLOCK_STATUS_SUCCEEDED = "SUCCEEDED" - BUILDING_BLOCK_STATUS_FAILED = "FAILED" - BUILDING_BLOCK_LIFECYCLE_STATE_ACTIVE = "ACTIVE" - BUILDING_BLOCK_LIFECYCLE_STATE_MARKED_FOR_DELETION = "MARKED_FOR_DELETION" - BUILDING_BLOCK_LIFECYCLE_STATE_DELETED = "DELETED" +type BuildingBlockLifecycleState string + +var ( + BuildingBlockLifecycleStates = enum.Enum[BuildingBlockLifecycleState]{} + BuildingBlockLifecycleStateActive = BuildingBlockLifecycleStates.Entry("ACTIVE") + BuildingBlockLifecycleStateMarkedForDeletion = BuildingBlockLifecycleStates.Entry("MARKED_FOR_DELETION") + BuildingBlockLifecycleStateDeleted = BuildingBlockLifecycleStates.Entry("DELETED") +) + +type BuildingBlockStatus string + +var ( + BuildingBlockStatuses = enum.Enum[BuildingBlockStatus]{} + BuildingBlockStatusWaitingForDependentInput = BuildingBlockStatuses.Entry("WAITING_FOR_DEPENDENT_INPUT") + BuildingBlockStatusWaitingForOperatorInput = BuildingBlockStatuses.Entry("WAITING_FOR_OPERATOR_INPUT") + BuildingBlockStatusWaitingForUserInput = BuildingBlockStatuses.Entry("WAITING_FOR_USER_INPUT") + BuildingBlockStatusPending = BuildingBlockStatuses.Entry("PENDING") + BuildingBlockStatusInProgress = BuildingBlockStatuses.Entry("IN_PROGRESS") + BuildingBlockStatusSucceeded = BuildingBlockStatuses.Entry("SUCCEEDED") + BuildingBlockStatusFailed = BuildingBlockStatuses.Entry("FAILED") + BuildingBlockStatusAborted = BuildingBlockStatuses.Entry("ABORTED") ) type MeshBuildingBlockV2 struct { Metadata MeshBuildingBlockV2Metadata `json:"metadata" tfsdk:"metadata"` Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` - Status MeshBuildingBlockV2Status `json:"status" tfsdk:"status"` + Status *MeshBuildingBlockV2Status `json:"status" tfsdk:"status"` } type MeshBuildingBlockV2Metadata struct { - Uuid string `json:"uuid" tfsdk:"uuid"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Uuid *string `json:"uuid" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } type MeshBuildingBlockV2Spec struct { @@ -38,46 +51,60 @@ type MeshBuildingBlockV2Spec struct { TargetRef MeshBuildingBlockV2TargetRef `json:"targetRef" tfsdk:"target_ref"` DisplayName string `json:"displayName" tfsdk:"display_name"` - Inputs map[string]MeshBuildingBlockV2Input `json:"inputs" tfsdk:"-"` - ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` + // Inputs as pointer MeshBuildingBlockInput to support mocking secret responses. + Inputs map[string]*MeshBuildingBlockInput `json:"inputs" tfsdk:"inputs"` + ParentBuildingBlocks types.Set[MeshBuildingBlockParent] `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` } -type MeshBuildingBlockV2Input struct { - Value types.SecretOrAny `json:"value"` - ValueType string `json:"valueType"` - IsSensitive bool `json:"isSensitive"` - AssignmentType *string `json:"assignmentType"` - UpdateableByConsumer bool `json:"updateableByConsumer"` +type MeshBuildingBlockInput struct { + Value types.SecretOrAny `json:"value" tfsdk:"value"` + ValueType *enum.Entry[MeshBuildingBlockIOType] `json:"valueType,omitempty" tfsdk:"-"` + AssignmentType enum.Entry[MeshBuildingBlockInputAssignmentType] `json:"assignmentType,omitempty" tfsdk:"-"` + + // If IsSensitive is true, the [types.Variant] (typedef [types.SecretOrAny]) for Value field + // is of [types.Secret] (case [types.Variant.X]). + // Otherwise, the [types.Variant] is of [types.Any] (case [types.Variant.Y]). + // As this is a fallback detection when JSON (un)marshaling, + // types.Any must go second as [types.Variant] intentionally prefers X over Y. + IsSensitive bool `json:"isSensitive" tfsdk:"-"` } -func (m *MeshBuildingBlockV2Input) UnmarshalJSON(bytes []byte) error { - type wrapped MeshBuildingBlockV2Input +func (m *MeshBuildingBlockInput) UnmarshalJSON(bytes []byte) error { + type wrapped MeshBuildingBlockInput var target wrapped if err := json.Unmarshal(bytes, &target); err != nil { return err } - *m = MeshBuildingBlockV2Input(target) - // Non-sensitive values must live in the Variant's Y branch; the Variant prefers X and - // types.Secret fields are omitempty, so move any accidental X match to Y when not sensitive. - if !m.IsSensitive && m.Value.HasX() { - xJson, err := json.Marshal(m.Value.X) - if err != nil { - return err + *m = MeshBuildingBlockInput(target) + switch { + case !m.IsSensitive: + // ensure "any" struct fields never end up in X accidentally, + // as X is only set when IsSensitive is true! + var errs []error + moveXtoYIfPresent := func(v *types.SecretOrAny) { + if v.HasX() { + xJson, err := json.Marshal(v.X) + errs = append(errs, err) + v.X = types.Secret{} + errs = append(errs, json.Unmarshal(xJson, &v.Y)) + } } - m.Value.X = types.Secret{} - return json.Unmarshal(xJson, &m.Value.Y) + moveXtoYIfPresent(&m.Value) + return errors.Join(errs...) + case m.Value.HasY(): + return fmt.Errorf("got sensitive argument or default_value but variant Y is set instead") + default: + return nil } - return nil -} - -type MeshBuildingBlockV2Output struct { - Value any `json:"value"` - ValueType string `json:"valueType"` - AssignmentType *string `json:"assignmentType"` } type MeshBuildingBlockV2DefinitionVersionRef struct { Uuid string `json:"uuid" tfsdk:"uuid"` + // ContentHash is a Terraform-only field (json:"-", never sent to or returned by the backend). + // It lets a config signal that the referenced version's content changed so a rerun is triggered + // even though the version uuid is unchanged. The building_block (v3) resource honors it via the + // shared rerunNeeded predicate used by both ModifyPlan and Update. + ContentHash *string `json:"-" tfsdk:"content_hash"` } type MeshBuildingBlockV2TargetRef struct { @@ -86,26 +113,36 @@ type MeshBuildingBlockV2TargetRef struct { Name *string `json:"name" tfsdk:"name"` } -type MeshBuildingBlockV2Create struct { - Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` -} - type MeshBuildingBlockV2Lifecycle struct { - State string `json:"state" tfsdk:"state"` + State enum.Entry[BuildingBlockLifecycleState] `json:"state" tfsdk:"state"` } type MeshBuildingBlockV2Status struct { - Status string `json:"status" tfsdk:"status"` - Outputs map[string]MeshBuildingBlockV2Output `json:"outputs" tfsdk:"-"` - ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` - Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"lifecycle"` + Status enum.Entry[BuildingBlockStatus] `json:"status" tfsdk:"status"` + Outputs map[string]MeshBuildingBlockOutput `json:"outputs" tfsdk:"outputs"` + ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` + Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"-"` + // LatestRunUuid is nil if permissions don't allow reading the run (e.g. because run_transparency is false). + // It tracks the latest *modifying* (apply/destroy) run and excludes dry runs. + LatestRunUuid *string `json:"latestRunUuid" tfsdk:"latest_run_uuid"` + // LatestDryRunUuid is the latest dry (DETECT) run, but only when it is the newest run; nil otherwise. + // Same permission gating and nullability caveat as LatestRunUuid. + LatestDryRunUuid *string `json:"latestDryRunUuid" tfsdk:"latest_dry_run_uuid"` +} + +type MeshBuildingBlockOutput struct { + Value types.Any `json:"value" tfsdk:"value"` + ValueType enum.Entry[MeshBuildingBlockIOType] `json:"valueType" tfsdk:"value_type"` + AssignmentType enum.Entry[MeshBuildingBlockDefinitionOutputAssignmentType] `json:"assignmentType" tfsdk:"assignment_type"` } type MeshBuildingBlockV2Client interface { Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) - Create(ctx context.Context, bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) + Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) + Update(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) Delete(ctx context.Context, uuid string, purge bool) error + TriggerRun(ctx context.Context, uuid string) error } type meshBuildingBlockV2Client struct { @@ -126,10 +163,17 @@ func (c meshBuildingBlockV2Client) ReadFunc(uuid string) func(ctx context.Contex } } -func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2Create) (*MeshBuildingBlockV2, error) { +func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { return c.meshObject.Post(ctx, bb) } +func (c meshBuildingBlockV2Client) Update(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { + if bb.Metadata.Uuid == nil { + return nil, fmt.Errorf("cannot update building block without UUID") + } + return c.meshObject.Put(ctx, *bb.Metadata.Uuid, bb) +} + func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string, purge bool) error { var options []internal.RequestOption if purge { @@ -138,14 +182,41 @@ func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string, purg return c.meshObject.Delete(ctx, uuid, options...) } +// IsWaitingForInput reports whether the building block run is paused awaiting +// human or dependency input. Such a run will not progress on its own, so polling +// callers treat it as a terminal (but non-fatal) state and surface a warning. +func (bb *MeshBuildingBlockV2) IsWaitingForInput() bool { + return bb.Status.Status == BuildingBlockStatusWaitingForOperatorInput || + bb.Status.Status == BuildingBlockStatusWaitingForUserInput || + bb.Status.Status == BuildingBlockStatusWaitingForDependentInput +} + +// bbUuidOrUnknown returns the building block UUID for diagnostic messages, or "" if nil. +func bbUuidOrUnknown(bb *MeshBuildingBlockV2) string { + if bb != nil && bb.Metadata.Uuid != nil { + return *bb.Metadata.Uuid + } + return "" +} + func (bb *MeshBuildingBlockV2) CreateSuccessful() (done bool, err error) { switch { case bb == nil: err = fmt.Errorf("building block not found after creation") - case bb.Status.Status == BUILDING_BLOCK_STATUS_FAILED: - err = fmt.Errorf("building block %s reached FAILED state during creation, check the building block run logs in meshStack", bb.Metadata.Uuid) - case bb.Status.Status == BUILDING_BLOCK_STATUS_SUCCEEDED: + case bb.Status == nil: + // no status yet — keep polling + case bb.Status.Status == BuildingBlockStatusFailed, + bb.Status.Status == BuildingBlockStatusAborted: + err = fmt.Errorf("building block %s reached %s state, check run logs in meshStack", bbUuidOrUnknown(bb), bb.Status.Status) + case bb.IsWaitingForInput(): + // Paused awaiting input — stop polling so the caller can surface a warning. + done = true + case bb.Status.Status == BuildingBlockStatusSucceeded: done = true + case !slices.Contains(BuildingBlockStatuses, bb.Status.Status): + // Unrecognized status: fail fast instead of polling to the timeout — the backend returned a + // status this provider version does not know about (provider may be out of date). + err = fmt.Errorf("unknown building block status %q for building block %s; provider may be out of date", bb.Status.Status, bbUuidOrUnknown(bb)) } return } @@ -153,13 +224,28 @@ func (bb *MeshBuildingBlockV2) CreateSuccessful() (done bool, err error) { func (bb *MeshBuildingBlockV2) DeletionSuccessful() (done bool, err error) { switch { case bb == nil: - // Expected when receiving a 404 (hard deletion), default behavior until meshStack v2026.20.0. - // For versions higher than that, we get a building block back with a lifecycle state to inspect. + // 404: the block was hard-removed (e.g. its definition was deleted too); treat as done. done = true - case bb.Status.Lifecycle.State == BUILDING_BLOCK_LIFECYCLE_STATE_DELETED: + case bb.Status != nil && bb.Status.Lifecycle.State == BuildingBlockLifecycleStateDeleted: + // Soft delete: once deletion completes the backend keeps returning the block with lifecycle + // DELETED (it does not 404), so treat DELETED as done. While deletion is still in progress the + // block is returned with MARKED_FOR_DELETION, which falls through as not-yet-done so we keep polling. done = true - case bb.Status.Status == BUILDING_BLOCK_STATUS_FAILED: - err = fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", bb.Metadata.Uuid) + case bb.Status != nil && bb.Status.Status == BuildingBlockStatusFailed: + err = fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", bbUuidOrUnknown(bb)) } return } + +func (c meshBuildingBlockV2Client) TriggerRun(ctx context.Context, bbUuid string) error { + // trigger-run returns an empty 2xx body; use DoAuthorizedRequest[any] to signal no body expected. + // No body is sent, so the backend triggers a normal (non-dry) apply run. + _, err := internal.DoAuthorizedRequest[any]( + ctx, + c.meshObject.HttpClient, + "POST", + c.meshObject.ApiUrl.JoinPath(bbUuid, "trigger-run"), + internal.WithAccept(c.meshObject.MeshObjectMimeType()), + ) + return err +} diff --git a/building_block_v2_test.go b/building_block_v2_test.go index dc221f94..85313b82 100644 --- a/building_block_v2_test.go +++ b/building_block_v2_test.go @@ -4,6 +4,9 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" ) func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { @@ -14,16 +17,16 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { wantErr bool }{ { - name: "nil (hard deletion / 404)", + name: "nil (404 — hard deletion / purge)", bb: nil, wantDone: true, wantErr: false, }, { - name: "lifecycle state DELETED", + name: "lifecycle state DELETED (soft delete completed, block still returned)", bb: &MeshBuildingBlockV2{ Status: &MeshBuildingBlockV2Status{ - Lifecycle: MeshBuildingBlockV2Lifecycle{State: BUILDING_BLOCK_LIFECYCLE_STATE_DELETED}, + Lifecycle: MeshBuildingBlockV2Lifecycle{State: BuildingBlockLifecycleStateDeleted}, }, }, wantDone: true, @@ -40,11 +43,22 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { wantDone: false, wantErr: true, }, + { + name: "status FAILED with nil Uuid does not panic", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: nil}, + Status: &MeshBuildingBlockV2Status{ + Status: BuildingBlockStatusFailed, + }, + }, + wantDone: false, + wantErr: true, + }, { name: "still in progress (MARKED_FOR_DELETION lifecycle, non-failed status)", bb: &MeshBuildingBlockV2{ Status: &MeshBuildingBlockV2Status{ - Lifecycle: MeshBuildingBlockV2Lifecycle{State: BUILDING_BLOCK_LIFECYCLE_STATE_MARKED_FOR_DELETION}, + Lifecycle: MeshBuildingBlockV2Lifecycle{State: BuildingBlockLifecycleStateMarkedForDeletion}, }, }, wantDone: false, @@ -64,3 +78,97 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { }) } } + +func TestMeshBuildingBlockV2_CreateSuccessful(t *testing.T) { + tests := []struct { + name string + bb *MeshBuildingBlockV2 + wantDone bool + wantErr bool + errContains string + }{ + { + name: "nil (not found after creation)", + bb: nil, + wantDone: false, + wantErr: true, + }, + { + name: "no status yet — keep polling", + bb: &MeshBuildingBlockV2{Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}}, + wantDone: false, + wantErr: false, + }, + { + name: "SUCCEEDED", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusSucceeded}, + }, + wantDone: true, + wantErr: false, + }, + { + name: "FAILED", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusFailed}, + }, + wantDone: false, + wantErr: true, + }, + { + name: "ABORTED", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusAborted}, + }, + wantDone: false, + wantErr: true, + }, + { + name: "WAITING_FOR_USER_INPUT — terminal but non-fatal", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusWaitingForUserInput}, + }, + wantDone: true, + wantErr: false, + }, + { + name: "FAILED with nil Uuid does not panic", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: nil}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusFailed}, + }, + wantDone: false, + wantErr: true, + errContains: "", + }, + { + name: "unknown status — fail fast", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: enum.Entry[BuildingBlockStatus]("SOMETHING_NEW")}, + }, + wantDone: false, + wantErr: true, + errContains: "unknown building block status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done, err := tt.bb.CreateSuccessful() + assert.Equal(t, tt.wantDone, done) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/client.go b/client.go index 1c3c194e..52988c6a 100644 --- a/client.go +++ b/client.go @@ -21,6 +21,7 @@ type Client struct { ApiKey MeshApiKeyClient BuildingBlock MeshBuildingBlockClient BuildingBlockV2 MeshBuildingBlockV2Client + BuildingBlockRun MeshBuildingBlockRunClient BuildingBlockDefinition MeshBuildingBlockDefinitionClient BuildingBlockDefinitionVersion MeshBuildingBlockDefinitionVersionClient BuildingBlockRunner MeshBuildingBlockRunnerClient @@ -72,6 +73,7 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza ApiKey: newApiKeyClient(ctx, httpClient), BuildingBlock: newBuildingBlockClient(ctx, httpClient), BuildingBlockV2: newBuildingBlockV2Client(ctx, httpClient), + BuildingBlockRun: newBuildingBlockRunClient(ctx, httpClient), BuildingBlockDefinition: newBuildingBlockDefinitionClient(ctx, httpClient), BuildingBlockDefinitionVersion: newBuildingBlockDefinitionVersionClient(ctx, httpClient), BuildingBlockRunner: newBuildingBlockRunnerClient(ctx, httpClient), diff --git a/client_kind.go b/client_kind.go index 6bc91fad..3264d46e 100644 --- a/client_kind.go +++ b/client_kind.go @@ -4,6 +4,7 @@ package client type meshObjectKind struct { ApiKey string BuildingBlock string + BuildingBlockRun string BuildingBlockDefinition string BuildingBlockDefinitionVersion string BuildingBlockRunner string @@ -28,6 +29,7 @@ type meshObjectKind struct { var MeshObjectKind = meshObjectKind{ ApiKey: "meshApiKey", BuildingBlock: "meshBuildingBlock", + BuildingBlockRun: "meshBuildingBlockRun", BuildingBlockDefinition: "meshBuildingBlockDefinition", BuildingBlockDefinitionVersion: "meshBuildingBlockDefinitionVersion", BuildingBlockRunner: "meshBuildingBlockRunner", diff --git a/internal/http_error.go b/internal/http_error.go index 92030fc0..55cc32c8 100644 --- a/internal/http_error.go +++ b/internal/http_error.go @@ -25,3 +25,8 @@ func (e HttpError) IsForbidden() bool { func (e HttpError) IsNotFound() bool { return e.StatusCode == http.StatusNotFound } + +// IsConflict returns true if the error is a 409 Conflict response. +func (e HttpError) IsConflict() bool { + return e.StatusCode == http.StatusConflict +} diff --git a/types/clienttypes.go b/types/clienttypes.go index 17589cb5..e0ec3204 100644 --- a/types/clienttypes.go +++ b/types/clienttypes.go @@ -11,8 +11,10 @@ type ( Set[T any] []T Secret struct { + // Plaintext is optionally set if secret is initially created (or rotated later) Plaintext *string `json:"plaintext,omitempty" tfsdk:"plaintext"` - Hash *string `json:"hash,omitempty" tfsdk:"-"` + // Hash is always present in responses (Plaintext is never returned) and set in requests if secret is supposed to be kept. + Hash *string `json:"hash,omitempty" tfsdk:"-"` } SecretOrAny = variant.Variant[Secret, any] From ed533bfe9653adde49468d38ea495361ade72e95 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 25 Jun 2026 10:50:44 +0200 Subject: [PATCH 155/215] feat: meshstack_building_blocks data source Add a read-only, filterable list data source backed by the v2-preview building block list endpoint. Each entry mirrors the meshstack_building_block resource (metadata/spec/status/all_inputs); sensitive inputs are surfaced as a hash only, reusing the secret data-source schema. Filters: workspace/project/platform identifier, name, definition_uuid, version_uuid, version_number (lenient "v1"/"1"), tenant_uuid, target_kind, status, lifecycle_states (repeated query param; empty => all), and the platform-operator scope selectors managed_by_definition_uuid / managed_by_workspace_identifier (MANAGED_BUILDINGBLOCK_LIST). Adds a List method + filter struct on the building block v2 client, a WithUrlQueryValues option for repeated query params, and a mock List implementation. Reuses the resource's all_inputs mapping via an extracted buildAllInput helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- building_block_v2.go | 56 ++++++++++++++++++++++++++++++++++++ internal/http_client_test.go | 22 ++++++++++++-- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/building_block_v2.go b/building_block_v2.go index 8e0d4945..1c2f2a8c 100644 --- a/building_block_v2.go +++ b/building_block_v2.go @@ -136,9 +136,36 @@ type MeshBuildingBlockOutput struct { AssignmentType enum.Entry[MeshBuildingBlockDefinitionOutputAssignmentType] `json:"assignmentType" tfsdk:"assignment_type"` } +// MeshBuildingBlockV2ListFilter holds the optional query filters for listing building blocks +// via the v2-preview list endpoint. All scalar fields are nil when unset (omitted from the +// query). The backend returns only active building blocks; soft-deleted ones are not listed. +type MeshBuildingBlockV2ListFilter struct { + WorkspaceIdentifier *string + ProjectIdentifier *string + PlatformIdentifier *string + Name *string + // DefinitionUuid filters by the owning building block definition's UUID (not a version). + DefinitionUuid *string + // VersionUuid filters by a specific building block definition version UUID. + VersionUuid *string + // VersionNumber filters by the literal definition version number. The backend parses it + // leniently, so both "v1" and "1" match version 1. + VersionNumber *string + TenantUuid *string + // TargetKind filters by target ref kind, one of meshTenant or meshWorkspace. + TargetKind *string + Status *string + // ManagedByWorkspaceIdentifier and ManagedByDefinitionUuid select the platform-operator + // (managed) permission scope: building blocks created from definitions owned by the given + // workspace / definition. Requires the MANAGED_BUILDINGBLOCK_LIST authority. + ManagedByWorkspaceIdentifier *string + ManagedByDefinitionUuid *string +} + type MeshBuildingBlockV2Client interface { Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) + List(ctx context.Context, filter *MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) Update(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) Delete(ctx context.Context, uuid string, purge bool) error @@ -163,6 +190,35 @@ func (c meshBuildingBlockV2Client) ReadFunc(uuid string) func(ctx context.Contex } } +func (c meshBuildingBlockV2Client) List(ctx context.Context, filter *MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) { + var options []internal.RequestOption + + // Map each non-nil scalar filter to its query param. Names must match the backend + // fetchBuildingBlocksV2 @RequestParam names exactly; a typo silently disables the filter. + if filter != nil { + for key, value := range map[string]*string{ + "workspaceIdentifier": filter.WorkspaceIdentifier, + "projectIdentifier": filter.ProjectIdentifier, + "platformIdentifier": filter.PlatformIdentifier, + "name": filter.Name, + "definitionUuid": filter.DefinitionUuid, + "versionUuid": filter.VersionUuid, + "versionNumber": filter.VersionNumber, + "tenantUuid": filter.TenantUuid, + "targetRefKind": filter.TargetKind, + "status": filter.Status, + "managedByWorkspaceIdentifier": filter.ManagedByWorkspaceIdentifier, + "managedByDefinitionUuid": filter.ManagedByDefinitionUuid, + } { + if value != nil { + options = append(options, internal.WithUrlQuery(key, *value)) + } + } + } + + return c.meshObject.List(ctx, options...) +} + func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { return c.meshObject.Post(ctx, bb) } diff --git a/internal/http_client_test.go b/internal/http_client_test.go index ea38a0f9..152799dd 100644 --- a/internal/http_client_test.go +++ b/internal/http_client_test.go @@ -36,7 +36,7 @@ func TestHttpClient(t *testing.T) { t.Run("DoRequest object call with empty 2xx body errors", func(t *testing.T) { client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(http.StatusOK) // 200 with no body + resp.WriteHeader(http.StatusOK) }) _, err := DoRequest[*string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) require.Error(t, err) @@ -45,7 +45,7 @@ func TestHttpClient(t *testing.T) { t.Run("DoRequest no-content call (any) tolerates an empty 2xx body", func(t *testing.T) { client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(http.StatusAccepted) // e.g. trigger-run / delete: empty body by design + resp.WriteHeader(http.StatusAccepted) // empty body by design (trigger-run/delete) }) _, err := DoRequest[any](t.Context(), client, http.MethodPost, client.RootUrl.JoinPath("trigger-run")) require.NoError(t, err) @@ -259,6 +259,24 @@ func TestHttpClient(t *testing.T) { }) } +func TestUrlQueryOptions(t *testing.T) { + t.Run("WithUrlQuery sets query parameters", func(t *testing.T) { + var gotQuery url.Values + client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + gotQuery = req.URL.Query() + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write([]byte(`"ok"`)) + }) + _, err := DoRequest[string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("list"), + WithUrlQuery("definitionUuid", "abc"), + WithUrlQuery("status", "SUCCEEDED"), + ) + require.NoError(t, err) + assert.Equal(t, "abc", gotQuery.Get("definitionUuid")) + assert.Equal(t, "SUCCEEDED", gotQuery.Get("status")) + }) +} + func mockTimeNowAsUTC(t *testing.T) time.Time { t.Helper() now := time.Now().UTC().Truncate(time.Second) From 66d8a17e31a5ac26a3fda1062f90c8dbe896791d Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 6 Jul 2026 07:13:36 +0200 Subject: [PATCH 156/215] fix: retry DELETE and widen retry budget for backend restarts Smoke tests against dev repeatedly failed with `503 Service Unavailable` whenever the meshfed backend restarted (e.g. an OOMKill + Spring Boot cold start), which leaves the gateway returning 503 for ~2-3 minutes. Two gaps let those transient 503s surface as hard failures: - DELETE was never retried (only GET/PUT and whitelisted POST /api/login), so a building block delete that hit a 503 failed immediately. DELETE is idempotent, so replaying it is safe. - The retry budget was only ~75s (MaxRetries 10, MaxWait 10s), shorter than a typical backend restart, so even the retried GET paths (BBD read, status polling, /mesh/info version check on provider configure) exhausted retries before the backend came back. Add DELETE to the idempotent methods retried on 429/502/503/504 and transport errors, and widen the budget to ~4 minutes (MaxRetries 12, MaxWait 30s). Co-Authored-By: Claude Opus 4.8 (1M context) --- client.go | 8 ++++++-- internal/http_client_test.go | 15 +++++++++++++++ internal/retry.go | 9 ++++++--- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/client.go b/client.go index 52988c6a..b366b318 100644 --- a/client.go +++ b/client.go @@ -59,8 +59,12 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza httpClient := internal.WithRetry( internal.NewHttpClient(rootUrl, userAgent, auth), internal.RetryOptions{ - MaxRetries: 10, - Backoff: internal.ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 10 * time.Second}, + // Sized to ride out a full meshStack backend restart (e.g. an OOMKill followed by a + // Spring Boot cold start), which can leave the gateway returning 503 for ~2-3 minutes — + // well beyond the previous ~75s budget. This backoff sequence sums to ~4 minutes: + // 1+2+4+8+16+30*7 seconds. + MaxRetries: 12, + Backoff: internal.ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 30 * time.Second}, WhitelistedPaths: map[string][]string{"POST": {apiLoginPath}}, }, ) diff --git a/internal/http_client_test.go b/internal/http_client_test.go index 152799dd..36da3a21 100644 --- a/internal/http_client_test.go +++ b/internal/http_client_test.go @@ -129,6 +129,21 @@ func TestHttpClient(t *testing.T) { assert.Equal(t, 1, attempts, "PATCH must not be retried") }) + t.Run("DoRequest with DELETE (retried, idempotent)", func(t *testing.T) { + attempts := 0 + client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { + attempts++ + if attempts == 1 { + resp.WriteHeader(503) + return + } + resp.WriteHeader(http.StatusNoContent) + }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}}) + _, err := DoRequest[any](t.Context(), client, http.MethodDelete, client.RootUrl.JoinPath("delete")) + require.NoError(t, err) + assert.Equal(t, 2, attempts, "DELETE must be retried after a 503") + }) + t.Run("DoRequest with PUT replays body on retry", func(t *testing.T) { attempt := 0 client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { diff --git a/internal/retry.go b/internal/retry.go index 6906422d..5d382781 100644 --- a/internal/retry.go +++ b/internal/retry.go @@ -14,8 +14,8 @@ import ( ) // WithRetry sets up the given client to retry certain requests. -// GET and PUT are retried by default, POST only if the path is explicitly whitelisted. -// See RetryOptions. +// The idempotent methods GET, PUT and DELETE are retried by default, POST only if the path is +// explicitly whitelisted. See RetryOptions. func WithRetry(c HttpClient, options RetryOptions) HttpClient { next := http.DefaultTransport if c.Transport != nil { @@ -40,7 +40,10 @@ func WithRetry(c HttpClient, options RetryOptions) HttpClient { return false } switch req.Method { - case http.MethodGet, http.MethodPut: + case http.MethodGet, http.MethodPut, http.MethodDelete: + // Idempotent methods are safe to retry: replaying them cannot create duplicate + // side effects. A DELETE that actually succeeded server-side before a proxy 503 + // simply yields a 404 on replay, which delete handlers already treat as done. return true } if whitelisted, found := whitelistedByMethodAndUrl[req.Method]; found { From b244bace5dbecc5cdbe19117e46f5340fcd740ef Mon Sep 17 00:00:00 2001 From: Stefan Tomm Date: Tue, 7 Jul 2026 11:33:00 +0200 Subject: [PATCH 157/215] fix: prepare for upcoming WAITING_FOR_APPROVAL building block status meshStack will soon add an approval gate that surfaces building block runs as WAITING_FOR_APPROVAL (added in meshfed, not released yet). The provider's await logic treats any status not in its BuildingBlockStatuses enum as fatal ("unknown building block status; provider may be out of date"), so once the backend starts returning it an awaited create/update would error when a run parked for approval. Add WAITING_FOR_APPROVAL to the enum and to IsWaitingForInput() ahead of that rollout so it is treated as a non-terminal, non-fatal parked state: polling stops and a "waiting for input" warning is surfaced, matching the other WAITING_FOR_* states. Generalize the warning wording to cover approvals, complete the legacy v1 status doc strings, and regenerate docs. Co-Authored-By: Claude Opus 4.8 --- building_block_v2.go | 9 ++++++--- building_block_v2_test.go | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/building_block_v2.go b/building_block_v2.go index 1c2f2a8c..31eca841 100644 --- a/building_block_v2.go +++ b/building_block_v2.go @@ -28,6 +28,7 @@ var ( BuildingBlockStatusWaitingForDependentInput = BuildingBlockStatuses.Entry("WAITING_FOR_DEPENDENT_INPUT") BuildingBlockStatusWaitingForOperatorInput = BuildingBlockStatuses.Entry("WAITING_FOR_OPERATOR_INPUT") BuildingBlockStatusWaitingForUserInput = BuildingBlockStatuses.Entry("WAITING_FOR_USER_INPUT") + BuildingBlockStatusWaitingForApproval = BuildingBlockStatuses.Entry("WAITING_FOR_APPROVAL") BuildingBlockStatusPending = BuildingBlockStatuses.Entry("PENDING") BuildingBlockStatusInProgress = BuildingBlockStatuses.Entry("IN_PROGRESS") BuildingBlockStatusSucceeded = BuildingBlockStatuses.Entry("SUCCEEDED") @@ -239,12 +240,14 @@ func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string, purg } // IsWaitingForInput reports whether the building block run is paused awaiting -// human or dependency input. Such a run will not progress on its own, so polling -// callers treat it as a terminal (but non-fatal) state and surface a warning. +// human input, a dependency, or an approval. Such a run will not progress on its +// own, so polling callers treat it as a terminal (but non-fatal) state and surface +// a warning. func (bb *MeshBuildingBlockV2) IsWaitingForInput() bool { return bb.Status.Status == BuildingBlockStatusWaitingForOperatorInput || bb.Status.Status == BuildingBlockStatusWaitingForUserInput || - bb.Status.Status == BuildingBlockStatusWaitingForDependentInput + bb.Status.Status == BuildingBlockStatusWaitingForDependentInput || + bb.Status.Status == BuildingBlockStatusWaitingForApproval } // bbUuidOrUnknown returns the building block UUID for diagnostic messages, or "" if nil. diff --git a/building_block_v2_test.go b/building_block_v2_test.go index 85313b82..d93d6a91 100644 --- a/building_block_v2_test.go +++ b/building_block_v2_test.go @@ -135,6 +135,15 @@ func TestMeshBuildingBlockV2_CreateSuccessful(t *testing.T) { wantDone: true, wantErr: false, }, + { + name: "WAITING_FOR_APPROVAL — terminal but non-fatal", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{Status: BuildingBlockStatusWaitingForApproval}, + }, + wantDone: true, + wantErr: false, + }, { name: "FAILED with nil Uuid does not panic", bb: &MeshBuildingBlockV2{ From 1c138e69318254315fde3f17f48d3e38c5bd5063 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Fri, 3 Jul 2026 21:28:32 +0200 Subject: [PATCH 158/215] feat: allow to manage display_order value for building block definition I/O without affecting the calculated hash for change detection CU-86cabn76y --- building_block_definition_version.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 72c38aaf..7882d1c9 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -112,6 +112,7 @@ type MeshBuildingBlockDefinitionInput struct { Description *string `json:"description,omitempty" tfsdk:"description"` ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` + DisplayOrder int64 `json:"displayOrder,omitempty" tfsdk:"display_order"` } func (m *MeshBuildingBlockDefinitionInput) UnmarshalJSON(bytes []byte) error { @@ -148,6 +149,7 @@ type MeshBuildingBlockDefinitionOutput struct { DisplayName string `json:"displayName" tfsdk:"display_name"` Type MeshBuildingBlockIOType `json:"type" tfsdk:"type"` AssignmentType MeshBuildingBlockDefinitionOutputAssignmentType `json:"assignmentType" tfsdk:"assignment_type"` + DisplayOrder int64 `json:"displayOrder,omitempty" tfsdk:"display_order"` } // Main version types From 37932cf07ae3715ad1136f5208e8517777e22395 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 9 Jul 2026 22:21:56 +0200 Subject: [PATCH 159/215] fix(building_block_definition): send display_order 0 so it round-trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit display_order was serialized with json `omitempty`, so the schema default of 0 (and what an unknown plan value collapses to via WithSetUnknownValueToZero) was dropped from the request. The backend then assigned a position itself, so the applied value differed from the plan — "Provider produced inconsistent result after apply: ...display_order: was 0, now 1" across the acceptance suite. - client: drop `omitempty` on input/output DisplayOrder (value int64) so 0 is sent and the backend stores it verbatim. - content hash: normalize display_order to 0 before hashing so presentation-only reordering is not a content change and the hash stays decoupled from the wire value. Golden hashes updated accordingly (the invariants still hold). - test: 07_manual_computed_outputs expects the backend's positional display_order for derived outputs (input order, 0-based: approval=0, region=1, ticket=2), per meshfed ManualDefinitionVersionService. - mock: derive manual output display_order by input index to mirror the backend. Verified against a local develop backend: TestAccBuildingBlockDefinition (all subtests) and TestAccLandingZone pass; unit tests and lint are green. Co-Authored-By: Claude Opus 4.8 (1M context) --- building_block_definition_version.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 7882d1c9..5e88a93a 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -112,7 +112,10 @@ type MeshBuildingBlockDefinitionInput struct { Description *string `json:"description,omitempty" tfsdk:"description"` ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` - DisplayOrder int64 `json:"displayOrder,omitempty" tfsdk:"display_order"` + // No omitempty: a 0 (the schema default, and what an unknown plan value collapses to) must be sent so + // the backend stores it verbatim. With omitempty the 0 would be dropped and the backend would assign + // a position itself, making the applied value differ from the plan. + DisplayOrder int64 `json:"displayOrder" tfsdk:"display_order"` } func (m *MeshBuildingBlockDefinitionInput) UnmarshalJSON(bytes []byte) error { @@ -149,7 +152,8 @@ type MeshBuildingBlockDefinitionOutput struct { DisplayName string `json:"displayName" tfsdk:"display_name"` Type MeshBuildingBlockIOType `json:"type" tfsdk:"type"` AssignmentType MeshBuildingBlockDefinitionOutputAssignmentType `json:"assignmentType" tfsdk:"assignment_type"` - DisplayOrder int64 `json:"displayOrder,omitempty" tfsdk:"display_order"` + // No omitempty so a 0 is sent, not dropped (see MeshBuildingBlockDefinitionInput.DisplayOrder). + DisplayOrder int64 `json:"displayOrder" tfsdk:"display_order"` } // Main version types From 3fda00f1199124de49565d417f142e50b4ad79ad Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 14 Jul 2026 13:49:38 +0200 Subject: [PATCH 160/215] fix(building_block): tolerate transient FAILED during a force-purge deletion When a building block's definition uses deletion_mode = PURGE, the backend force-purges the block: it soft-deletes it regardless of the delete run's outcome. The delete run still executes, so a block whose destroy run fails passes through a transient FAILED status before the lifecycle reaches DELETED. DeletionSuccessful treated any FAILED status as terminal, so a delete-poll that happened to sample that transient window aborted with "reached FAILED state during deletion" even though the block was about to be purged -- an intermittent, timing-dependent deletion failure. Tolerate FAILED while status.forcePurge is set and keep polling until DELETED. A FAILED deletion that is not being purged is still surfaced as an error. Co-Authored-By: Claude Opus 4.8 (1M context) --- building_block_v2.go | 8 +++++++- building_block_v2_test.go | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/building_block_v2.go b/building_block_v2.go index 31eca841..79e5e896 100644 --- a/building_block_v2.go +++ b/building_block_v2.go @@ -291,7 +291,13 @@ func (bb *MeshBuildingBlockV2) DeletionSuccessful() (done bool, err error) { // block is returned with MARKED_FOR_DELETION, which falls through as not-yet-done so we keep polling. done = true case bb.Status != nil && bb.Status.Status == BuildingBlockStatusFailed: - err = fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", bbUuidOrUnknown(bb)) + // A force-purge (definition deletion_mode = PURGE, or an admin purge) deletes the block + // regardless of its delete run's outcome, so a FAILED status here is transient — the + // lifecycle still proceeds to DELETED. Keep polling instead of erroring on that transient + // FAILED. Only a FAILED delete that is NOT being force-purged is a genuine stuck deletion. + if !bb.Status.ForcePurge { + err = fmt.Errorf("building block %s reached FAILED state during deletion. For more details, check the building block run logs in meshStack", bbUuidOrUnknown(bb)) + } } return } diff --git a/building_block_v2_test.go b/building_block_v2_test.go index d93d6a91..d87f6cf0 100644 --- a/building_block_v2_test.go +++ b/building_block_v2_test.go @@ -43,6 +43,18 @@ func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { wantDone: false, wantErr: true, }, + { + name: "status FAILED but force-purged keeps polling (transient, will reach DELETED)", + bb: &MeshBuildingBlockV2{ + Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, + Status: &MeshBuildingBlockV2Status{ + Status: BuildingBlockStatusFailed, + ForcePurge: true, + }, + }, + wantDone: false, + wantErr: false, + }, { name: "status FAILED with nil Uuid does not panic", bb: &MeshBuildingBlockV2{ From 2eff7cf53bf93521c5dec7b771e2bc19b405f85a Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Thu, 16 Jul 2026 14:17:51 +0200 Subject: [PATCH 161/215] feat: add meshTenant UUID as BB input assignmentType --- building_block_definition_version.go | 1 + 1 file changed, 1 insertion(+) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 5e88a93a..bb1d0e44 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -59,6 +59,7 @@ var ( MeshBuildingBlockInputAssignmentTypePlatformOperatorManualInput = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_OPERATOR_MANUAL_INPUT") MeshBuildingBlockInputAssignmentTypeBuildingBlockOutput = MeshBuildingBlockInputAssignmentTypes.Entry("BUILDING_BLOCK_OUTPUT") MeshBuildingBlockInputAssignmentTypePlatformTenantID = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_TENANT_ID") + MeshBuildingBlockInputAssignmentTypeMeshTenantUuid = MeshBuildingBlockInputAssignmentTypes.Entry("MESH_TENANT_UUID") MeshBuildingBlockInputAssignmentTypeWorkspaceIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("WORKSPACE_IDENTIFIER") MeshBuildingBlockInputAssignmentTypeProjectIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("PROJECT_IDENTIFIER") MeshBuildingBlockInputAssignmentTypeFullPlatformIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("FULL_PLATFORM_IDENTIFIER") From 38137db95d9c0caa9bc84bba1e459391ec4a2a28 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Thu, 16 Jul 2026 15:38:14 +0200 Subject: [PATCH 162/215] refactor: rename MESH_TENANT_UUID to MESHSTACK_TENANT_ID --- building_block_definition_version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index bb1d0e44..f5e845ca 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -59,7 +59,7 @@ var ( MeshBuildingBlockInputAssignmentTypePlatformOperatorManualInput = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_OPERATOR_MANUAL_INPUT") MeshBuildingBlockInputAssignmentTypeBuildingBlockOutput = MeshBuildingBlockInputAssignmentTypes.Entry("BUILDING_BLOCK_OUTPUT") MeshBuildingBlockInputAssignmentTypePlatformTenantID = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_TENANT_ID") - MeshBuildingBlockInputAssignmentTypeMeshTenantUuid = MeshBuildingBlockInputAssignmentTypes.Entry("MESH_TENANT_UUID") + MeshBuildingBlockInputAssignmentTypeMeshstackTenantId = MeshBuildingBlockInputAssignmentTypes.Entry("MESHSTACK_TENANT_ID") MeshBuildingBlockInputAssignmentTypeWorkspaceIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("WORKSPACE_IDENTIFIER") MeshBuildingBlockInputAssignmentTypeProjectIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("PROJECT_IDENTIFIER") MeshBuildingBlockInputAssignmentTypeFullPlatformIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("FULL_PLATFORM_IDENTIFIER") From f7313c9a1071f4eb84fd77bb96acb13aadc0e856 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 17 Jul 2026 10:15:40 +0200 Subject: [PATCH 163/215] refactor: rename MESHSTACK_TENANT_ID assignment type to MESHSTACK_TENANT_UUID (#238) * refactor: rename MESHSTACK_TENANT_ID assignment type to MESHSTACK_TENANT_UUID The value assigned is the meshTenant's UUID, so name the assignment type accordingly. The old name was never in a tagged release (pending v0.23.3), so this only affects pre-release users; noted in CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(changelog): clarify MESHSTACK_TENANT_UUID rename note Co-Authored-By: Claude Opus 4.8 (1M context) * docs(changelog): drop MESHSTACK_TENANT_ID note; feature entry names the new type directly Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- building_block_definition_version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index f5e845ca..60d57512 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -59,7 +59,7 @@ var ( MeshBuildingBlockInputAssignmentTypePlatformOperatorManualInput = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_OPERATOR_MANUAL_INPUT") MeshBuildingBlockInputAssignmentTypeBuildingBlockOutput = MeshBuildingBlockInputAssignmentTypes.Entry("BUILDING_BLOCK_OUTPUT") MeshBuildingBlockInputAssignmentTypePlatformTenantID = MeshBuildingBlockInputAssignmentTypes.Entry("PLATFORM_TENANT_ID") - MeshBuildingBlockInputAssignmentTypeMeshstackTenantId = MeshBuildingBlockInputAssignmentTypes.Entry("MESHSTACK_TENANT_ID") + MeshBuildingBlockInputAssignmentTypeMeshstackTenantUuid = MeshBuildingBlockInputAssignmentTypes.Entry("MESHSTACK_TENANT_UUID") MeshBuildingBlockInputAssignmentTypeWorkspaceIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("WORKSPACE_IDENTIFIER") MeshBuildingBlockInputAssignmentTypeProjectIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("PROJECT_IDENTIFIER") MeshBuildingBlockInputAssignmentTypeFullPlatformIdentifier = MeshBuildingBlockInputAssignmentTypes.Entry("FULL_PLATFORM_IDENTIFIER") From 868021d45ebcc8f20ebc6ce50fb5d2a6b170f2b7 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Mon, 20 Jul 2026 08:15:21 +0200 Subject: [PATCH 164/215] feat: allow all dedicated (not NONE) assignment types for outputs on manual BB definition version inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual building blocks (implementation.manual) now accept any special output assignment_type — SIGN_IN_URL, RESOURCE_URL, and SUMMARY in addition to PLATFORM_TENANT_ID — to mark how a derived output is used. Previously only PLATFORM_TENANT_ID was allowed. The output key must match an input key; the backend still derives the output set from the inputs (see #131, #176). The non-NONE set is derived once from the full enum via a new enum.Enum.Except helper (nonNoneOutputAssignmentTypes), so adding an assignment type flows into the schema description, ValidateConfig, and the tests without editing each site. Co-Authored-By: Claude Opus 4.8 (1M context) --- types/enum/enum.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/types/enum/enum.go b/types/enum/enum.go index 0f07fef5..a8279e65 100644 --- a/types/enum/enum.go +++ b/types/enum/enum.go @@ -2,6 +2,7 @@ package enum import ( "fmt" + "slices" "strings" ) @@ -28,6 +29,14 @@ func (e Enum[T]) Strings() []string { return e.to(Entry[T].String) } +// Except returns the enum minus the given entries, preserving order. Deriving a subset this way keeps a +// single source of truth: adding an entry to the base enum flows into the subset automatically. +func (e Enum[T]) Except(excluded ...Entry[T]) Enum[T] { + return slices.DeleteFunc(slices.Clone(e), func(ee Entry[T]) bool { + return slices.Contains(excluded, ee) + }) +} + func (e Enum[T]) Markdown() string { return strings.Join(e.to(Entry[T].Markdown), ", ") } From fb1aa92c6c1ae8dfd714716d56b6adeda20e8d7a Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 16 Jul 2026 22:20:43 +0200 Subject: [PATCH 165/215] refactor: consolidate meshObject reference handling behind a single meshRef helper Collapse the near-duplicate reference-schema helpers into one builder so every meshObject reference is constructed the same way. - `meshRefByUuid` / `meshRefByName` constructors take a `meshRefOptions` struct with two behaviour flags (`Output`, `OptionalComputed`); the zero value is a required input. This replaces the earlier `meshRef(kind, refId, desc, ...opts)` signature and the `asOutput()` / `required()` / `optionalComputed()` / `requiredId()` functional options. - Input identifiers stay Optional+Computed with an `AlsoRequires` guard rather than Required: a ref used as a set element collapses to a wholly-unknown element at plan (sets hash by whole value), which a Required nested attribute rejects with "Missing Configuration for Required Attribute". The guard enforces presence while tolerating the unknown; identifiers may also be resolved after apply (computed `.ref`, random suffix). - `refOutputKind` plan modifier keeps an output ref's `kind` known at plan time (it is always the single constant value); only the identifier is computed. - Give `building_block_definition_version_ref` a computed `kind` across all schema sites and the definition's version outputs, so the last outlier ref carries a kind like every other meshObject reference. Docs regenerated; CHANGELOG updated under the pending v0.23.3. Co-Authored-By: Claude Opus 4.8 (1M context) --- building_block_v2.go | 1 + 1 file changed, 1 insertion(+) diff --git a/building_block_v2.go b/building_block_v2.go index 79e5e896..868ad5c7 100644 --- a/building_block_v2.go +++ b/building_block_v2.go @@ -101,6 +101,7 @@ func (m *MeshBuildingBlockInput) UnmarshalJSON(bytes []byte) error { type MeshBuildingBlockV2DefinitionVersionRef struct { Uuid string `json:"uuid" tfsdk:"uuid"` + Kind string `json:"kind" tfsdk:"kind"` // ContentHash is a Terraform-only field (json:"-", never sent to or returned by the backend). // It lets a config signal that the referenced version's content changed so a rerun is triggered // even though the version uuid is unchanged. The building_block (v3) resource honors it via the From 05780df40073b67fbf882c8b6b6b770326a854ff Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 17 Jul 2026 08:28:00 +0200 Subject: [PATCH 166/215] refactor: consolidate client ref DTOs into shared NamedRef/UuidRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the provider-side meshRef consolidation on the client side. Add client/refs.go with two DTO structs — NamedRef ({name, kind}) and UuidRef ({uuid, kind}) — as the counterparts of the meshRefByName / meshRefByUuid schema builders, and route every {name|uuid, kind} reference through them. - Remove the near-duplicate named types (MeshProjectRoleRefV2, PlatformTypeRef, LocationRef, BuildingBlockDefinitionSupportedPlatform → NamedRef; BuildingBlockRunnerRef, MeshBuildingBlockDefinitionRef, MeshLandingZonePlatformRef, MeshIntegrationRef, BuildingBlockDefinitionRef → UuidRef). - A ref that adds fields embeds the matching struct by value: MeshBuildingBlockV2DefinitionVersionRef now embeds UuidRef next to its content_hash; json and tfsdk reflection both promote the embedded fields. - MeshBuildingBlockV2TargetRef (mixes uuid and name) stays bespoke. Wire format and tfsdk schemas are unchanged — tags are identical and the version ref's content_hash stays tfsdk:"-" — so there is no user-facing change and no CHANGELOG entry. AGENTS.md's meshObject-references section now points at client/refs.go so the client alignment is documented alongside the schema rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- building_block_definition.go | 11 ++----- building_block_definition_version.go | 16 ++-------- ...block_definition_version_implementation.go | 32 +++++++++---------- building_block_runner.go | 5 --- building_block_v2.go | 3 +- buildingblock.go | 5 --- integration_config.go | 22 ++++++------- landingzone.go | 11 ++----- platform.go | 7 +--- platform_config_aws.go | 12 +++---- platform_config_azure.go | 4 +-- platform_config_custom.go | 7 +--- platform_config_gcp.go | 4 +-- platform_config_openshift.go | 4 +-- platform_properties_aks.go | 4 +-- platform_properties_aws.go | 6 ++-- platform_properties_azure.go | 2 +- platform_properties_azurerg.go | 6 ++-- platform_properties_gcp.go | 4 +-- project_binding.go | 7 +--- refs.go | 19 +++++++++++ 21 files changed, 81 insertions(+), 110 deletions(-) create mode 100644 refs.go diff --git a/building_block_definition.go b/building_block_definition.go index 5259cc14..46f062ac 100644 --- a/building_block_definition.go +++ b/building_block_definition.go @@ -22,11 +22,6 @@ type MeshBuildingBlockDefinitionMetadata struct { Tags map[string][]string `json:"tags" tfsdk:"tags"` } -type BuildingBlockDefinitionSupportedPlatform struct { - Kind string `json:"kind" tfsdk:"kind"` - Name string `json:"name" tfsdk:"name"` -} - type MeshBuildingBlockDefinitionSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` TargetType MeshBuildingBlockType `json:"targetType" tfsdk:"target_type"` @@ -37,9 +32,9 @@ type MeshBuildingBlockDefinitionSpec struct { SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` // NotificationSubscribers can also specify emails with prefix 'email:', so it's not only usernames (as the JSON field name suggests)! - NotificationSubscribers types.Set[string] `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` - Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` - SupportedPlatforms types.Set[BuildingBlockDefinitionSupportedPlatform] `json:"supportedPlatforms" tfsdk:"supported_platforms"` + NotificationSubscribers types.Set[string] `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` + Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` + SupportedPlatforms types.Set[NamedRef] `json:"supportedPlatforms" tfsdk:"supported_platforms"` } type MeshBuildingBlockDefinitionStatusVersion struct { diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 60d57512..7212ddef 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -79,18 +79,6 @@ var ( MeshBuildingBlockDefinitionOutputAssignmentTypeSummary = MeshBuildingBlockDefinitionOutputAssignmentTypes.Entry("SUMMARY") ) -// Ref types - -type BuildingBlockDefinitionRef struct { - Uuid string `json:"uuid"` - Kind string `json:"kind"` -} - -type MeshIntegrationRef struct { - Uuid string `json:"uuid" tfsdk:"uuid"` - Kind string `json:"kind" tfsdk:"kind"` -} - // Input and Output types type MeshBuildingBlockDefinitionInput struct { @@ -167,14 +155,14 @@ type MeshBuildingBlockDefinitionVersionMetadata struct { type BuildingBlockDependencyRef string type MeshBuildingBlockDefinitionVersionSpec struct { - BuildingBlockDefinitionRef *BuildingBlockDefinitionRef `json:"buildingBlockDefinitionRef" tfsdk:"-"` + BuildingBlockDefinitionRef *UuidRef `json:"buildingBlockDefinitionRef" tfsdk:"-"` OnlyApplyOncePerTenant bool `json:"onlyApplyOncePerTenant" tfsdk:"only_apply_once_per_tenant"` DeletionMode BuildingBlockDeletionMode `json:"deletionMode" tfsdk:"deletion_mode"` Permissions types.Set[ApiPermission] `json:"permissions,omitempty" tfsdk:"permissions"` Outputs map[string]MeshBuildingBlockDefinitionOutput `json:"outputs" tfsdk:"outputs"` VersionNumber *int64 `json:"versionNumber,omitempty" tfsdk:"version_number"` State *MeshBuildingBlockDefinitionVersionState `json:"state,omitempty" tfsdk:"state"` - RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` + RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` DependencyDefinitionUUIDs types.Set[BuildingBlockDependencyRef] `json:"dependencyDefinitionUuids,omitempty" tfsdk:"dependency_refs"` Implementation MeshBuildingBlockDefinitionImplementation `json:"implementation" tfsdk:"implementation"` Inputs map[string]*MeshBuildingBlockDefinitionInput `json:"inputs" tfsdk:"inputs"` diff --git a/building_block_definition_version_implementation.go b/building_block_definition_version_implementation.go index c16c3971..d8ae2dc9 100644 --- a/building_block_definition_version_implementation.go +++ b/building_block_definition_version_implementation.go @@ -39,31 +39,31 @@ type MeshBuildingBlockDefinitionTerraformImplementation struct { } type MeshBuildingBlockDefinitionGitHubWorkflowsImplementation struct { - Repository string `json:"repository" tfsdk:"repository"` - Branch string `json:"branch" tfsdk:"branch"` - ApplyWorkflow string `json:"applyWorkflow" tfsdk:"apply_workflow"` - DestroyWorkflow *string `json:"destroyWorkflow" tfsdk:"destroy_workflow"` - Async bool `json:"async" tfsdk:"async"` - OmitRunObjectInput bool `json:"omitRunObjectInput" tfsdk:"omit_run_object_input"` - IntegrationRef MeshIntegrationRef `json:"integrationRef" tfsdk:"integration_ref"` + Repository string `json:"repository" tfsdk:"repository"` + Branch string `json:"branch" tfsdk:"branch"` + ApplyWorkflow string `json:"applyWorkflow" tfsdk:"apply_workflow"` + DestroyWorkflow *string `json:"destroyWorkflow" tfsdk:"destroy_workflow"` + Async bool `json:"async" tfsdk:"async"` + OmitRunObjectInput bool `json:"omitRunObjectInput" tfsdk:"omit_run_object_input"` + IntegrationRef UuidRef `json:"integrationRef" tfsdk:"integration_ref"` } type MeshBuildingBlockDefinitionManualImplementation struct { } type MeshBuildingBlockDefinitionGitLabPipelineImplementation struct { - ProjectID string `json:"projectId" tfsdk:"project_id"` - RefName string `json:"refName" tfsdk:"ref_name"` - IntegrationRef MeshIntegrationRef `json:"integrationRef" tfsdk:"integration_ref"` - PipelineTriggerToken types.Secret `json:"pipelineTriggerToken" tfsdk:"pipeline_trigger_token"` + ProjectID string `json:"projectId" tfsdk:"project_id"` + RefName string `json:"refName" tfsdk:"ref_name"` + IntegrationRef UuidRef `json:"integrationRef" tfsdk:"integration_ref"` + PipelineTriggerToken types.Secret `json:"pipelineTriggerToken" tfsdk:"pipeline_trigger_token"` } type MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation struct { - Project string `json:"project" tfsdk:"project"` - PipelineID string `json:"pipelineId" tfsdk:"pipeline_id"` - RefName *string `json:"refName,omitempty" tfsdk:"ref_name"` - Async bool `json:"async" tfsdk:"async"` - IntegrationRef MeshIntegrationRef `json:"integrationRef" tfsdk:"integration_ref"` + Project string `json:"project" tfsdk:"project"` + PipelineID string `json:"pipelineId" tfsdk:"pipeline_id"` + RefName *string `json:"refName,omitempty" tfsdk:"ref_name"` + Async bool `json:"async" tfsdk:"async"` + IntegrationRef UuidRef `json:"integrationRef" tfsdk:"integration_ref"` } type MeshBuildingBlockDefinitionImplementation struct { diff --git a/building_block_runner.go b/building_block_runner.go index ba63140f..8376af36 100644 --- a/building_block_runner.go +++ b/building_block_runner.go @@ -27,11 +27,6 @@ var MeshBuildingBlockRunnerImplementationTypes = []string{ string(MeshBuildingBlockRunnerImplementationTypeAll), } -type BuildingBlockRunnerRef struct { - Uuid string `json:"uuid" tfsdk:"uuid"` - Kind string `json:"kind" tfsdk:"kind"` -} - type MeshBuildingBlockRunner struct { Metadata MeshBuildingBlockRunnerMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshBuildingBlockRunnerSpec `json:"spec" tfsdk:"spec"` diff --git a/building_block_v2.go b/building_block_v2.go index 868ad5c7..0f4c6128 100644 --- a/building_block_v2.go +++ b/building_block_v2.go @@ -100,8 +100,7 @@ func (m *MeshBuildingBlockInput) UnmarshalJSON(bytes []byte) error { } type MeshBuildingBlockV2DefinitionVersionRef struct { - Uuid string `json:"uuid" tfsdk:"uuid"` - Kind string `json:"kind" tfsdk:"kind"` + UuidRef // ContentHash is a Terraform-only field (json:"-", never sent to or returned by the backend). // It lets a config signal that the referenced version's content changed so a rerun is triggered // even though the version uuid is unchanged. The building_block (v3) resource honors it via the diff --git a/buildingblock.go b/buildingblock.go index fa93b54c..8ec9e5d6 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -67,11 +67,6 @@ type MeshBuildingBlockCreateMetadata struct { TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` } -type MeshBuildingBlockDefinitionRef struct { - Kind string `json:"kind" tfsdk:"kind"` - Uuid string `json:"uuid" tfsdk:"uuid"` -} - type MeshBuildingBlockClient interface { Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) Create(ctx context.Context, bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) diff --git a/integration_config.go b/integration_config.go index e88677fe..5e23cda5 100644 --- a/integration_config.go +++ b/integration_config.go @@ -20,23 +20,23 @@ var ( ) type MeshIntegrationGithubConfig struct { - Owner string `json:"owner" tfsdk:"owner"` - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - AppId string `json:"appId" tfsdk:"app_id"` - AppPrivateKey types.Secret `json:"appPrivateKey" tfsdk:"app_private_key"` - RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` + Owner string `json:"owner" tfsdk:"owner"` + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + AppId string `json:"appId" tfsdk:"app_id"` + AppPrivateKey types.Secret `json:"appPrivateKey" tfsdk:"app_private_key"` + RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` } type MeshIntegrationGitlabConfig struct { - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` } type MeshIntegrationAzureDevopsConfig struct { - BaseUrl string `json:"baseUrl" tfsdk:"base_url"` - Organization string `json:"organization" tfsdk:"organization"` - PersonalAccessToken types.Secret `json:"personalAccessToken" tfsdk:"personal_access_token"` - RunnerRef *BuildingBlockRunnerRef `json:"runnerRef" tfsdk:"runner_ref"` + BaseUrl string `json:"baseUrl" tfsdk:"base_url"` + Organization string `json:"organization" tfsdk:"organization"` + PersonalAccessToken types.Secret `json:"personalAccessToken" tfsdk:"personal_access_token"` + RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` } type MeshIntegrationEntraIdConfig struct { diff --git a/landingzone.go b/landingzone.go index bf7ac676..42474aa0 100644 --- a/landingzone.go +++ b/landingzone.go @@ -24,11 +24,11 @@ type MeshLandingZoneSpec struct { AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` - PlatformRef MeshLandingZonePlatformRef `json:"platformRef" tfsdk:"platform_ref"` + PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` PlatformProperties *MeshLandingZonePlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` Quotas []MeshLandingZoneQuota `json:"quotas" tfsdk:"quotas"` - MandatoryBuildingBlockRefs []MeshBuildingBlockDefinitionRef `json:"mandatoryBuildingBlockRefs" tfsdk:"mandatory_building_block_refs"` - RecommendedBuildingBlockRefs []MeshBuildingBlockDefinitionRef `json:"recommendedBuildingBlockRefs" tfsdk:"recommended_building_block_refs"` + MandatoryBuildingBlockRefs []UuidRef `json:"mandatoryBuildingBlockRefs" tfsdk:"mandatory_building_block_refs"` + RecommendedBuildingBlockRefs []UuidRef `json:"recommendedBuildingBlockRefs" tfsdk:"recommended_building_block_refs"` } type MeshLandingZoneStatus struct { @@ -36,11 +36,6 @@ type MeshLandingZoneStatus struct { Restricted bool `json:"restricted" tfsdk:"restricted"` } -type MeshLandingZonePlatformRef struct { - Uuid string `json:"uuid" tfsdk:"uuid"` - Kind string `json:"kind" tfsdk:"kind"` -} - type MeshLandingZonePlatformProperties struct { Type string `json:"type" tfsdk:"type"` Aws *AwsPlatformProperties `json:"aws" tfsdk:"aws"` diff --git a/platform.go b/platform.go index 5de34b39..d49d4d3e 100644 --- a/platform.go +++ b/platform.go @@ -25,7 +25,7 @@ type MeshPlatformSpec struct { SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` AccessInformation *string `json:"accessInformation,omitempty" tfsdk:"access_information"` - LocationRef LocationRef `json:"locationRef" tfsdk:"location_ref"` + LocationRef NamedRef `json:"locationRef" tfsdk:"location_ref"` ContributingWorkspaces types.Set[string] `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` Availability PlatformAvailability `json:"availability" tfsdk:"availability"` Config PlatformConfig `json:"config" tfsdk:"config"` @@ -42,11 +42,6 @@ type QuotaDefinition struct { Label string `json:"label" tfsdk:"label"` } -type LocationRef struct { - Kind string `json:"kind" tfsdk:"kind"` - Name string `json:"name" tfsdk:"name"` -} - type PlatformAvailability struct { Restriction string `json:"restriction" tfsdk:"restriction"` PublicationState string `json:"publicationState" tfsdk:"publication_state"` diff --git a/platform_config_aws.go b/platform_config_aws.go index b7a60db9..a45a1d24 100644 --- a/platform_config_aws.go +++ b/platform_config_aws.go @@ -57,9 +57,9 @@ type AwsSsoConfig struct { } type AwsSsoRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - AwsRole string `json:"awsRole" tfsdk:"aws_role"` - PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AwsRole string `json:"awsRole" tfsdk:"aws_role"` + PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` } type AwsEnrollmentConfiguration struct { @@ -76,9 +76,9 @@ type AwsIdentityStoreConfig struct { } type AwsIdentityStoreRoleMapping struct { - ProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - AwsRole string `json:"awsRole" tfsdk:"aws_role"` - PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` + ProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AwsRole string `json:"awsRole" tfsdk:"aws_role"` + PermissionSetArns []string `json:"permissionSetArns" tfsdk:"permission_set_arns"` } type AwsMeteringConfig struct { diff --git a/platform_config_azure.go b/platform_config_azure.go index c753b160..b5d68aa7 100644 --- a/platform_config_azure.go +++ b/platform_config_azure.go @@ -71,8 +71,8 @@ type AzureInviteB2BUserConfig struct { } type AzureRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - AzureRole AzureRole `json:"azureRole" tfsdk:"azure_role"` + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureRole AzureRole `json:"azureRole" tfsdk:"azure_role"` } type AzureRole struct { diff --git a/platform_config_custom.go b/platform_config_custom.go index 293cc39c..03a632d3 100644 --- a/platform_config_custom.go +++ b/platform_config_custom.go @@ -1,15 +1,10 @@ package client type CustomPlatformConfig struct { - PlatformTypeRef PlatformTypeRef `json:"platformTypeRef" tfsdk:"platform_type_ref"` + PlatformTypeRef NamedRef `json:"platformTypeRef" tfsdk:"platform_type_ref"` Metering *CustomMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` } -type PlatformTypeRef struct { - Name string `json:"name" tfsdk:"name"` - Kind string `json:"kind" tfsdk:"kind"` -} - type CustomMeteringConfig struct { Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` } diff --git a/platform_config_gcp.go b/platform_config_gcp.go index 8febcb6e..2a65f609 100644 --- a/platform_config_gcp.go +++ b/platform_config_gcp.go @@ -35,8 +35,8 @@ type GcpServiceAccountWorkloadIdentityConfig struct { } type GcpPlatformRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - GcpRole string `json:"gcpRole" tfsdk:"gcp_role"` + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + GcpRole string `json:"gcpRole" tfsdk:"gcp_role"` } type GcpMeteringConfig struct { diff --git a/platform_config_openshift.go b/platform_config_openshift.go index 49587d75..123c6d85 100644 --- a/platform_config_openshift.go +++ b/platform_config_openshift.go @@ -24,6 +24,6 @@ type OpenShiftMeteringConfig struct { } type OpenShiftPlatformRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - OpenshiftRole string `json:"openshiftRole" tfsdk:"openshift_role"` + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + OpenshiftRole string `json:"openshiftRole" tfsdk:"openshift_role"` } diff --git a/platform_properties_aks.go b/platform_properties_aks.go index 4599a5fe..04870c39 100644 --- a/platform_properties_aks.go +++ b/platform_properties_aks.go @@ -5,6 +5,6 @@ type AksPlatformProperties struct { } type KubernetesRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - PlatformRoles []string `json:"platformRoles" tfsdk:"platform_roles"` + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + PlatformRoles []string `json:"platformRoles" tfsdk:"platform_roles"` } diff --git a/platform_properties_aws.go b/platform_properties_aws.go index bbd8480d..41a747a4 100644 --- a/platform_properties_aws.go +++ b/platform_properties_aws.go @@ -8,7 +8,7 @@ type AwsPlatformProperties struct { } type AwsRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - PlatformRole string `json:"platformRole" tfsdk:"platform_role"` - Policies []string `json:"policies" tfsdk:"policies"` + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + PlatformRole string `json:"platformRole" tfsdk:"platform_role"` + Policies []string `json:"policies" tfsdk:"policies"` } diff --git a/platform_properties_azure.go b/platform_properties_azure.go index 6979403e..23098578 100644 --- a/platform_properties_azure.go +++ b/platform_properties_azure.go @@ -6,7 +6,7 @@ type AzurePlatformProperties struct { } type AzureRoleMappingProperty struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` AzureGroupSuffix string `json:"azureGroupSuffix" tfsdk:"azure_group_suffix"` AzureRoleDefinitions []AzureRoleDefinition `json:"azureRoleDefinitions" tfsdk:"azure_role_definitions"` } diff --git a/platform_properties_azurerg.go b/platform_properties_azurerg.go index dc97e8fe..4bc2b700 100644 --- a/platform_properties_azurerg.go +++ b/platform_properties_azurerg.go @@ -7,9 +7,9 @@ type AzureRgPlatformProperties struct { } type AzureRgRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - AzureGroupSuffix string `json:"azureGroupSuffix" tfsdk:"azure_group_suffix"` - AzureRoleDefinitionIds []string `json:"azureRoleDefinitionIds" tfsdk:"azure_role_definition_ids"` + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + AzureGroupSuffix string `json:"azureGroupSuffix" tfsdk:"azure_group_suffix"` + AzureRoleDefinitionIds []string `json:"azureRoleDefinitionIds" tfsdk:"azure_role_definition_ids"` } type AzureFunction struct { diff --git a/platform_properties_gcp.go b/platform_properties_gcp.go index c8bb02b0..f10ae346 100644 --- a/platform_properties_gcp.go +++ b/platform_properties_gcp.go @@ -7,6 +7,6 @@ type GcpPlatformProperties struct { } type GcpRoleMapping struct { - MeshProjectRoleRef MeshProjectRoleRefV2 `json:"projectRoleRef" tfsdk:"project_role_ref"` - PlatformRoles []string `json:"platformRoles" tfsdk:"platform_roles"` + MeshProjectRoleRef NamedRef `json:"projectRoleRef" tfsdk:"project_role_ref"` + PlatformRoles []string `json:"platformRoles" tfsdk:"platform_roles"` } diff --git a/project_binding.go b/project_binding.go index 4178b68a..df1529d4 100644 --- a/project_binding.go +++ b/project_binding.go @@ -11,17 +11,12 @@ type MeshProjectBindingMetadata struct { Name string `json:"name" tfsdk:"name"` } -// Deprecated: Use MeshProjectRoleRefV2 if possible. The convention is to also provide the `kind`, +// Deprecated: Use NamedRef if possible. The convention is to also provide the `kind`, // so this struct should only be used for meshobjects that violate our API conventions. type MeshProjectRoleRef struct { Name string `json:"name" tfsdk:"name"` } -type MeshProjectRoleRefV2 struct { - Name string `json:"name" tfsdk:"name"` - Kind string `json:"kind" tfsdk:"kind"` -} - type MeshProjectTargetRef struct { Name string `json:"name" tfsdk:"name"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` diff --git a/refs.go b/refs.go new file mode 100644 index 00000000..0f68eb2a --- /dev/null +++ b/refs.go @@ -0,0 +1,19 @@ +package client + +// NamedRef is the client-side DTO for a meshObject reference that identifies its +// target by name. It is the counterpart to the meshRefByName schema builder in +// internal/provider (schema_utils.go): every {name, kind} reference block on the +// wire deserializes into this struct. Refs that carry extra fields embed it. +type NamedRef struct { + Name string `json:"name" tfsdk:"name"` + Kind string `json:"kind" tfsdk:"kind"` +} + +// UuidRef is the client-side DTO for a meshObject reference that identifies its +// target by uuid. It is the counterpart to the meshRefByUuid schema builder in +// internal/provider (schema_utils.go): every {uuid, kind} reference block on the +// wire deserializes into this struct. Refs that carry extra fields embed it. +type UuidRef struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + Kind string `json:"kind" tfsdk:"kind"` +} From 71d0aa0eeb14083088fbe720d710dfc525e0a39c Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 17 Jul 2026 13:51:25 +0200 Subject: [PATCH 167/215] refactor: model platform aws/gcp role mappings as sets The backend stores AWS (aws_sso, aws_identity_store) and GCP platform role mappings in a map keyed by the referenced meshProjectRole, so their order is neither meaningful nor preserved across a round-trip. Modeling them as ordered ListNestedAttributes therefore produced a permanent no-op plan diff whenever the backend returned them in a different order. Model them as SetNestedAttributes (matching azure/openshift role mappings), retyping the client DTO fields to the generic client/types.Set[T] so the DTO->model converter emits set values, and mark the nested project_role_ref InSet so its identifier stays lenient inside the set. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform_config_aws.go | 22 +++++++++++----------- platform_config_gcp.go | 26 +++++++++++++------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/platform_config_aws.go b/platform_config_aws.go index a45a1d24..55fa2053 100644 --- a/platform_config_aws.go +++ b/platform_config_aws.go @@ -48,12 +48,12 @@ type AwsWorkloadIdentityCredential struct { } type AwsSsoConfig struct { - ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` - Arn string `json:"arn" tfsdk:"arn"` - GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` - SsoAccessToken types.Secret `json:"ssoAccessToken" tfsdk:"sso_access_token"` - AwsRoleMappings []AwsSsoRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` - SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` + ScimEndpoint string `json:"scimEndpoint" tfsdk:"scim_endpoint"` + Arn string `json:"arn" tfsdk:"arn"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + SsoAccessToken types.Secret `json:"ssoAccessToken" tfsdk:"sso_access_token"` + AwsRoleMappings types.Set[AwsSsoRoleMapping] `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` + SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` } type AwsSsoRoleMapping struct { @@ -68,11 +68,11 @@ type AwsEnrollmentConfiguration struct { } type AwsIdentityStoreConfig struct { - IdentityStoreId string `json:"identityStoreId" tfsdk:"identity_store_id"` - Arn string `json:"arn" tfsdk:"arn"` - GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` - AwsRoleMappings []AwsIdentityStoreRoleMapping `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` - SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` + IdentityStoreId string `json:"identityStoreId" tfsdk:"identity_store_id"` + Arn string `json:"arn" tfsdk:"arn"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + AwsRoleMappings types.Set[AwsIdentityStoreRoleMapping] `json:"awsRoleMappings" tfsdk:"aws_role_mappings"` + SignInUrl string `json:"signInUrl" tfsdk:"sign_in_url"` } type AwsIdentityStoreRoleMapping struct { diff --git a/platform_config_gcp.go b/platform_config_gcp.go index 2a65f609..3c27767f 100644 --- a/platform_config_gcp.go +++ b/platform_config_gcp.go @@ -8,19 +8,19 @@ type GcpPlatformConfig struct { } type GcpReplicationConfig struct { - ServiceAccount GcpServiceAccountConfig `json:"serviceAccount" tfsdk:"service_account"` - Domain string `json:"domain" tfsdk:"domain"` - CustomerId string `json:"customerId" tfsdk:"customer_id"` - GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` - ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` - ProjectIdPattern string `json:"projectIdPattern" tfsdk:"project_id_pattern"` - BillingAccountId string `json:"billingAccountId" tfsdk:"billing_account_id"` - UserLookupStrategy string `json:"userLookupStrategy" tfsdk:"user_lookup_strategy"` - UsedExternalIdType *string `json:"usedExternalIdType,omitempty" tfsdk:"used_external_id_type"` - GcpRoleMappings []GcpPlatformRoleMapping `json:"gcpRoleMappings" tfsdk:"gcp_role_mappings"` - AllowHierarchicalFolderAssignment bool `json:"allowHierarchicalFolderAssignment" tfsdk:"allow_hierarchical_folder_assignment"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` + ServiceAccount GcpServiceAccountConfig `json:"serviceAccount" tfsdk:"service_account"` + Domain string `json:"domain" tfsdk:"domain"` + CustomerId string `json:"customerId" tfsdk:"customer_id"` + GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` + ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` + ProjectIdPattern string `json:"projectIdPattern" tfsdk:"project_id_pattern"` + BillingAccountId string `json:"billingAccountId" tfsdk:"billing_account_id"` + UserLookupStrategy string `json:"userLookupStrategy" tfsdk:"user_lookup_strategy"` + UsedExternalIdType *string `json:"usedExternalIdType,omitempty" tfsdk:"used_external_id_type"` + GcpRoleMappings types.Set[GcpPlatformRoleMapping] `json:"gcpRoleMappings" tfsdk:"gcp_role_mappings"` + AllowHierarchicalFolderAssignment bool `json:"allowHierarchicalFolderAssignment" tfsdk:"allow_hierarchical_folder_assignment"` + TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` } type GcpServiceAccountConfig struct { From 84640316e0360015ce8df294b854cd38738cbecf Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 20 Jul 2026 13:22:02 +0200 Subject: [PATCH 168/215] feat: back meshstack_tenant and meshstack_tenants with the meshTenant v4 API Move the unsuffixed meshstack_tenant resource and meshstack_tenant/meshstack_tenants data sources onto the meshTenant v4 body, referencing platform and landing zone by ref (spec.platform_ref by uuid, spec.landing_zone_ref by name) via the shared meshRef helper, with a computed ref, richer status, and a wait_for_completion toggle. State conversion goes through the generic converter layer (internal/types/generic) reusing the client.MeshTenant DTO directly rather than a hand-rolled model; spec.quotas is a client/types.Set so it renders as the SetNestedAttribute set. Existing v3 state is upgraded in place by an UpgradeState that re-reads the tenant from the v4 API; a moved block from the deprecated meshstack_tenant_v4 is supported (the mover carries the uuid, the post-move refresh re-reads the ref body), so neither path recreates the tenant. Import accepts a tenant UUID or the legacy workspace.project.platform.location composite. The ref-based client.MeshTenant is co-located with the identifier-based client.MeshTenantV4 (that still backs the deprecated meshstack_tenant_v4) in client/tenant_v4.go. --- tenant.go | 78 ------------------------------- tenant_v4.go | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 78 deletions(-) delete mode 100644 tenant.go diff --git a/tenant.go b/tenant.go deleted file mode 100644 index 3aa49374..00000000 --- a/tenant.go +++ /dev/null @@ -1,78 +0,0 @@ -package client - -import ( - "context" - - "github.com/meshcloud/terraform-provider-meshstack/client/internal" -) - -type MeshTenant struct { - Metadata MeshTenantMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshTenantSpec `json:"spec" tfsdk:"spec"` -} - -type MeshTenantMetadata struct { - OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` - AssignedTags map[string][]string `json:"assignedTags" tfsdk:"assigned_tags"` - DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` -} - -type MeshTenantSpec struct { - LocalId *string `json:"localId" tfsdk:"local_id"` - LandingZoneIdentifier string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` - Quotas []MeshTenantQuota `json:"quotas" tfsdk:"quotas"` -} - -type MeshTenantQuota struct { - Key string `json:"key" tfsdk:"key"` - Value int64 `json:"value" tfsdk:"value"` -} - -type MeshTenantCreate struct { - Metadata MeshTenantCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshTenantCreateSpec `json:"spec" tfsdk:"spec"` -} - -type MeshTenantCreateMetadata struct { - OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` -} - -type MeshTenantCreateSpec struct { - LocalId *string `json:"localId" tfsdk:"local_id"` - LandingZoneIdentifier *string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` - Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` -} - -type MeshTenantClient interface { - Read(ctx context.Context, workspace string, project string, platform string) (*MeshTenant, error) - Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) - Delete(ctx context.Context, workspace string, project string, platform string) error -} - -type meshTenantClient struct { - meshObject internal.MeshObjectClient[MeshTenant] -} - -func newTenantClient(ctx context.Context, httpClient internal.HttpClient) MeshTenantClient { - return meshTenantClient{internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v3")} -} - -func (c meshTenantClient) tenantId(workspace string, project string, platform string) string { - return workspace + "." + project + "." + platform -} - -func (c meshTenantClient) Read(ctx context.Context, workspace string, project string, platform string) (*MeshTenant, error) { - return c.meshObject.Get(ctx, c.tenantId(workspace, project, platform)) -} - -func (c meshTenantClient) Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) { - return c.meshObject.Post(ctx, tenant) -} - -func (c meshTenantClient) Delete(ctx context.Context, workspace string, project string, platform string) error { - return c.meshObject.Delete(ctx, c.tenantId(workspace, project, platform)) -} diff --git a/tenant_v4.go b/tenant_v4.go index 1968a100..697d43f4 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/terraform-provider-meshstack/client/types" ) type MeshTenantV4 struct { @@ -132,3 +133,132 @@ func (tenant *MeshTenantV4) CreationSuccessful() (done bool, err error) { func (tenant *MeshTenantV4) DeletionSuccessful() (done bool, err error) { return tenant == nil, nil } + +type MeshTenant struct { + Metadata MeshTenantMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantSpec `json:"spec" tfsdk:"spec"` + Status MeshTenantStatus `json:"status" tfsdk:"status"` +} + +type MeshTenantMetadata struct { + Uuid string `json:"uuid" tfsdk:"uuid"` + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshTenantSpec struct { + PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` + PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` + LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` + Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` +} + +// MeshTenantStatus has no quotas field; quotas are part of the tenant spec, not its status. +type MeshTenantStatus struct { + TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` + PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` + PlatformWorkspaceId *string `json:"platformWorkspaceId" tfsdk:"platform_workspace_id"` + Tags map[string][]string `json:"tags" tfsdk:"tags"` +} + +type MeshTenantQuota struct { + Key string `json:"key" tfsdk:"key"` + Value int64 `json:"value" tfsdk:"value"` +} + +type MeshTenantCreate struct { + Metadata MeshTenantCreateMetadata `json:"metadata" tfsdk:"metadata"` + Spec MeshTenantCreateSpec `json:"spec" tfsdk:"spec"` +} + +type MeshTenantCreateMetadata struct { + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +type MeshTenantCreateSpec struct { + PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` + LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` + PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` + Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` +} + +type MeshTenantQuery struct { + Workspace string + Project *string + Platform *string + PlatformType *string + LandingZone *string + PlatformTenant *string +} + +type MeshTenantClient interface { + Read(ctx context.Context, uuid string) (*MeshTenant, error) + ReadFunc(uuid string) func(ctx context.Context) (*MeshTenant, error) + List(ctx context.Context, query *MeshTenantQuery) ([]MeshTenant, error) + Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) + Delete(ctx context.Context, uuid string) error +} + +type meshTenantClient struct { + meshObject internal.MeshObjectClient[MeshTenant] +} + +func newTenantClient(ctx context.Context, httpClient internal.HttpClient) MeshTenantClient { + return meshTenantClient{internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v4-preview")} +} + +func (c meshTenantClient) Read(ctx context.Context, uuid string) (*MeshTenant, error) { + return c.ReadFunc(uuid)(ctx) +} + +func (c meshTenantClient) ReadFunc(uuid string) func(ctx context.Context) (*MeshTenant, error) { + return func(ctx context.Context) (*MeshTenant, error) { + return c.meshObject.Get(ctx, uuid) + } +} + +func (c meshTenantClient) Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) { + return c.meshObject.Post(ctx, tenant) +} + +func (c meshTenantClient) List(ctx context.Context, query *MeshTenantQuery) ([]MeshTenant, error) { + options := []internal.RequestOption{ + internal.WithUrlQuery("workspaceIdentifier", query.Workspace), + } + if query.Project != nil { + options = append(options, internal.WithUrlQuery("projectIdentifier", *query.Project)) + } + if query.Platform != nil { + options = append(options, internal.WithUrlQuery("platformIdentifier", *query.Platform)) + } + if query.PlatformType != nil { + options = append(options, internal.WithUrlQuery("platformTypeIdentifier", *query.PlatformType)) + } + if query.LandingZone != nil { + options = append(options, internal.WithUrlQuery("landingZoneIdentifier", *query.LandingZone)) + } + if query.PlatformTenant != nil { + options = append(options, internal.WithUrlQuery("platformTenantId", *query.PlatformTenant)) + } + return c.meshObject.List(ctx, options...) +} + +func (c meshTenantClient) Delete(ctx context.Context, uuid string) error { + return c.meshObject.Delete(ctx, uuid) +} + +func (tenant *MeshTenant) CreationSuccessful() (done bool, err error) { + switch { + case tenant == nil: + err = fmt.Errorf("tenant not found after creation") + case tenant.Spec.PlatformTenantId != nil && *tenant.Spec.PlatformTenantId != "": + // Creation is complete (platformTenantId is set and not empty) + done = true + } + return +} + +func (tenant *MeshTenant) DeletionSuccessful() (done bool, err error) { + return tenant == nil, nil +} From 3cd734605e0034b665f66009dd33c5e06412db6a Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 22 Jul 2026 07:46:17 +0200 Subject: [PATCH 169/215] fix: source meshstack_tenant status from the v4 tenantName field The meshTenant v4 API introduced status.tenantName (superseding tenantIdentifier, which is retained as a deprecated copy while v4 is in preview and dropped at the GA cutover). Point the canonical meshstack_tenant / meshstack_tenants status mapping at tenantName so the provider keeps working once the backend removes tenantIdentifier. The public tenant_identifier attribute is unchanged: the value (the qualified tenant identifier) is identical, so this is behaviour-preserving on the preview API. Co-Authored-By: Claude Opus 4.8 (1M context) --- tenant_v4.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tenant_v4.go b/tenant_v4.go index 697d43f4..50fd7452 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -155,7 +155,11 @@ type MeshTenantSpec struct { // MeshTenantStatus has no quotas field; quotas are part of the tenant spec, not its status. type MeshTenantStatus struct { - TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` + // Sourced from the backend's status.tenantName (the qualified tenant identifier). The v4 GA + // API renamed tenantIdentifier -> tenantName and drops the deprecated tenantIdentifier; the + // public tenant_identifier attribute keeps its name since the value (qualifiedTenantIdentifier) + // is unchanged. + TenantIdentifier string `json:"tenantName" tfsdk:"tenant_identifier"` PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` PlatformWorkspaceId *string `json:"platformWorkspaceId" tfsdk:"platform_workspace_id"` Tags map[string][]string `json:"tags" tfsdk:"tags"` From c045ae8cab8ba55dce203655806cf07a133db445 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Wed, 22 Jul 2026 10:06:09 +0200 Subject: [PATCH 170/215] fix: track only user-declared tags on taggable resources meshStack returns a tag superset on create: an entry for every defined tag property (empty list when unset), plus injected restricted-tag defaults on meshProject / meshLandingZone / meshBuildingBlockDefinition. Writing that superset into the Optional+Computed `tags` attribute broke plan/apply consistency (meshstack_landingzone crashed with "Provider produced inconsistent result after apply") and produced perpetual drift (meshstack_project) for tags the caller may not be permitted to manage. Create/Update now persist the plan's tags; Read reconciles the API response down to the keys already tracked in state (reconcileTrackedTags), so server-injected entries never enter `tags` or surface as drift. Import keeps the full set (there is no prior state to reconcile against). Covers meshstack_landingzone / _project / _workspace / _payment_method / _building_block_definition with real-backend acceptance subtests (restricted-default and superset cases) and a pure reconcileTags unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- tenant_v4.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tenant_v4.go b/tenant_v4.go index 50fd7452..697d43f4 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -155,11 +155,7 @@ type MeshTenantSpec struct { // MeshTenantStatus has no quotas field; quotas are part of the tenant spec, not its status. type MeshTenantStatus struct { - // Sourced from the backend's status.tenantName (the qualified tenant identifier). The v4 GA - // API renamed tenantIdentifier -> tenantName and drops the deprecated tenantIdentifier; the - // public tenant_identifier attribute keeps its name since the value (qualifiedTenantIdentifier) - // is unchanged. - TenantIdentifier string `json:"tenantName" tfsdk:"tenant_identifier"` + TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` PlatformWorkspaceId *string `json:"platformWorkspaceId" tfsdk:"platform_workspace_id"` Tags map[string][]string `json:"tags" tfsdk:"tags"` From 74b5fa933760c7ca2eedad17cf7da4b038c3d0fd Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 21 Jul 2026 20:07:57 +0200 Subject: [PATCH 171/215] feat: send building block definition dependencies as dependencyDefinitionRefs Switch the version-spec dependency wire field from the deprecated dependencyDefinitionUuids (bare UUID array) to dependencyDefinitionRefs ([{uuid, kind}]) so a dependency's kind round-trips. Stays on the current v1-preview media type; the resource schema is unchanged (no config or state migration). The client field is now types.Set[client.UuidRef], so no custom converter is needed. This is the coordinated provider adaptation (per the terraform-provider-compat handshake) for the backend dropping the deprecated dependencyDefinitionUuids: released providers read/write it and would silently stop round-tripping dependencies once the backend removes it, so this must be released and adopted before that removal deploys. Requires meshStack 2026.29.0 (which serves dependencyDefinitionRefs on the preview BBD-version API); MinMeshStackVersion bumped accordingly. Content-hash bumped to v4 (v3 taken by display_order on main); v3 hashes read as incomparable and are gracefully recomputed. Co-Authored-By: Claude Opus 4.8 (1M context) --- building_block_definition_version.go | 8 ++++---- client.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 7212ddef..732f2c63 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -153,7 +153,6 @@ type MeshBuildingBlockDefinitionVersionMetadata struct { CreatedOn string `json:"createdOn"` } -type BuildingBlockDependencyRef string type MeshBuildingBlockDefinitionVersionSpec struct { BuildingBlockDefinitionRef *UuidRef `json:"buildingBlockDefinitionRef" tfsdk:"-"` OnlyApplyOncePerTenant bool `json:"onlyApplyOncePerTenant" tfsdk:"only_apply_once_per_tenant"` @@ -163,9 +162,10 @@ type MeshBuildingBlockDefinitionVersionSpec struct { VersionNumber *int64 `json:"versionNumber,omitempty" tfsdk:"version_number"` State *MeshBuildingBlockDefinitionVersionState `json:"state,omitempty" tfsdk:"state"` RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` - DependencyDefinitionUUIDs types.Set[BuildingBlockDependencyRef] `json:"dependencyDefinitionUuids,omitempty" tfsdk:"dependency_refs"` - Implementation MeshBuildingBlockDefinitionImplementation `json:"implementation" tfsdk:"implementation"` - Inputs map[string]*MeshBuildingBlockDefinitionInput `json:"inputs" tfsdk:"inputs"` + // Replaces the deprecated bare-UUID dependencyDefinitionUuids; requires a backend serving it. + DependencyDefinitionRefs types.Set[UuidRef] `json:"dependencyDefinitionRefs,omitempty" tfsdk:"dependency_refs"` + Implementation MeshBuildingBlockDefinitionImplementation `json:"implementation" tfsdk:"implementation"` + Inputs map[string]*MeshBuildingBlockDefinitionInput `json:"inputs" tfsdk:"inputs"` } type MeshBuildingBlockDefinitionVersionStatus struct { diff --git a/client.go b/client.go index b366b318..93d33872 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.24.0") +var MinMeshStackVersion = version.MustParse("2026.29.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. From f95cf671f471cadd25ee7d8507065ed239c7597b Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 22 Jul 2026 13:33:56 +0200 Subject: [PATCH 172/215] refactor: derive List query params from struct json tags in WithUrlQuery WithUrlQuery now takes a single value instead of a (key, value) pair: a filter struct (passed by value) whose json tags name the query params, or a map[string]string / map[string]any taken verbatim. The value is JSON-marshalled and flattened; for a struct, zero-value fields are dropped as an implicit omitempty, so an unset filter needs neither a pointer nor an omitempty tag and a zero-value struct adds no params. Maps keep every entry (e.g. page=0) and round-trip unchanged. Each List client that filters by a query/filter struct now hands that struct to WithUrlQuery by value; the query structs carry json tags naming their params. An error while flattening a query is surfaced on the request instead of building a partial one. Co-Authored-By: Claude Opus 4.8 (1M context) --- building_block_definition.go | 18 ++++--- building_block_definition_version.go | 8 +++- building_block_v2.go | 58 +++++++--------------- internal/http_client.go | 3 ++ internal/http_client_test.go | 40 ++++++++++++++-- internal/mesh_object_client.go | 2 +- internal/options.go | 50 ++++++++++++++----- platform_type.go | 17 +++---- project.go | 16 ++++--- service_instance.go | 34 ++++--------- tenant_v4.go | 72 +++++++--------------------- 11 files changed, 159 insertions(+), 159 deletions(-) diff --git a/building_block_definition.go b/building_block_definition.go index 46f062ac..9ef2e7f0 100644 --- a/building_block_definition.go +++ b/building_block_definition.go @@ -76,13 +76,19 @@ func newBuildingBlockDefinitionClient(ctx context.Context, httpClient internal.H } } +type meshBuildingBlockDefinitionListQuery struct { + // IncludeAllPublished is always true here: list definitions published across the platform in + // addition to the workspace's own. (A false bool would be dropped by WithUrlQuery, which is fine — + // this endpoint is only ever called with it set.) + IncludeAllPublished bool `json:"includeAllPublished"` + OwnedByWorkspace *string `json:"ownedByWorkspace"` +} + func (c meshBuildingBlockDefinitionClient) List(ctx context.Context, workspaceIdentifier *string) ([]MeshBuildingBlockDefinition, error) { - var options []internal.RequestOption - options = append(options, internal.WithUrlQuery("includeAllPublished", "true")) - if workspaceIdentifier != nil { - options = append(options, internal.WithUrlQuery("ownedByWorkspace", *workspaceIdentifier)) - } - return c.meshObject.List(ctx, options...) + return c.meshObject.List(ctx, internal.WithUrlQuery(meshBuildingBlockDefinitionListQuery{ + IncludeAllPublished: true, + OwnedByWorkspace: workspaceIdentifier, + })) } func (c meshBuildingBlockDefinitionClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlockDefinition, error) { diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 732f2c63..d63654b8 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -198,8 +198,14 @@ func newBuildingBlockDefinitionVersionClient(ctx context.Context, httpClient int } } +type meshBuildingBlockDefinitionVersionListQuery struct { + BuildingBlockDefinitionUuid string `json:"buildingBlockDefinitionUuid"` +} + func (c meshBuildingBlockDefinitionVersionClient) List(ctx context.Context, buildingBlockDefinitionUuid string) ([]MeshBuildingBlockDefinitionVersion, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery("buildingBlockDefinitionUuid", buildingBlockDefinitionUuid)) + return c.meshObject.List(ctx, internal.WithUrlQuery(meshBuildingBlockDefinitionVersionListQuery{ + BuildingBlockDefinitionUuid: buildingBlockDefinitionUuid, + })) } func (c meshBuildingBlockDefinitionVersionClient) Create(ctx context.Context, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) { diff --git a/building_block_v2.go b/building_block_v2.go index 0f4c6128..1a2bf192 100644 --- a/building_block_v2.go +++ b/building_block_v2.go @@ -140,33 +140,36 @@ type MeshBuildingBlockOutput struct { // MeshBuildingBlockV2ListFilter holds the optional query filters for listing building blocks // via the v2-preview list endpoint. All scalar fields are nil when unset (omitted from the // query). The backend returns only active building blocks; soft-deleted ones are not listed. +// MeshBuildingBlockV2ListFilter holds the optional filters for the V2 building block list endpoint. +// The json tags are the query param names and must match the backend fetchBuildingBlocksV2 +// @RequestParam names exactly; a typo silently disables the filter. type MeshBuildingBlockV2ListFilter struct { - WorkspaceIdentifier *string - ProjectIdentifier *string - PlatformIdentifier *string - Name *string + WorkspaceIdentifier *string `json:"workspaceIdentifier"` + ProjectIdentifier *string `json:"projectIdentifier"` + PlatformIdentifier *string `json:"platformIdentifier"` + Name *string `json:"name"` // DefinitionUuid filters by the owning building block definition's UUID (not a version). - DefinitionUuid *string + DefinitionUuid *string `json:"definitionUuid"` // VersionUuid filters by a specific building block definition version UUID. - VersionUuid *string + VersionUuid *string `json:"versionUuid"` // VersionNumber filters by the literal definition version number. The backend parses it // leniently, so both "v1" and "1" match version 1. - VersionNumber *string - TenantUuid *string + VersionNumber *string `json:"versionNumber"` + TenantUuid *string `json:"tenantUuid"` // TargetKind filters by target ref kind, one of meshTenant or meshWorkspace. - TargetKind *string - Status *string + TargetKind *string `json:"targetRefKind"` + Status *string `json:"status"` // ManagedByWorkspaceIdentifier and ManagedByDefinitionUuid select the platform-operator // (managed) permission scope: building blocks created from definitions owned by the given // workspace / definition. Requires the MANAGED_BUILDINGBLOCK_LIST authority. - ManagedByWorkspaceIdentifier *string - ManagedByDefinitionUuid *string + ManagedByWorkspaceIdentifier *string `json:"managedByWorkspaceIdentifier"` + ManagedByDefinitionUuid *string `json:"managedByDefinitionUuid"` } type MeshBuildingBlockV2Client interface { Read(ctx context.Context, uuid string) (*MeshBuildingBlockV2, error) ReadFunc(uuid string) func(ctx context.Context) (*MeshBuildingBlockV2, error) - List(ctx context.Context, filter *MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) + List(ctx context.Context, filter MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) Update(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) Delete(ctx context.Context, uuid string, purge bool) error @@ -191,33 +194,8 @@ func (c meshBuildingBlockV2Client) ReadFunc(uuid string) func(ctx context.Contex } } -func (c meshBuildingBlockV2Client) List(ctx context.Context, filter *MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) { - var options []internal.RequestOption - - // Map each non-nil scalar filter to its query param. Names must match the backend - // fetchBuildingBlocksV2 @RequestParam names exactly; a typo silently disables the filter. - if filter != nil { - for key, value := range map[string]*string{ - "workspaceIdentifier": filter.WorkspaceIdentifier, - "projectIdentifier": filter.ProjectIdentifier, - "platformIdentifier": filter.PlatformIdentifier, - "name": filter.Name, - "definitionUuid": filter.DefinitionUuid, - "versionUuid": filter.VersionUuid, - "versionNumber": filter.VersionNumber, - "tenantUuid": filter.TenantUuid, - "targetRefKind": filter.TargetKind, - "status": filter.Status, - "managedByWorkspaceIdentifier": filter.ManagedByWorkspaceIdentifier, - "managedByDefinitionUuid": filter.ManagedByDefinitionUuid, - } { - if value != nil { - options = append(options, internal.WithUrlQuery(key, *value)) - } - } - } - - return c.meshObject.List(ctx, options...) +func (c meshBuildingBlockV2Client) List(ctx context.Context, filter MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(filter)) } func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { diff --git a/internal/http_client.go b/internal/http_client.go index f4190b9e..f7cecd9d 100644 --- a/internal/http_client.go +++ b/internal/http_client.go @@ -66,6 +66,9 @@ func (c HttpClient) doRequest(ctx context.Context, method string, url *url.URL, for _, option := range options { option(&opts) } + if opts.optionErr != nil { + return nil, opts.optionErr + } req, err := c.buildRequest(ctx, method, *url, opts) if err != nil { return nil, err diff --git a/internal/http_client_test.go b/internal/http_client_test.go index 36da3a21..baceaec3 100644 --- a/internal/http_client_test.go +++ b/internal/http_client_test.go @@ -275,7 +275,8 @@ func TestHttpClient(t *testing.T) { } func TestUrlQueryOptions(t *testing.T) { - t.Run("WithUrlQuery sets query parameters", func(t *testing.T) { + queryFrom := func(t *testing.T, query any) url.Values { + t.Helper() var gotQuery url.Values client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { gotQuery = req.URL.Query() @@ -283,12 +284,41 @@ func TestUrlQueryOptions(t *testing.T) { _, _ = resp.Write([]byte(`"ok"`)) }) _, err := DoRequest[string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("list"), - WithUrlQuery("definitionUuid", "abc"), - WithUrlQuery("status", "SUCCEEDED"), + WithUrlQuery(query), ) require.NoError(t, err) - assert.Equal(t, "abc", gotQuery.Get("definitionUuid")) - assert.Equal(t, "SUCCEEDED", gotQuery.Get("status")) + return gotQuery + } + + t.Run("a map is sent verbatim", func(t *testing.T) { + got := queryFrom(t, map[string]string{"definitionUuid": "abc", "status": "SUCCEEDED"}) + assert.Equal(t, "abc", got.Get("definitionUuid")) + assert.Equal(t, "SUCCEEDED", got.Get("status")) + }) + + t.Run("map values are kept even when zero", func(t *testing.T) { + got := queryFrom(t, map[string]any{"page": 0}) + assert.Equal(t, "0", got.Get("page")) + }) + + t.Run("struct fields are named by json tag and zero fields are dropped", func(t *testing.T) { + type filter struct { + Identifier *string `json:"identifier"` + Name string `json:"name"` + Restricted *bool `json:"restricted"` + } + got := queryFrom(t, filter{Identifier: new("abc")}) + assert.Equal(t, "abc", got.Get("identifier")) + assert.False(t, got.Has("name"), "zero string field must be dropped") + assert.False(t, got.Has("restricted"), "nil pointer field must be dropped") + }) + + t.Run("a zero-value struct adds no params", func(t *testing.T) { + type filter struct { + Identifier *string `json:"identifier"` + } + got := queryFrom(t, &filter{}) + assert.Empty(t, got) }) } diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index 0fc5b0b6..eec05f73 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -146,7 +146,7 @@ func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) } response, err := DoAuthorizedRequest[paginatedResponse](ctx, c.HttpClient, http.MethodGet, c.ApiUrl, append(options, WithAccept(c.MeshObjectMimeType()), - WithUrlQuery("page", pageNumber), + WithUrlQuery(map[string]any{"page": pageNumber}), )...) if err != nil { return result, fmt.Errorf("error getting page %d: %w", pageNumber, err) diff --git a/internal/options.go b/internal/options.go index 26d41cf2..c06e8ba7 100644 --- a/internal/options.go +++ b/internal/options.go @@ -1,8 +1,11 @@ package internal import ( + "bytes" + "encoding/json" "fmt" "net/http" + "reflect" ) type ( @@ -14,24 +17,49 @@ type ( extraPathElems []string requestPayload any requestModifiers []requestModifier + // optionErr holds the first error produced while applying options (e.g. an unmarshalable + // query); doRequest surfaces it instead of building a request from partial options. + optionErr error } requestModifier func(req *http.Request) ) -// WithUrlQuery adds a URL query parameter to the request. -// The value is stringified using fmt.Stringer.String() if implemented, otherwise fmt.Sprintf("%v", value). -func WithUrlQuery(key string, value any) RequestOption { +// WithUrlQuery adds URL query parameters from a query value. +// +// The value is JSON-marshalled and decoded into a flat map, so each field becomes a query param +// named by its `json` tag. A struct passed by value is the common case: its zero-value fields are +// dropped (an implicit `omitempty`), so an unset filter needs neither a pointer nor an `omitempty` +// tag and a zero-value struct adds no params at all. A map[string]string / map[string]any is taken +// verbatim — every entry is sent, including deliberate zero values such as page=0. +// +// Values are stringified with fmt.Sprintf("%v", ...); nested objects or arrays are not supported. +func WithUrlQuery(query any) RequestOption { return func(opts *requestOptions) { - var valueStr string - if stringerValue, ok := value.(fmt.Stringer); ok { - valueStr = stringerValue.String() - } else { - valueStr = fmt.Sprintf("%v", value) + data, err := json.Marshal(query) + if err != nil { + opts.optionErr = fmt.Errorf("cannot marshal url query of type %T: %w", query, err) + return } - if opts.urlQueryParams == nil { - opts.urlQueryParams = map[string]string{} + // UseNumber keeps integers (e.g. page) from becoming float64 and gaining a ".0" or exponent. + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var params map[string]any + if err := decoder.Decode(¶ms); err != nil { + opts.optionErr = fmt.Errorf("cannot decode url query of type %T into a flat map: %w", query, err) + return + } + // Drop zero-value fields only for a struct (passed by value, not by pointer); a map is + // passed through as given. + skipZero := reflect.ValueOf(query).Kind() == reflect.Struct + for key, value := range params { + if value == nil || (skipZero && reflect.ValueOf(value).IsZero()) { + continue + } + if opts.urlQueryParams == nil { + opts.urlQueryParams = map[string]string{} + } + opts.urlQueryParams[key] = fmt.Sprintf("%v", value) } - opts.urlQueryParams[key] = valueStr } } diff --git a/platform_type.go b/platform_type.go index a13a77fe..270a5c61 100644 --- a/platform_type.go +++ b/platform_type.go @@ -75,13 +75,14 @@ func (c meshPlatformTypeClient) Delete(ctx context.Context, name string) error { return c.meshObject.Delete(ctx, name) } +type meshPlatformTypeListQuery struct { + Category *string `json:"category"` + LifecycleStatus *string `json:"lifecycleStatus"` +} + func (c meshPlatformTypeClient) List(ctx context.Context, category *string, lifecycleStatus *string) ([]MeshPlatformType, error) { - var options []internal.RequestOption - if category != nil { - options = append(options, internal.WithUrlQuery("category", *category)) - } - if lifecycleStatus != nil { - options = append(options, internal.WithUrlQuery("lifecycleStatus", *lifecycleStatus)) - } - return c.meshObject.List(ctx, options...) + return c.meshObject.List(ctx, internal.WithUrlQuery(meshPlatformTypeListQuery{ + Category: category, + LifecycleStatus: lifecycleStatus, + })) } diff --git a/project.go b/project.go index 2d4f60db..1f9078bc 100644 --- a/project.go +++ b/project.go @@ -59,14 +59,16 @@ func (c meshProjectClient) Read(ctx context.Context, workspace string, name stri return c.meshObject.Get(ctx, c.projectId(workspace, name)) } +type meshProjectListQuery struct { + WorkspaceIdentifier string `json:"workspaceIdentifier"` + PaymentIdentifier *string `json:"paymentIdentifier"` +} + func (c meshProjectClient) List(ctx context.Context, workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { - options := []internal.RequestOption{ - internal.WithUrlQuery("workspaceIdentifier", workspaceIdentifier), - } - if paymentMethodIdentifier != nil { - options = append(options, internal.WithUrlQuery("paymentIdentifier", *paymentMethodIdentifier)) - } - return c.meshObject.List(ctx, options...) + return c.meshObject.List(ctx, internal.WithUrlQuery(meshProjectListQuery{ + WorkspaceIdentifier: workspaceIdentifier, + PaymentIdentifier: paymentMethodIdentifier, + })) } func (c meshProjectClient) Create(ctx context.Context, project *MeshProjectCreate) (*MeshProject, error) { diff --git a/service_instance.go b/service_instance.go index 50c7dbb7..74918e76 100644 --- a/service_instance.go +++ b/service_instance.go @@ -29,7 +29,7 @@ type MeshServiceInstanceSpec struct { type MeshServiceInstanceClient interface { Read(ctx context.Context, instanceId string) (*MeshServiceInstance, error) - List(ctx context.Context, filter *MeshServiceInstanceFilter) ([]MeshServiceInstance, error) + List(ctx context.Context, filter MeshServiceInstanceFilter) ([]MeshServiceInstance, error) } type meshServiceInstanceClient struct { @@ -37,11 +37,11 @@ type meshServiceInstanceClient struct { } type MeshServiceInstanceFilter struct { - WorkspaceIdentifier *string - ProjectIdentifier *string - MarketplaceIdentifier *string - ServiceIdentifier *string - PlanIdentifier *string + WorkspaceIdentifier *string `json:"workspaceIdentifier"` + ProjectIdentifier *string `json:"projectIdentifier"` + MarketplaceIdentifier *string `json:"marketplaceIdentifier"` + ServiceIdentifier *string `json:"serviceIdentifier"` + PlanIdentifier *string `json:"planIdentifier"` } func newServiceInstanceClient(ctx context.Context, httpClient internal.HttpClient) MeshServiceInstanceClient { @@ -52,24 +52,6 @@ func (c meshServiceInstanceClient) Read(ctx context.Context, instanceId string) return c.meshObject.Get(ctx, instanceId) } -func (c meshServiceInstanceClient) List(ctx context.Context, filter *MeshServiceInstanceFilter) ([]MeshServiceInstance, error) { - var options []internal.RequestOption - if filter != nil { - if filter.WorkspaceIdentifier != nil { - options = append(options, internal.WithUrlQuery("workspaceIdentifier", *filter.WorkspaceIdentifier)) - } - if filter.ProjectIdentifier != nil { - options = append(options, internal.WithUrlQuery("projectIdentifier", *filter.ProjectIdentifier)) - } - if filter.MarketplaceIdentifier != nil { - options = append(options, internal.WithUrlQuery("marketplaceIdentifier", *filter.MarketplaceIdentifier)) - } - if filter.ServiceIdentifier != nil { - options = append(options, internal.WithUrlQuery("serviceIdentifier", *filter.ServiceIdentifier)) - } - if filter.PlanIdentifier != nil { - options = append(options, internal.WithUrlQuery("planIdentifier", *filter.PlanIdentifier)) - } - } - return c.meshObject.List(ctx, options...) +func (c meshServiceInstanceClient) List(ctx context.Context, filter MeshServiceInstanceFilter) ([]MeshServiceInstance, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(filter)) } diff --git a/tenant_v4.go b/tenant_v4.go index 697d43f4..7ebc67ac 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -55,18 +55,18 @@ type MeshTenantV4CreateSpec struct { } type MeshTenantV4Query struct { - Workspace string - Project *string - Platform *string - PlatformType *string - LandingZone *string - PlatformTenant *string + Workspace string `json:"workspaceIdentifier"` + Project *string `json:"projectIdentifier"` + Platform *string `json:"platformIdentifier"` + PlatformType *string `json:"platformTypeIdentifier"` + LandingZone *string `json:"landingZoneIdentifier"` + PlatformTenant *string `json:"platformTenantId"` } type MeshTenantV4Client interface { Read(ctx context.Context, uuid string) (*MeshTenantV4, error) ReadFunc(uuid string) func(ctx context.Context) (*MeshTenantV4, error) - List(ctx context.Context, query *MeshTenantV4Query) ([]MeshTenantV4, error) + List(ctx context.Context, query MeshTenantV4Query) ([]MeshTenantV4, error) Create(ctx context.Context, tenant *MeshTenantV4Create) (*MeshTenantV4, error) Delete(ctx context.Context, uuid string) error } @@ -93,26 +93,8 @@ func (c meshTenantV4Client) Create(ctx context.Context, tenant *MeshTenantV4Crea return c.meshObject.Post(ctx, tenant) } -func (c meshTenantV4Client) List(ctx context.Context, query *MeshTenantV4Query) ([]MeshTenantV4, error) { - options := []internal.RequestOption{ - internal.WithUrlQuery("workspaceIdentifier", query.Workspace), - } - if query.Project != nil { - options = append(options, internal.WithUrlQuery("projectIdentifier", *query.Project)) - } - if query.Platform != nil { - options = append(options, internal.WithUrlQuery("platformIdentifier", *query.Platform)) - } - if query.PlatformType != nil { - options = append(options, internal.WithUrlQuery("platformTypeIdentifier", *query.PlatformType)) - } - if query.LandingZone != nil { - options = append(options, internal.WithUrlQuery("landingZoneIdentifier", *query.LandingZone)) - } - if query.PlatformTenant != nil { - options = append(options, internal.WithUrlQuery("platformTenantId", *query.PlatformTenant)) - } - return c.meshObject.List(ctx, options...) +func (c meshTenantV4Client) List(ctx context.Context, query MeshTenantV4Query) ([]MeshTenantV4, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(query)) } func (c meshTenantV4Client) Delete(ctx context.Context, uuid string) error { @@ -184,18 +166,18 @@ type MeshTenantCreateSpec struct { } type MeshTenantQuery struct { - Workspace string - Project *string - Platform *string - PlatformType *string - LandingZone *string - PlatformTenant *string + Workspace string `json:"workspaceIdentifier"` + Project *string `json:"projectIdentifier"` + Platform *string `json:"platformIdentifier"` + PlatformType *string `json:"platformTypeIdentifier"` + LandingZone *string `json:"landingZoneIdentifier"` + PlatformTenant *string `json:"platformTenantId"` } type MeshTenantClient interface { Read(ctx context.Context, uuid string) (*MeshTenant, error) ReadFunc(uuid string) func(ctx context.Context) (*MeshTenant, error) - List(ctx context.Context, query *MeshTenantQuery) ([]MeshTenant, error) + List(ctx context.Context, query MeshTenantQuery) ([]MeshTenant, error) Create(ctx context.Context, tenant *MeshTenantCreate) (*MeshTenant, error) Delete(ctx context.Context, uuid string) error } @@ -222,26 +204,8 @@ func (c meshTenantClient) Create(ctx context.Context, tenant *MeshTenantCreate) return c.meshObject.Post(ctx, tenant) } -func (c meshTenantClient) List(ctx context.Context, query *MeshTenantQuery) ([]MeshTenant, error) { - options := []internal.RequestOption{ - internal.WithUrlQuery("workspaceIdentifier", query.Workspace), - } - if query.Project != nil { - options = append(options, internal.WithUrlQuery("projectIdentifier", *query.Project)) - } - if query.Platform != nil { - options = append(options, internal.WithUrlQuery("platformIdentifier", *query.Platform)) - } - if query.PlatformType != nil { - options = append(options, internal.WithUrlQuery("platformTypeIdentifier", *query.PlatformType)) - } - if query.LandingZone != nil { - options = append(options, internal.WithUrlQuery("landingZoneIdentifier", *query.LandingZone)) - } - if query.PlatformTenant != nil { - options = append(options, internal.WithUrlQuery("platformTenantId", *query.PlatformTenant)) - } - return c.meshObject.List(ctx, options...) +func (c meshTenantClient) List(ctx context.Context, query MeshTenantQuery) ([]MeshTenant, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(query)) } func (c meshTenantClient) Delete(ctx context.Context, uuid string) error { From 66fe8157dbd09b0555044ee8331b16c93632f483 Mon Sep 17 00:00:00 2001 From: Vadim Zaslavsky Date: Tue, 21 Jul 2026 12:50:59 +0200 Subject: [PATCH 173/215] fix: make declared outputs work on manual building blocks Previously, declaring `version_spec.outputs` on a manual building block failed at apply with "Provider produced inconsistent result after apply": the backend always returns one output per input, while config held only the outputs the user declared, so backend-derived elements "appeared" and the backend-assigned display_order / content_hash disagreed with the plan on create, release, and re-draft. Declared manual outputs are now a sparse override: declare only the outputs you customize (keyed by the matching input); the rest are derived. Config and state hold just your overrides, and the backend's full one-per-input response is pruned to that tracked subset on read-back (keep an output iff its assignment_type != NONE or its display_name differs from the input's). An empty `outputs = {}`, like omitting it, means "no overrides". - type is always backend-derived: rejected in config, derived and sent by the provider (the backend 400s on an empty or mismatching output type). - display_name/display_order are Optional+Computed and held from state via UseStateForUnknown, so a no-op plan stays fully known and the content hash is stable. - The full one-per-input set is sent on every apply, so dropping an override resets it (the backend preserves overrides for keys absent from the request). - ValidateConfig rejects an output with no matching input, a declared type, or a no-op override; non-manual outputs still require type and display_name. - content_hash bumped to v5 (hashing the tracked subset); an older hash is recomputed rather than flagged changed, so upgrading does not rerun already-released blocks. Fixes #131, #176, #240. BD-2594 Co-Authored-By: Claude Opus 4.8 (1M context) --- types/enum/enum.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/types/enum/enum.go b/types/enum/enum.go index a8279e65..0f07fef5 100644 --- a/types/enum/enum.go +++ b/types/enum/enum.go @@ -2,7 +2,6 @@ package enum import ( "fmt" - "slices" "strings" ) @@ -29,14 +28,6 @@ func (e Enum[T]) Strings() []string { return e.to(Entry[T].String) } -// Except returns the enum minus the given entries, preserving order. Deriving a subset this way keeps a -// single source of truth: adding an entry to the base enum flows into the subset automatically. -func (e Enum[T]) Except(excluded ...Entry[T]) Enum[T] { - return slices.DeleteFunc(slices.Clone(e), func(ee Entry[T]) bool { - return slices.Contains(excluded, ee) - }) -} - func (e Enum[T]) Markdown() string { return strings.Join(e.to(Entry[T].Markdown), ", ") } From 958d44853217b9c35012032f9e517bb36ffcf469 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 23 Jul 2026 08:07:25 +0200 Subject: [PATCH 174/215] feat!: rename meshstack_tenant status.tenant_identifier to tenant_name Track the GA meshTenant v4 API, which dropped status.tenantIdentifier in favour of status.tenantName. The value is unchanged (the fully-qualified ...); only the attribute/field name changes on meshstack_tenant and the meshstack_tenants data source. Co-Authored-By: Claude Opus 4.8 (1M context) --- tenant_v4.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_v4.go b/tenant_v4.go index 7ebc67ac..c5ed3781 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -137,7 +137,7 @@ type MeshTenantSpec struct { // MeshTenantStatus has no quotas field; quotas are part of the tenant spec, not its status. type MeshTenantStatus struct { - TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` + TenantName string `json:"tenantName" tfsdk:"tenant_name"` PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` PlatformWorkspaceId *string `json:"platformWorkspaceId" tfsdk:"platform_workspace_id"` Tags map[string][]string `json:"tags" tfsdk:"tags"` From dc0621d308401d1bab1b12731b8b746539a14c5c Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 23 Jul 2026 16:52:53 +0200 Subject: [PATCH 175/215] chore: apply go1.26 go fix idioms Run `go fix ./...` and take its default fixes: - any: interface{} -> any in a test helper signature - rangeint: for i := 0; i < len(x); i++ -> for i := range x - omitzero: drop dead `,omitempty` on struct-typed TF fields (types.SecretOrAny, types.List). encoding/json never omits struct types, so the tag was a no-op; Variant already marshals the zero value to null. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- building_block_definition_version.go | 4 ++-- version/version_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index d63654b8..2f84dc9e 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -94,8 +94,8 @@ type MeshBuildingBlockDefinitionInput struct { // Otherwise, the [types.Variant] is of [types.Any] (case [types.Variant.Y]). // As this is a fallback detection when JSON (un)marshaling, // types.Any must go second as [types.Variant] intentionally prefers X over Y. - Argument types.SecretOrAny `json:"argument,omitempty" tfsdk:"argument"` - DefaultValue types.SecretOrAny `json:"defaultValue,omitempty" tfsdk:"default_value"` + Argument types.SecretOrAny `json:"argument" tfsdk:"argument"` + DefaultValue types.SecretOrAny `json:"defaultValue" tfsdk:"default_value"` UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` SelectableValues types.Set[string] `json:"selectableValues,omitempty" tfsdk:"selectable_values"` Description *string `json:"description,omitempty" tfsdk:"description"` diff --git a/version/version_test.go b/version/version_test.go index 9e06840c..aa7911b6 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -10,7 +10,7 @@ import ( func TestParse(t *testing.T) { assertErrorContainsAllOf := func(contains ...string) assert.ErrorAssertionFunc { - return func(t assert.TestingT, err error, msgAndArgs ...interface{}) bool { + return func(t assert.TestingT, err error, msgAndArgs ...any) bool { assert.NotEmpty(t, contains) allOk := true for _, contain := range contains { From 2999fae2998ec448833327b56da059950db4aaed Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 21 Jul 2026 01:28:11 +0200 Subject: [PATCH 176/215] feat: add List wrappers to platform and landing zone clients Add thin List methods over MeshObjectClient.List (with WithUrlQuery filters, mirroring platform_type/tenant_v4) to MeshPlatformClient and MeshLandingZoneClient, plus matching in-memory List implementations on the mock clients that apply only plain attribute filtering. The mocks deliberately do not simulate marketplace visibility or permissions, so cross-workspace entitlement remains acceptance-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- landingzone.go | 15 +++++++++++++++ platform.go | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/landingzone.go b/landingzone.go index 42474aa0..259be05d 100644 --- a/landingzone.go +++ b/landingzone.go @@ -58,8 +58,19 @@ type MeshLandingZoneCreate struct { Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` } +// MeshLandingZoneListQuery holds the optional filters for the V1 landing zone list endpoint. The +// json tags name the query params; unset (nil/zero) fields are dropped by WithUrlQuery. +type MeshLandingZoneListQuery struct { + PlatformUuid *string `json:"platformUuid"` + Identifier *string `json:"identifier"` + DisplayName *string `json:"displayName"` + Restricted *bool `json:"restricted"` + OwnedByWorkspace *string `json:"ownedByWorkspace"` +} + type MeshLandingZoneClient interface { Read(ctx context.Context, name string) (*MeshLandingZone, error) + List(ctx context.Context, query MeshLandingZoneListQuery) ([]MeshLandingZone, error) Create(ctx context.Context, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) Update(ctx context.Context, name string, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) Delete(ctx context.Context, name string) error @@ -77,6 +88,10 @@ func (c meshLandingZoneClient) Read(ctx context.Context, name string) (*MeshLand return c.meshObject.Get(ctx, name) } +func (c meshLandingZoneClient) List(ctx context.Context, query MeshLandingZoneListQuery) ([]MeshLandingZone, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(query)) +} + func (c meshLandingZoneClient) Create(ctx context.Context, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { return c.meshObject.Post(ctx, landingZone) } diff --git a/platform.go b/platform.go index d49d4d3e..33fa150e 100644 --- a/platform.go +++ b/platform.go @@ -75,8 +75,24 @@ type TagMapper struct { ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` } +// MeshPlatformListQuery holds the optional filters for the V2 platform list endpoint. The json tags +// name the query params; unset (nil/zero) fields are dropped by WithUrlQuery. +type MeshPlatformListQuery struct { + OwnedByWorkspace *string `json:"ownedByWorkspace"` + Identifier *string `json:"identifier"` + LocationIdentifier *string `json:"locationIdentifier"` + DisplayName *string `json:"displayName"` + Restriction *string `json:"restriction"` + PublicationState *string `json:"publicationState"` + ContributingWorkspace *string `json:"contributingWorkspace"` + // PlatformTypeIdentifier filters by the platform type's identifier (matched backend-side); the type + // is not carried in the response, and spec.config is redacted for marketplace consumers anyway. + PlatformTypeIdentifier *string `json:"platformTypeIdentifier"` +} + type MeshPlatformClient interface { Read(ctx context.Context, uuid string) (*MeshPlatform, error) + List(ctx context.Context, query MeshPlatformListQuery) ([]MeshPlatform, error) Create(ctx context.Context, platform MeshPlatform) (*MeshPlatform, error) Update(ctx context.Context, uuid string, platform MeshPlatform) (*MeshPlatform, error) Delete(ctx context.Context, uuid string) error @@ -94,6 +110,10 @@ func (c meshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatfor return c.meshObject.Get(ctx, uuid) } +func (c meshPlatformClient) List(ctx context.Context, query MeshPlatformListQuery) ([]MeshPlatform, error) { + return c.meshObject.List(ctx, internal.WithUrlQuery(query)) +} + func (c meshPlatformClient) Create(ctx context.Context, platform MeshPlatform) (*MeshPlatform, error) { return c.meshObject.Post(ctx, platform) } From ee819a2cb5a31cd962bbd228c80a3fc6d362442d Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 21 Jul 2026 01:28:24 +0200 Subject: [PATCH 177/215] feat: add meshstack_platforms and meshstack_landingzones data sources New plural (list) data sources so a platform or landing zone can be resolved by name, identifier, type or publication state in HCL instead of hardcoding a UUID. Each element reuses the singular data source's element schema (extracted into shared schema helpers) and its per-element platformModelFromDto / landingZoneModelFrom mapping, including the computed ref that drops straight into meshstack_tenant's platform_ref / landing_zone_ref. meshstack_platforms exposes the marketplace/discovery filters (publication_state, restriction, platform_type as a PlatformCategory, owned_by_workspace, ...) and, on a backend that supports it, also returns platforms published to the caller's workspace; meshstack_landingzones exposes platform_uuid to list a chosen platform's landing zones. spec.config is Computed and may be omitted for a platform the caller only consumes cross-workspace; the singular and plural platform data source descriptions document this. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/platform.go b/platform.go index 33fa150e..9c0bbad8 100644 --- a/platform.go +++ b/platform.go @@ -19,17 +19,18 @@ type MeshPlatformMetadata struct { } type MeshPlatformSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Description string `json:"description" tfsdk:"description"` - Endpoint string `json:"endpoint" tfsdk:"endpoint"` - SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` - DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` - AccessInformation *string `json:"accessInformation,omitempty" tfsdk:"access_information"` - LocationRef NamedRef `json:"locationRef" tfsdk:"location_ref"` - ContributingWorkspaces types.Set[string] `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` - Availability PlatformAvailability `json:"availability" tfsdk:"availability"` - Config PlatformConfig `json:"config" tfsdk:"config"` - QuotaDefinitions types.Set[QuotaDefinition] `json:"quotaDefinitions" tfsdk:"quota_definitions"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + Endpoint string `json:"endpoint" tfsdk:"endpoint"` + SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` + DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + AccessInformation *string `json:"accessInformation,omitempty" tfsdk:"access_information"` + LocationRef NamedRef `json:"locationRef" tfsdk:"location_ref"` + ContributingWorkspaces types.Set[string] `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` + Availability PlatformAvailability `json:"availability" tfsdk:"availability"` + // Config is nullable in responses: redacted (omitted) for marketplace-consumer callers. Required on write. + Config *PlatformConfig `json:"config,omitempty" tfsdk:"config"` + QuotaDefinitions types.Set[QuotaDefinition] `json:"quotaDefinitions" tfsdk:"quota_definitions"` } type QuotaDefinition struct { From 6a1043114a8e1d35bbc9925942c130cbbb3f66d8 Mon Sep 17 00:00:00 2001 From: Vadim Zaslavsky Date: Thu, 23 Jul 2026 16:47:04 +0200 Subject: [PATCH 178/215] feat: expiry date on workspace bindings Workspace group and user bindings now accept the expiry date. --- client.go | 2 +- workspace_binding.go | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client.go b/client.go index 93d33872..e266727b 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.29.0") +var MinMeshStackVersion = version.MustParse("2026.30.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. diff --git a/workspace_binding.go b/workspace_binding.go index fc6a253c..30d744ec 100644 --- a/workspace_binding.go +++ b/workspace_binding.go @@ -1,10 +1,11 @@ package client type MeshWorkspaceBinding struct { - Metadata MeshWorkspaceBindingMetadata `json:"metadata" tfsdk:"metadata"` - RoleRef MeshWorkspaceRoleRef `json:"roleRef" tfsdk:"role_ref"` - TargetRef MeshWorkspaceTargetRef `json:"targetRef" tfsdk:"target_ref"` - Subject MeshWorkspaceSubject `json:"subject" tfsdk:"subject"` + Metadata MeshWorkspaceBindingMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshWorkspaceRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshWorkspaceTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshWorkspaceSubject `json:"subject" tfsdk:"subject"` + ExpiryDate *string `json:"expiryDate,omitempty" tfsdk:"expiry_date"` } type MeshWorkspaceBindingMetadata struct { From a6d35b7dbbcd0cff46aefaea5efd8d79ab211ca7 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Wed, 22 Jul 2026 17:36:55 +0200 Subject: [PATCH 179/215] feat: read back effective tenant quotas from meshTenant v4 status.quotas The meshObject meshTenant v4-preview API now returns the tenant's applied quotas in status.quotas and enforces spec.quotas against each quota's [minValue, maxValue] bounds and auto-approval threshold on create (HTTP 400 on rejection). Consume that contract: - Expose a computed status.quotas set on the meshstack_tenant resource and the meshstack_tenant / meshstack_tenants data sources, sourced from the API instead of being fabricated from spec.quotas. - Switch the deprecated meshstack_tenant_v4 resource/data source to read the real status.quotas as well (schema-compatible; status is computed-only). - spec.quotas stays create-only: changing it on an existing tenant is still rejected at plan time, and the backend's descriptive 400 for out-of-range/above-threshold values bubbles up via the existing create error path. - Bump MinMeshStackVersion to 2026.30.0 (the release carrying the BD-2607 backend). The change is additive, so no terraform-provider-compat registry entry is required. Co-Authored-By: Claude Opus 4.8 (1M context) --- tenant_v4.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tenant_v4.go b/tenant_v4.go index c5ed3781..c2259b50 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -35,6 +35,9 @@ type MeshTenantV4Status struct { PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` PlatformWorkspaceIdentifier *string `json:"platformWorkspaceIdentifier" tfsdk:"platform_workspace_identifier"` Tags map[string][]string `json:"tags" tfsdk:"tags"` + // Quotas are the effective quotas meshStack applied to the tenant, distinct from the create-only + // spec.quotas which carries only the requested values. + Quotas []MeshTenantQuota `json:"quotas" tfsdk:"quotas"` } type MeshTenantV4Create struct { @@ -135,12 +138,15 @@ type MeshTenantSpec struct { Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` } -// MeshTenantStatus has no quotas field; quotas are part of the tenant spec, not its status. type MeshTenantStatus struct { TenantName string `json:"tenantName" tfsdk:"tenant_name"` PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` PlatformWorkspaceId *string `json:"platformWorkspaceId" tfsdk:"platform_workspace_id"` Tags map[string][]string `json:"tags" tfsdk:"tags"` + // Quotas are the effective quotas meshStack applied to the tenant. spec.quotas carries only the + // values requested at create (create-only); the effective quotas here can differ once landing-zone + // defaults are merged in or an operator adjusts them, so drift is tracked against these. + Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` } type MeshTenantQuota struct { From 6ae439db9f609b4919020967464b0d0f23e4e310 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Thu, 23 Jul 2026 13:00:15 +0200 Subject: [PATCH 180/215] feat: model tenant quotas as maps (requested_quotas / applied_quotas) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the meshTenant v4 API quota rework: - spec: add `requested_quotas` (map(number)); keep `quotas` (list) as a deprecated attribute for backward compatibility - status: `applied_quotas` (map(number)) read back from the API, replacing the unreleased `status.quotas` set — on meshstack_tenant, its data sources, and the deprecated meshstack_tenant_v4 - update client structs, mocks, tests; regenerate docs; changelog Co-Authored-By: Claude Opus 4.8 (1M context) --- tenant_v4.go | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/tenant_v4.go b/tenant_v4.go index c2259b50..eac3f89b 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -35,9 +35,9 @@ type MeshTenantV4Status struct { PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` PlatformWorkspaceIdentifier *string `json:"platformWorkspaceIdentifier" tfsdk:"platform_workspace_identifier"` Tags map[string][]string `json:"tags" tfsdk:"tags"` - // Quotas are the effective quotas meshStack applied to the tenant, distinct from the create-only - // spec.quotas which carries only the requested values. - Quotas []MeshTenantQuota `json:"quotas" tfsdk:"quotas"` + // AppliedQuotas are the effective quotas meshStack applied to the tenant as a key->value map, + // distinct from the create-only spec.quotas which carries only the requested values. + AppliedQuotas map[string]int64 `json:"appliedQuotas" tfsdk:"applied_quotas"` } type MeshTenantV4Create struct { @@ -132,10 +132,14 @@ type MeshTenantMetadata struct { } type MeshTenantSpec struct { - PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` - PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` - LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` - Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` + PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` + PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` + LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` + // RequestedQuotas is the preferred key->value form for requesting quotas at creation, e.g. + // {"limits.cpu": 4}. + RequestedQuotas map[string]int64 `json:"requestedQuotas" tfsdk:"requested_quotas"` + // Deprecated: superseded by RequestedQuotas; retained so existing configurations keep working. + Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` } type MeshTenantStatus struct { @@ -143,10 +147,11 @@ type MeshTenantStatus struct { PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` PlatformWorkspaceId *string `json:"platformWorkspaceId" tfsdk:"platform_workspace_id"` Tags map[string][]string `json:"tags" tfsdk:"tags"` - // Quotas are the effective quotas meshStack applied to the tenant. spec.quotas carries only the - // values requested at create (create-only); the effective quotas here can differ once landing-zone - // defaults are merged in or an operator adjusts them, so drift is tracked against these. - Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` + // AppliedQuotas are the effective quotas meshStack applied to the tenant as a key->value map. + // spec.requested_quotas carries only the values requested at create (create-only); the effective + // quotas here can differ once landing-zone defaults are merged in or an operator adjusts them, so + // drift is tracked against these. + AppliedQuotas map[string]int64 `json:"appliedQuotas" tfsdk:"applied_quotas"` } type MeshTenantQuota struct { @@ -165,10 +170,13 @@ type MeshTenantCreateMetadata struct { } type MeshTenantCreateSpec struct { - PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` - LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` - PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` - Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` + PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` + LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` + PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` + // RequestedQuotas is the preferred key->value form; Quotas is the deprecated list form. Only one + // should be set — the backend rejects a create that carries both with conflicting values. + RequestedQuotas map[string]int64 `json:"requestedQuotas,omitempty" tfsdk:"requested_quotas"` + Quotas types.Set[MeshTenantQuota] `json:"quotas,omitempty" tfsdk:"quotas"` } type MeshTenantQuery struct { From acd3c49ee9b1d9969d3ce146cdfc515fe68e7ae4 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Fri, 24 Jul 2026 14:57:26 +0200 Subject: [PATCH 181/215] feat: model tenant quotas as structured value objects and warn on unrealized quotas Address review feedback on the v4 meshTenant quota representation, matching the companion meshfed-release change (BD-2607, branch feature/BD-2607-meshobject-api-ignores-quotas): - Wrap requested_quotas/applied_quotas map values in RequestQuotaValue / AppliedQuotaValue objects ({value}) across meshstack_tenant, its data sources, and the deprecated meshstack_tenant_v4. The map values now match the API and can gain per-quota fields later without a breaking change; the user-facing attributes become maps of objects (e.g. { "limits.cpu" = { value = 4 } }). - Warn (not error) when the requested quotas were not applied verbatim, hinting that landing-zone defaults or a pending platform-operator approval may be the reason. Comparison logic is a pure helper with unit tests. - spec.requested_quotas is a create-time input the API no longer returns on read; the data sources now document it as typically null. Update mocks, tests, changelog, and regenerate docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- tenant_v4.go | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/tenant_v4.go b/tenant_v4.go index eac3f89b..fe347b93 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -36,8 +36,10 @@ type MeshTenantV4Status struct { PlatformWorkspaceIdentifier *string `json:"platformWorkspaceIdentifier" tfsdk:"platform_workspace_identifier"` Tags map[string][]string `json:"tags" tfsdk:"tags"` // AppliedQuotas are the effective quotas meshStack applied to the tenant as a key->value map, - // distinct from the create-only spec.quotas which carries only the requested values. - AppliedQuotas map[string]int64 `json:"appliedQuotas" tfsdk:"applied_quotas"` + // distinct from the create-only spec.quotas which carries only the requested values. Each value is a + // structured object (e.g. `{"limits.cpu": {"value": 4}}`) so the preview API can grow per-quota + // fields without a breaking change to the map shape. + AppliedQuotas map[string]AppliedQuotaValue `json:"appliedQuotas" tfsdk:"applied_quotas"` } type MeshTenantV4Create struct { @@ -136,8 +138,9 @@ type MeshTenantSpec struct { PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` // RequestedQuotas is the preferred key->value form for requesting quotas at creation, e.g. - // {"limits.cpu": 4}. - RequestedQuotas map[string]int64 `json:"requestedQuotas" tfsdk:"requested_quotas"` + // {"limits.cpu": {"value": 4}}. The backend does not return it on read (it is a create-time input), + // so the resource echoes the configured value from state. + RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas" tfsdk:"requested_quotas"` // Deprecated: superseded by RequestedQuotas; retained so existing configurations keep working. Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` } @@ -147,11 +150,11 @@ type MeshTenantStatus struct { PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` PlatformWorkspaceId *string `json:"platformWorkspaceId" tfsdk:"platform_workspace_id"` Tags map[string][]string `json:"tags" tfsdk:"tags"` - // AppliedQuotas are the effective quotas meshStack applied to the tenant as a key->value map. - // spec.requested_quotas carries only the values requested at create (create-only); the effective - // quotas here can differ once landing-zone defaults are merged in or an operator adjusts them, so - // drift is tracked against these. - AppliedQuotas map[string]int64 `json:"appliedQuotas" tfsdk:"applied_quotas"` + // AppliedQuotas are the effective quotas meshStack applied to the tenant as a key->value map, each + // value a structured object (e.g. `{"limits.cpu": {"value": 4}}`). spec.requested_quotas carries + // only the values requested at create (create-only); the effective quotas here can differ once + // landing-zone defaults are merged in or an operator adjusts them, so drift is tracked against these. + AppliedQuotas map[string]AppliedQuotaValue `json:"appliedQuotas" tfsdk:"applied_quotas"` } type MeshTenantQuota struct { @@ -159,6 +162,20 @@ type MeshTenantQuota struct { Value int64 `json:"value" tfsdk:"value"` } +// RequestQuotaValue is a requested tenant quota value. The scalar is wrapped in an object (rather than +// a bare number) so the v4 preview API can grow per-quota fields — e.g. a unit — without a breaking +// change to the requested_quotas map shape. +type RequestQuotaValue struct { + Value int64 `json:"value" tfsdk:"value"` +} + +// AppliedQuotaValue is a tenant quota value as actually applied by the backend. Kept distinct from +// RequestQuotaValue so it can later carry applied-only context (e.g. why the applied value differs +// from what was requested). +type AppliedQuotaValue struct { + Value int64 `json:"value" tfsdk:"value"` +} + type MeshTenantCreate struct { Metadata MeshTenantCreateMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshTenantCreateSpec `json:"spec" tfsdk:"spec"` @@ -175,8 +192,8 @@ type MeshTenantCreateSpec struct { PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` // RequestedQuotas is the preferred key->value form; Quotas is the deprecated list form. Only one // should be set — the backend rejects a create that carries both with conflicting values. - RequestedQuotas map[string]int64 `json:"requestedQuotas,omitempty" tfsdk:"requested_quotas"` - Quotas types.Set[MeshTenantQuota] `json:"quotas,omitempty" tfsdk:"quotas"` + RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas,omitempty" tfsdk:"requested_quotas"` + Quotas types.Set[MeshTenantQuota] `json:"quotas,omitempty" tfsdk:"quotas"` } type MeshTenantQuery struct { From 88079633b4740059a2b0b16fd7c2af066ee4ac7e Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Wed, 29 Jul 2026 15:56:47 +0200 Subject: [PATCH 182/215] fix: correct tenant quota approval semantics, cover landing-zone defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the PR review on the tenant quota map work. The provider claimed a requested quota beyond the platform's auto-approval threshold waits for operator approval. The meshObject API does the opposite: it refuses such a create outright, deliberately, because it has no quota-request representation and a pending request would let an apply report success on quotas that are not in effect. Rewrite the warning and the schema descriptions to document what actually happens — applied quotas are the landing zone's defaults overlaid with the request, and a requested key that differs was changed after creation. Cover the two cases a real backend can produce: a landing-zone default the tenant never requests (applied is a strict superset of requested, asserted to not drift on re-plan) and an above-threshold request being rejected. The tenant mock now overlays landing-zone default quotas as the backend does, so the former runs in both unit and acceptance mode. Also mark the deprecated create-spec quotas field with a godoc marker, document MeshTenantQuota as the deprecated list-form element, state the real reason RequestQuotaValue and AppliedQuotaValue stay distinct types, and shorten the changelog entry. Co-Authored-By: Claude Opus 5 (1M context) --- tenant_v4.go | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tenant_v4.go b/tenant_v4.go index fe347b93..f8bdacf4 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -157,21 +157,27 @@ type MeshTenantStatus struct { AppliedQuotas map[string]AppliedQuotaValue `json:"appliedQuotas" tfsdk:"applied_quotas"` } +// MeshTenantQuota is the {key, value} element of the deprecated list-form spec.quotas, superseded by +// the requested_quotas / applied_quotas maps. It is still the quota shape of the deprecated +// meshstack_tenant_v4 resource, so it carries no godoc deprecation marker. type MeshTenantQuota struct { Key string `json:"key" tfsdk:"key"` Value int64 `json:"value" tfsdk:"value"` } -// RequestQuotaValue is a requested tenant quota value. The scalar is wrapped in an object (rather than -// a bare number) so the v4 preview API can grow per-quota fields — e.g. a unit — without a breaking -// change to the requested_quotas map shape. +// RequestQuotaValue is a tenant quota value as requested at create time. The scalar is wrapped in an +// object (rather than a bare number) so the v4 preview API can grow per-quota fields — e.g. a unit — +// without a breaking change to the requested_quotas map shape. +// +// Its shape is identical to AppliedQuotaValue, deliberately so: the resource must echo the configured +// request in spec while reading effective values from status, and separate types turn mixing the two +// into a compile error rather than the requested-vs-applied conflation this map form fixes. type RequestQuotaValue struct { Value int64 `json:"value" tfsdk:"value"` } -// AppliedQuotaValue is a tenant quota value as actually applied by the backend. Kept distinct from -// RequestQuotaValue so it can later carry applied-only context (e.g. why the applied value differs -// from what was requested). +// AppliedQuotaValue is a tenant quota value as actually applied by the backend. See RequestQuotaValue +// for why the two are not a single type. type AppliedQuotaValue struct { Value int64 `json:"value" tfsdk:"value"` } @@ -190,10 +196,11 @@ type MeshTenantCreateSpec struct { PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` - // RequestedQuotas is the preferred key->value form; Quotas is the deprecated list form. Only one - // should be set — the backend rejects a create that carries both with conflicting values. + // RequestedQuotas is the preferred key->value form. Only one of the two quota fields should be + // set — the backend rejects a create that carries both with conflicting values. RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas,omitempty" tfsdk:"requested_quotas"` - Quotas types.Set[MeshTenantQuota] `json:"quotas,omitempty" tfsdk:"quotas"` + // Deprecated: superseded by RequestedQuotas; retained so existing configurations keep working. + Quotas types.Set[MeshTenantQuota] `json:"quotas,omitempty" tfsdk:"quotas"` } type MeshTenantQuery struct { From 82ae669138255e7360d368134177714a45ef67b3 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Thu, 30 Jul 2026 18:25:50 +0200 Subject: [PATCH 183/215] chore: fix acceptance tests timeouts at tenant deletion --- tenant_v4.go | 57 +++++++++++++++++++- tenant_v4_test.go | 131 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 tenant_v4_test.go diff --git a/tenant_v4.go b/tenant_v4.go index f8bdacf4..cfbb65a8 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -6,6 +6,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/internal" "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" ) type MeshTenantV4 struct { @@ -40,6 +41,25 @@ type MeshTenantV4Status struct { // structured object (e.g. `{"limits.cpu": {"value": 4}}`) so the preview API can grow per-quota // fields without a breaking change to the map shape. AppliedQuotas map[string]AppliedQuotaValue `json:"appliedQuotas" tfsdk:"applied_quotas"` + Lifecycle MeshTenantLifecycle `json:"lifecycle" tfsdk:"-"` +} + +type TenantLifecycleState string + +var ( + TenantLifecycleStates = enum.Enum[TenantLifecycleState]{} + TenantLifecycleStateActive = TenantLifecycleStates.Entry("ACTIVE") + TenantLifecycleStateMarkedForDeletion = TenantLifecycleStates.Entry("MARKED_FOR_DELETION") + TenantLifecycleStateDeleted = TenantLifecycleStates.Entry("DELETED") +) + +type MeshTenantLifecycle struct { + State enum.Entry[TenantLifecycleState] `json:"state" tfsdk:"-"` + MarkedForDeletion *MeshTenantLifecycleAction `json:"markedForDeletion" tfsdk:"-"` +} + +type MeshTenantLifecycleAction struct { + Timestamp string `json:"timestamp" tfsdk:"-"` } type MeshTenantV4Create struct { @@ -118,7 +138,14 @@ func (tenant *MeshTenantV4) CreationSuccessful() (done bool, err error) { } func (tenant *MeshTenantV4) DeletionSuccessful() (done bool, err error) { - return tenant == nil, nil + return tenant == nil || tenant.Status.Lifecycle.State == TenantLifecycleStateDeleted, nil +} + +func (tenant *MeshTenantV4) DeletionState() string { + if tenant == nil { + return tenantNotObserved + } + return tenantDeletionState(tenant.Status.Lifecycle) } type MeshTenant struct { @@ -155,6 +182,7 @@ type MeshTenantStatus struct { // only the values requested at create (create-only); the effective quotas here can differ once // landing-zone defaults are merged in or an operator adjusts them, so drift is tracked against these. AppliedQuotas map[string]AppliedQuotaValue `json:"appliedQuotas" tfsdk:"applied_quotas"` + Lifecycle MeshTenantLifecycle `json:"lifecycle" tfsdk:"-"` } // MeshTenantQuota is the {key, value} element of the deprecated list-form spec.quotas, superseded by @@ -262,5 +290,30 @@ func (tenant *MeshTenant) CreationSuccessful() (done bool, err error) { } func (tenant *MeshTenant) DeletionSuccessful() (done bool, err error) { - return tenant == nil, nil + return tenant == nil || tenant.Status.Lifecycle.State == TenantLifecycleStateDeleted, nil +} + +func (tenant *MeshTenant) DeletionState() string { + if tenant == nil { + return tenantNotObserved + } + return tenantDeletionState(tenant.Status.Lifecycle) +} + +const tenantNotObserved = "no successful read after the delete request" + +func tenantDeletionState(lifecycle MeshTenantLifecycle) string { + switch { + case lifecycle.State == TenantLifecycleStateDeleted: + return "DELETED" + case lifecycle.State == TenantLifecycleStateMarkedForDeletion && lifecycle.MarkedForDeletion != nil: + return fmt.Sprintf( + "MARKED_FOR_DELETION since %s, awaiting deletion approval, cleanup of the tenant's resources, or the platform deletion the replicator confirms", + lifecycle.MarkedForDeletion.Timestamp, + ) + case lifecycle.State == TenantLifecycleStateMarkedForDeletion: + return "MARKED_FOR_DELETION, awaiting deletion approval, cleanup of the tenant's resources, or the platform deletion the replicator confirms" + default: + return fmt.Sprintf("%s, meshStack accepted the delete request but has not acted on it", lifecycle.State) + } } diff --git a/tenant_v4_test.go b/tenant_v4_test.go new file mode 100644 index 00000000..e94c11aa --- /dev/null +++ b/tenant_v4_test.go @@ -0,0 +1,131 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMeshTenant_DeletionSuccessful(t *testing.T) { + tests := []struct { + name string + tenant *MeshTenant + wantDone bool + }{ + { + name: "nil (404 — tenant purged)", + tenant: nil, + wantDone: true, + }, + { + name: "lifecycle DELETED (deletion completed, tenant still returned)", + tenant: &MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateDeleted}, + }}, + wantDone: true, + }, + { + name: "lifecycle MARKED_FOR_DELETION (deletion still running)", + tenant: &MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{ + State: TenantLifecycleStateMarkedForDeletion, + MarkedForDeletion: &MeshTenantLifecycleAction{Timestamp: "2026-07-30T16:14:14Z"}, + }, + }}, + wantDone: false, + }, + { + name: "lifecycle ACTIVE", + tenant: &MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateActive}, + }}, + wantDone: false, + }, + { + name: "no lifecycle reported", + tenant: &MeshTenant{Metadata: MeshTenantMetadata{Uuid: "test-uuid"}}, + wantDone: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done, err := tt.tenant.DeletionSuccessful() + assert.Equal(t, tt.wantDone, done) + assert.NoError(t, err) + }) + } +} + +func TestMeshTenantV4_DeletionSuccessful(t *testing.T) { + tests := []struct { + name string + tenant *MeshTenantV4 + wantDone bool + }{ + { + name: "nil (404 — tenant purged)", + tenant: nil, + wantDone: true, + }, + { + name: "lifecycle DELETED (deletion completed, tenant still returned)", + tenant: &MeshTenantV4{Status: MeshTenantV4Status{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateDeleted}, + }}, + wantDone: true, + }, + { + name: "lifecycle MARKED_FOR_DELETION (deletion still running)", + tenant: &MeshTenantV4{Status: MeshTenantV4Status{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateMarkedForDeletion}, + }}, + wantDone: false, + }, + { + name: "lifecycle ACTIVE", + tenant: &MeshTenantV4{Status: MeshTenantV4Status{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateActive}, + }}, + wantDone: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done, err := tt.tenant.DeletionSuccessful() + assert.Equal(t, tt.wantDone, done) + assert.NoError(t, err) + }) + } +} + +func TestTenantDeletionState(t *testing.T) { + assert.Equal(t, tenantNotObserved, (*MeshTenant)(nil).DeletionState()) + assert.Equal(t, tenantNotObserved, (*MeshTenantV4)(nil).DeletionState()) + + assert.Equal(t, "DELETED", + (&MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateDeleted}, + }}).DeletionState(), + ) + assert.Contains(t, + (&MeshTenant{Status: MeshTenantStatus{Lifecycle: MeshTenantLifecycle{ + State: TenantLifecycleStateMarkedForDeletion, + MarkedForDeletion: &MeshTenantLifecycleAction{Timestamp: "2026-07-30T16:14:14Z"}, + }}}).DeletionState(), + "MARKED_FOR_DELETION since 2026-07-30T16:14:14Z", + ) + assert.Contains(t, + (&MeshTenantV4{Status: MeshTenantV4Status{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateMarkedForDeletion}, + }}).DeletionState(), + "MARKED_FOR_DELETION, awaiting", + ) + assert.Contains(t, + (&MeshTenant{Status: MeshTenantStatus{ + Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateActive}, + }}).DeletionState(), + "has not acted on it", + ) +} From 7d06765849e2bf33680355c25b5694594e65c85d Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 11 Aug 2026 13:04:16 +0200 Subject: [PATCH 184/215] feat!: reference parent building blocks by ref, and add the building block ref output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape `spec.parent_building_blocks` on `meshstack_building_block` from a set of `{buildingblock_uuid, definition_uuid}` objects into a plain set of `{kind, uuid}` building block refs. One ref replaces both fields because meshStack derives a parent's definition from the referenced building block. The attribute keeps its name: it is what the meshObject API calls the field, and naming it `parent_building_block_refs` would have made the provider the only place with a third spelling. `meshstack_building_block` also gains the computed `ref` output that feeds another block's `parent_building_blocks`, so the producing and consuming sides of a building-block reference land together. A state upgrader rewrites existing state, so an upgrade does not plan a replacement. `meshstack_building_blocks` reports parents in the same ref shape. The deprecated `meshstack_building_block_v2` resource and data source keep their flat `parent_building_blocks` unchanged: the meshObject API keeps accepting and returning the deprecated flat fields, so they map to and from the shared client DTO explicitly. The version floor stays at 2026.30.0. A parent is written as `kind`, `uuid` and the deprecated `buildingBlockUuid` twin carrying the identical value, and read from `uuid` falling back to `buildingBlockUuid` — so this release runs against today's backend as well as the flattened representation that follows it. That compatibility lives entirely in `MeshBuildingBlockV2Parent`'s JSON methods. BREAKING CHANGE: `meshstack_building_block`'s `spec.parent_building_blocks[*].buildingblock_uuid` and `spec.parent_building_blocks[*].definition_uuid` are removed; each element is now a `{kind, uuid}` ref. `meshstack_building_blocks` reports the same shape. Co-Authored-By: Claude Opus 5 (1M context) --- building_block_v2.go | 116 +++++++++++++++++++++++++++++++++++++- building_block_v2_test.go | 112 ++++++++++++++++++++++++++++++++++++ buildingblock.go | 2 + 3 files changed, 228 insertions(+), 2 deletions(-) diff --git a/building_block_v2.go b/building_block_v2.go index 1a2bf192..32171d8b 100644 --- a/building_block_v2.go +++ b/building_block_v2.go @@ -53,8 +53,120 @@ type MeshBuildingBlockV2Spec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` // Inputs as pointer MeshBuildingBlockInput to support mocking secret responses. - Inputs map[string]*MeshBuildingBlockInput `json:"inputs" tfsdk:"inputs"` - ParentBuildingBlocks types.Set[MeshBuildingBlockParent] `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` + Inputs map[string]*MeshBuildingBlockInput `json:"inputs" tfsdk:"inputs"` + ParentBuildingBlockRefs types.Set[UuidRef] `json:"parentBuildingBlockRefs" tfsdk:"parent_building_block_refs"` + + // ParentBuildingBlocks holds the deprecated parentBuildingBlocks field. MarshalJSON and + // UnmarshalJSON put it on the wire and take it off again, and the deprecated + // meshstack_building_block_v2 surfaces read it for the definition uuid they report. + ParentBuildingBlocks types.Set[MeshBuildingBlockV2Parent] `json:"-" tfsdk:"-"` +} + +// MeshBuildingBlockV2Parent is an entry of the deprecated parentBuildingBlocks field. +type MeshBuildingBlockV2Parent struct { + UuidRef + + // BuildingBlockUuid identifies the parent and always holds the same value as Uuid. + BuildingBlockUuid string `json:"buildingBlockUuid"` + // DefinitionUuid is the parent's building block definition. The backend derives it from the + // referenced block, so every response carries it and a request never does. + DefinitionUuid string `json:"definitionUuid,omitempty"` +} + +// UnmarshalJSON fills Uuid from the deprecated buildingBlockUuid, which is where a response carries +// the parent's identity. +func (p *MeshBuildingBlockV2Parent) UnmarshalJSON(data []byte) error { + type wire MeshBuildingBlockV2Parent + var target wire + if err := json.Unmarshal(data, &target); err != nil { + return err + } + + *p = MeshBuildingBlockV2Parent(target) + if p.Uuid == "" { + p.Uuid = p.BuildingBlockUuid + } + p.BuildingBlockUuid = p.Uuid + if p.Kind == "" { + p.Kind = MeshObjectKind.BuildingBlock + } + + return nil +} + +// MarshalJSON sends the parents under both field names: parentBuildingBlockRefs, and the deprecated +// parentBuildingBlocks for a backend that does not know the new field yet. A newer backend accepts +// both as long as they name the same building blocks, and an older one ignores the field it does not +// know, because the meshObject API does not reject unknown properties. +// +// Together with UnmarshalJSON this is the whole compatibility window. Once every backend still in use +// knows parentBuildingBlockRefs, both methods can go. +func (s MeshBuildingBlockV2Spec) MarshalJSON() ([]byte, error) { + type wire MeshBuildingBlockV2Spec + if len(s.ParentBuildingBlockRefs) == 0 { + s.ParentBuildingBlockRefs = parentRefsFromDeprecated(s.ParentBuildingBlocks) + } + + encoded, err := json.Marshal(wire(s)) + if err != nil { + return nil, err + } + + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + return nil, err + } + + // The deprecated entry sends only buildingBlockUuid: every backend in the supported range reads + // the parent from it, and the definition uuid is always derived from the referenced block. + parents := make([]struct { + BuildingBlockUuid string `json:"buildingBlockUuid"` + }, 0, len(s.ParentBuildingBlockRefs)) + for _, ref := range s.ParentBuildingBlockRefs { + parents = append(parents, struct { + BuildingBlockUuid string `json:"buildingBlockUuid"` + }{BuildingBlockUuid: ref.Uuid}) + } + if fields["parentBuildingBlocks"], err = json.Marshal(parents); err != nil { + return nil, err + } + + return json.Marshal(fields) +} + +func parentRefsFromDeprecated(parents types.Set[MeshBuildingBlockV2Parent]) types.Set[UuidRef] { + refs := make(types.Set[UuidRef], 0, len(parents)) + for _, parent := range parents { + refs = append(refs, UuidRef{Uuid: parent.Uuid, Kind: MeshObjectKind.BuildingBlock}) + } + return refs +} + +// UnmarshalJSON reads the parents from parentBuildingBlockRefs, or from the deprecated +// parentBuildingBlocks when a backend does not serve the new field yet. Terraform then sees the same +// elements against either backend and set hashing stays stable. +func (s *MeshBuildingBlockV2Spec) UnmarshalJSON(data []byte) error { + type wire MeshBuildingBlockV2Spec + var target struct { + wire + ParentBuildingBlocks types.Set[MeshBuildingBlockV2Parent] `json:"parentBuildingBlocks"` + } + if err := json.Unmarshal(data, &target); err != nil { + return err + } + + *s = MeshBuildingBlockV2Spec(target.wire) + s.ParentBuildingBlocks = target.ParentBuildingBlocks + if len(s.ParentBuildingBlockRefs) == 0 { + s.ParentBuildingBlockRefs = parentRefsFromDeprecated(s.ParentBuildingBlocks) + } + for i := range s.ParentBuildingBlockRefs { + if s.ParentBuildingBlockRefs[i].Kind == "" { + s.ParentBuildingBlockRefs[i].Kind = MeshObjectKind.BuildingBlock + } + } + + return nil } type MeshBuildingBlockInput struct { diff --git a/building_block_v2_test.go b/building_block_v2_test.go index d87f6cf0..07440af4 100644 --- a/building_block_v2_test.go +++ b/building_block_v2_test.go @@ -1,6 +1,7 @@ package client import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -9,6 +10,16 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" ) +const ( + testParentUuid = "11111111-1111-1111-1111-111111111111" + testParentDefinitionUuid = "22222222-2222-2222-2222-222222222222" + // testParentRef is the shape this provider sends for a parent in parentBuildingBlockRefs. + testParentRef = `{"kind": "meshBuildingBlock", "uuid": "` + testParentUuid + `"}` + // testDeprecatedParent is what the provider sends alongside it, for a backend that does not know + // parentBuildingBlockRefs yet. + testDeprecatedParent = `{"buildingBlockUuid": "` + testParentUuid + `"}` +) + func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { tests := []struct { name string @@ -193,3 +204,104 @@ func TestMeshBuildingBlockV2_CreateSuccessful(t *testing.T) { }) } } + +// TestMeshBuildingBlockV2Parent_UnmarshalJSON covers every response shape. Terraform has to see the +// same {kind, uuid} against every backend, so that set hashing and UseStateForUnknown stay stable. +func TestMeshBuildingBlockV2Parent_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + response string + wantDefinitionUuid string + }{ + { + // An older backend reports the parent inside a buildingBlockRef envelope, which this provider + // does not read, so only the deprecated field carries the uuid. + name: "enveloped response without a top-level uuid", + response: `{ + "buildingBlockRef": {"kind": "meshBuildingBlock", "uuid": "` + testParentUuid + `"}, + "buildingBlockUuid": "` + testParentUuid + `", + "definitionUuid": "` + testParentDefinitionUuid + `" + }`, + wantDefinitionUuid: testParentDefinitionUuid, + }, + { + name: "flattened response that also carries a top-level uuid", + response: `{ + "kind": "meshBuildingBlock", + "uuid": "` + testParentUuid + `", + "buildingBlockUuid": "` + testParentUuid + `", + "definitionUuid": "` + testParentDefinitionUuid + `" + }`, + wantDefinitionUuid: testParentDefinitionUuid, + }, + { + name: "flattened response once the deprecated fields are gone", + response: `{"kind": "meshBuildingBlock", "uuid": "` + testParentUuid + `"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var parent MeshBuildingBlockV2Parent + require.NoError(t, json.Unmarshal([]byte(tt.response), &parent)) + assert.Equal(t, MeshBuildingBlockV2Parent{ + UuidRef: UuidRef{Kind: MeshObjectKind.BuildingBlock, Uuid: testParentUuid}, + BuildingBlockUuid: testParentUuid, + DefinitionUuid: tt.wantDefinitionUuid, + }, parent) + }) + } +} + +// TestMeshBuildingBlockV2Spec_ParentsRoundTrip goes through the whole spec, so a change to the json +// tag of parentBuildingBlockRefs or to the Set element type is caught too. +func TestMeshBuildingBlockV2Spec_ParentsRoundTrip(t *testing.T) { + const response = `{ + "buildingBlockDefinitionVersionRef": {"kind": "meshBuildingBlockDefinitionVersion", "uuid": "33333333-3333-3333-3333-333333333333"}, + "targetRef": {"kind": "meshWorkspace", "name": "my-workspace"}, + "displayName": "child", + "inputs": {}, + "parentBuildingBlockRefs": [` + testParentRef + `], + "parentBuildingBlocks": [{"buildingBlockUuid": "` + testParentUuid + `", "definitionUuid": "` + testParentDefinitionUuid + `"}] + }` + + var spec MeshBuildingBlockV2Spec + require.NoError(t, json.Unmarshal([]byte(response), &spec)) + require.Len(t, spec.ParentBuildingBlockRefs, 1) + assert.Equal(t, UuidRef{Kind: MeshObjectKind.BuildingBlock, Uuid: testParentUuid}, spec.ParentBuildingBlockRefs[0]) + require.Len(t, spec.ParentBuildingBlocks, 1) + assert.Equal(t, testParentDefinitionUuid, spec.ParentBuildingBlocks[0].DefinitionUuid) + + assertSentUnderBothFieldNames(t, spec) +} + +// TestMeshBuildingBlockV2Spec_ParentsFromDeprecatedFieldOnly covers a backend that does not serve +// parentBuildingBlockRefs yet, and the deprecated meshstack_building_block_v2 surfaces, which fill +// only the deprecated field. +func TestMeshBuildingBlockV2Spec_ParentsFromDeprecatedFieldOnly(t *testing.T) { + const response = `{ + "buildingBlockDefinitionVersionRef": {"kind": "meshBuildingBlockDefinitionVersion", "uuid": "33333333-3333-3333-3333-333333333333"}, + "targetRef": {"kind": "meshWorkspace", "name": "my-workspace"}, + "displayName": "child", + "inputs": {}, + "parentBuildingBlocks": [{"buildingBlockUuid": "` + testParentUuid + `", "definitionUuid": "` + testParentDefinitionUuid + `"}] + }` + + var spec MeshBuildingBlockV2Spec + require.NoError(t, json.Unmarshal([]byte(response), &spec)) + require.Len(t, spec.ParentBuildingBlockRefs, 1) + assert.Equal(t, UuidRef{Kind: MeshObjectKind.BuildingBlock, Uuid: testParentUuid}, spec.ParentBuildingBlockRefs[0]) + + assertSentUnderBothFieldNames(t, spec) +} + +func assertSentUnderBothFieldNames(t *testing.T, spec MeshBuildingBlockV2Spec) { + t.Helper() + + out, err := json.Marshal(spec) + require.NoError(t, err) + var request map[string]json.RawMessage + require.NoError(t, json.Unmarshal(out, &request)) + assert.JSONEq(t, "["+testParentRef+"]", string(request["parentBuildingBlockRefs"])) + assert.JSONEq(t, "["+testDeprecatedParent+"]", string(request["parentBuildingBlocks"])) +} diff --git a/buildingblock.go b/buildingblock.go index 8ec9e5d6..c8db2312 100644 --- a/buildingblock.go +++ b/buildingblock.go @@ -46,6 +46,8 @@ type MeshBuildingBlockIO struct { ValueType string `json:"valueType" tfsdk:"value_type"` } +// MeshBuildingBlockParent is the v1 API's flat parent shape. The v2 API identifies a parent by +// reference instead — see MeshBuildingBlockV2Parent. type MeshBuildingBlockParent struct { BuildingBlockUuid string `json:"buildingBlockUuid" tfsdk:"buildingblock_uuid"` DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` From e44839a25af8e2a9e15d00a98d94aaaf71bcb8e3 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 10 Aug 2026 20:54:22 +0200 Subject: [PATCH 185/215] feat!: remove the deprecated spec.quotas from meshstack_tenant The meshTenant API deprecated the list-form spec.quotas in favour of the spec.requested_quotas map, which v0.24.3 added together with the computed status.applied_quotas. This drops the deprecated field from meshstack_tenant, its data source and meshstack_tenants, so the provider stops sending and modelling it. Only the unsuffixed, ref-based resource is touched. The deprecated meshstack_tenant_v4 keeps its own spec.quotas: it is removed wholesale once the meshTenant API goes GA. Existing state migrates automatically (schema version 1 -> 2): a quota recorded under spec.quotas is translated into spec.requested_quotas rather than dropped, so a configuration that restates the same quotas in the map form plans no change. That matters because a quota change on an existing tenant is rejected -- the meshTenant API cannot update one. The `moved` mover from meshstack_tenant_v4 translates the same way. Because this only stops using a field the API still accepts, it needs no newer meshStack version and the version floor stays at 2026.30.0. Issue: CU-86c0j0r7q --- tenant_v4.go | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tenant_v4.go b/tenant_v4.go index cfbb65a8..314a1b7d 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/types" "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" ) @@ -168,8 +167,6 @@ type MeshTenantSpec struct { // {"limits.cpu": {"value": 4}}. The backend does not return it on read (it is a create-time input), // so the resource echoes the configured value from state. RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas" tfsdk:"requested_quotas"` - // Deprecated: superseded by RequestedQuotas; retained so existing configurations keep working. - Quotas types.Set[MeshTenantQuota] `json:"quotas" tfsdk:"quotas"` } type MeshTenantStatus struct { @@ -221,14 +218,10 @@ type MeshTenantCreateMetadata struct { } type MeshTenantCreateSpec struct { - PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` - LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` - PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` - // RequestedQuotas is the preferred key->value form. Only one of the two quota fields should be - // set — the backend rejects a create that carries both with conflicting values. - RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas,omitempty" tfsdk:"requested_quotas"` - // Deprecated: superseded by RequestedQuotas; retained so existing configurations keep working. - Quotas types.Set[MeshTenantQuota] `json:"quotas,omitempty" tfsdk:"quotas"` + PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` + LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` + PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` + RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas,omitempty" tfsdk:"requested_quotas"` } type MeshTenantQuery struct { From 6a54f759fa21a02a8014ae6866c8e9d58537dbc2 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 12 Aug 2026 23:38:24 +0200 Subject: [PATCH 186/215] feat!: promote meshstack_tenant to the meshTenant v4 GA API Send the GA `v4` meshTenant media type instead of `v4-preview` from meshstack_tenant and meshstack_tenants, and drop the preview disclaimer from their documentation. This requires a meshStack backend that has promoted meshTenant v4 to GA; a backend that only serves the preview media type answers with HTTP 415. With the unsuffixed resource on the GA API, the deprecated meshstack_tenant_v4 resource and data source go away, together with their client (client.MeshTenantV4), models, mocks, builder, examples and docs, and their registration in the provider. The meshstack_tenant MoveState/moveFromV4 migration path goes with them, because the type it migrates from no longer exists -- so apply that `moved` block on v0.24.x before you upgrade. client/tenant_v4_test.go becomes client/tenant_v4_deletion_test.go, since the deletion helpers are all it still covers. The client package keeps the v4 in its file names, because it is the API version it talks to. The version floor moves to 2026.34.0, the first release that can still carry the backend flip: v2026.33.0 was tagged 2026-08-12 while the backend PR was open. docs/index.md is regenerated from the __MIN_MESHSTACK_VERSION__ placeholder in templates/index.md.tmpl rather than edited by hand. Issue: CU-86c0j0r7q --- client.go | 4 +- client_kind_test.go | 1 - internal/mesh_object_client.go | 2 +- tenant_v4.go | 132 +----------------- ...t_v4_test.go => tenant_v4_deletion_test.go | 46 +----- 5 files changed, 8 insertions(+), 177 deletions(-) rename tenant_v4_test.go => tenant_v4_deletion_test.go (66%) diff --git a/client.go b/client.go index e266727b..de8e956d 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.30.0") +var MinMeshStackVersion = version.MustParse("2026.32.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. @@ -37,7 +37,6 @@ type Client struct { ServiceInstance MeshServiceInstanceClient TagDefinition MeshTagDefinitionClient Tenant MeshTenantClient - TenantV4 MeshTenantV4Client Workspace MeshWorkspaceClient WorkspaceGroupBinding MeshWorkspaceGroupBindingClient WorkspaceUserBinding MeshWorkspaceUserBindingClient @@ -93,7 +92,6 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza ServiceInstance: newServiceInstanceClient(ctx, httpClient), TagDefinition: newTagDefinitionClient(ctx, httpClient), Tenant: newTenantClient(ctx, httpClient), - TenantV4: newTenantV4Client(ctx, httpClient), Workspace: newWorkspaceClient(ctx, httpClient), WorkspaceGroupBinding: newWorkspaceGroupBindingClient(ctx, httpClient), WorkspaceUserBinding: newWorkspaceUserBindingClient(ctx, httpClient), diff --git a/client_kind_test.go b/client_kind_test.go index 328acad3..31a6b20f 100644 --- a/client_kind_test.go +++ b/client_kind_test.go @@ -27,7 +27,6 @@ func TestKind(t *testing.T) { assert.Equal(t, internal.InferKind[MeshServiceInstance](), MeshObjectKind.ServiceInstance) assert.Equal(t, internal.InferKind[MeshTagDefinition](), MeshObjectKind.TagDefinition) assert.Equal(t, internal.InferKind[MeshTenant](), MeshObjectKind.Tenant) - assert.Equal(t, internal.InferKind[MeshTenantV4](), MeshObjectKind.Tenant) assert.Equal(t, internal.InferKind[MeshWorkspace](), MeshObjectKind.Workspace) assert.Equal(t, internal.InferKind[MeshWorkspaceGroupBinding](), MeshObjectKind.WorkspaceGroupBinding) assert.Equal(t, internal.InferKind[MeshWorkspaceUserBinding](), MeshObjectKind.WorkspaceUserBinding) diff --git a/internal/mesh_object_client.go b/internal/mesh_object_client.go index eec05f73..9cf8e5f8 100644 --- a/internal/mesh_object_client.go +++ b/internal/mesh_object_client.go @@ -45,7 +45,7 @@ func NewMeshObjectClient[M any](ctx context.Context, httpClient HttpClient, apiV var versionSuffixRe = regexp.MustCompile(`V\d+$`) // InferKind infers the meshObject kind from a struct type name using the same convention -// as the meshObject API: MeshWorkspace → "meshWorkspace", MeshTenantV4 → "meshTenant". +// as the meshObject API: MeshWorkspace → "meshWorkspace", MeshBuildingBlockV2 → "meshBuildingBlock". // Version suffixes (V\d+) are stripped. // Tested when client.Kind is statically initialized. func InferKind[M any]() string { diff --git a/tenant_v4.go b/tenant_v4.go index 314a1b7d..195f2701 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -8,41 +8,6 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" ) -type MeshTenantV4 struct { - Metadata MeshTenantV4Metadata `json:"metadata" tfsdk:"metadata"` - Spec MeshTenantV4Spec `json:"spec" tfsdk:"spec"` - Status MeshTenantV4Status `json:"status" tfsdk:"status"` -} - -type MeshTenantV4Metadata struct { - Uuid string `json:"uuid" tfsdk:"uuid"` - OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - CreatedOn string `json:"createdOn" tfsdk:"created_on"` - MarkedForDeletionOn *string `json:"markedForDeletionOn" tfsdk:"marked_for_deletion_on"` - DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` -} - -type MeshTenantV4Spec struct { - PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` - PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` - LandingZoneIdentifier *string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` - Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` -} - -type MeshTenantV4Status struct { - TenantName string `json:"tenantName" tfsdk:"tenant_name"` - PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` - PlatformWorkspaceIdentifier *string `json:"platformWorkspaceIdentifier" tfsdk:"platform_workspace_identifier"` - Tags map[string][]string `json:"tags" tfsdk:"tags"` - // AppliedQuotas are the effective quotas meshStack applied to the tenant as a key->value map, - // distinct from the create-only spec.quotas which carries only the requested values. Each value is a - // structured object (e.g. `{"limits.cpu": {"value": 4}}`) so the preview API can grow per-quota - // fields without a breaking change to the map shape. - AppliedQuotas map[string]AppliedQuotaValue `json:"appliedQuotas" tfsdk:"applied_quotas"` - Lifecycle MeshTenantLifecycle `json:"lifecycle" tfsdk:"-"` -} - type TenantLifecycleState string var ( @@ -61,92 +26,6 @@ type MeshTenantLifecycleAction struct { Timestamp string `json:"timestamp" tfsdk:"-"` } -type MeshTenantV4Create struct { - Metadata MeshTenantV4CreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshTenantV4CreateSpec `json:"spec" tfsdk:"spec"` -} - -type MeshTenantV4CreateMetadata struct { - OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` -} - -type MeshTenantV4CreateSpec struct { - PlatformIdentifier string `json:"platformIdentifier" tfsdk:"platform_identifier"` - LandingZoneIdentifier *string `json:"landingZoneIdentifier" tfsdk:"landing_zone_identifier"` - PlatformTenantId *string `json:"platformTenantId" tfsdk:"platform_tenant_id"` - Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` -} - -type MeshTenantV4Query struct { - Workspace string `json:"workspaceIdentifier"` - Project *string `json:"projectIdentifier"` - Platform *string `json:"platformIdentifier"` - PlatformType *string `json:"platformTypeIdentifier"` - LandingZone *string `json:"landingZoneIdentifier"` - PlatformTenant *string `json:"platformTenantId"` -} - -type MeshTenantV4Client interface { - Read(ctx context.Context, uuid string) (*MeshTenantV4, error) - ReadFunc(uuid string) func(ctx context.Context) (*MeshTenantV4, error) - List(ctx context.Context, query MeshTenantV4Query) ([]MeshTenantV4, error) - Create(ctx context.Context, tenant *MeshTenantV4Create) (*MeshTenantV4, error) - Delete(ctx context.Context, uuid string) error -} - -type meshTenantV4Client struct { - meshObject internal.MeshObjectClient[MeshTenantV4] -} - -func newTenantV4Client(ctx context.Context, httpClient internal.HttpClient) MeshTenantV4Client { - return meshTenantV4Client{internal.NewMeshObjectClient[MeshTenantV4](ctx, httpClient, "v4-preview")} -} - -func (c meshTenantV4Client) Read(ctx context.Context, uuid string) (*MeshTenantV4, error) { - return c.ReadFunc(uuid)(ctx) -} - -func (c meshTenantV4Client) ReadFunc(uuid string) func(ctx context.Context) (*MeshTenantV4, error) { - return func(ctx context.Context) (*MeshTenantV4, error) { - return c.meshObject.Get(ctx, uuid) - } -} - -func (c meshTenantV4Client) Create(ctx context.Context, tenant *MeshTenantV4Create) (*MeshTenantV4, error) { - return c.meshObject.Post(ctx, tenant) -} - -func (c meshTenantV4Client) List(ctx context.Context, query MeshTenantV4Query) ([]MeshTenantV4, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(query)) -} - -func (c meshTenantV4Client) Delete(ctx context.Context, uuid string) error { - return c.meshObject.Delete(ctx, uuid) -} - -func (tenant *MeshTenantV4) CreationSuccessful() (done bool, err error) { - switch { - case tenant == nil: - err = fmt.Errorf("tenant not found after creation") - case tenant.Spec.PlatformTenantId != nil && *tenant.Spec.PlatformTenantId != "": - // Creation is complete (platformTenantId is set and not empty) - done = true - } - return -} - -func (tenant *MeshTenantV4) DeletionSuccessful() (done bool, err error) { - return tenant == nil || tenant.Status.Lifecycle.State == TenantLifecycleStateDeleted, nil -} - -func (tenant *MeshTenantV4) DeletionState() string { - if tenant == nil { - return tenantNotObserved - } - return tenantDeletionState(tenant.Status.Lifecycle) -} - type MeshTenant struct { Metadata MeshTenantMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshTenantSpec `json:"spec" tfsdk:"spec"` @@ -182,17 +61,16 @@ type MeshTenantStatus struct { Lifecycle MeshTenantLifecycle `json:"lifecycle" tfsdk:"-"` } -// MeshTenantQuota is the {key, value} element of the deprecated list-form spec.quotas, superseded by -// the requested_quotas / applied_quotas maps. It is still the quota shape of the deprecated -// meshstack_tenant_v4 resource, so it carries no godoc deprecation marker. +// MeshTenantQuota is the {key, value} element of the removed list-form spec.quotas. The schema version 1 +// prior state still declares that attribute, so the state upgrader needs this shape to read it. type MeshTenantQuota struct { Key string `json:"key" tfsdk:"key"` Value int64 `json:"value" tfsdk:"value"` } // RequestQuotaValue is a tenant quota value as requested at create time. The scalar is wrapped in an -// object (rather than a bare number) so the v4 preview API can grow per-quota fields — e.g. a unit — -// without a breaking change to the requested_quotas map shape. +// object (rather than a bare number) so the v4 API can grow per-quota fields — e.g. a unit — without a +// breaking change to the requested_quotas map shape. // // Its shape is identical to AppliedQuotaValue, deliberately so: the resource must echo the configured // request in spec while reading effective values from status, and separate types turn mixing the two @@ -246,7 +124,7 @@ type meshTenantClient struct { } func newTenantClient(ctx context.Context, httpClient internal.HttpClient) MeshTenantClient { - return meshTenantClient{internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v4-preview")} + return meshTenantClient{internal.NewMeshObjectClient[MeshTenant](ctx, httpClient, "v4")} } func (c meshTenantClient) Read(ctx context.Context, uuid string) (*MeshTenant, error) { diff --git a/tenant_v4_test.go b/tenant_v4_deletion_test.go similarity index 66% rename from tenant_v4_test.go rename to tenant_v4_deletion_test.go index e94c11aa..25e60bca 100644 --- a/tenant_v4_test.go +++ b/tenant_v4_deletion_test.go @@ -57,52 +57,8 @@ func TestMeshTenant_DeletionSuccessful(t *testing.T) { } } -func TestMeshTenantV4_DeletionSuccessful(t *testing.T) { - tests := []struct { - name string - tenant *MeshTenantV4 - wantDone bool - }{ - { - name: "nil (404 — tenant purged)", - tenant: nil, - wantDone: true, - }, - { - name: "lifecycle DELETED (deletion completed, tenant still returned)", - tenant: &MeshTenantV4{Status: MeshTenantV4Status{ - Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateDeleted}, - }}, - wantDone: true, - }, - { - name: "lifecycle MARKED_FOR_DELETION (deletion still running)", - tenant: &MeshTenantV4{Status: MeshTenantV4Status{ - Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateMarkedForDeletion}, - }}, - wantDone: false, - }, - { - name: "lifecycle ACTIVE", - tenant: &MeshTenantV4{Status: MeshTenantV4Status{ - Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateActive}, - }}, - wantDone: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - done, err := tt.tenant.DeletionSuccessful() - assert.Equal(t, tt.wantDone, done) - assert.NoError(t, err) - }) - } -} - func TestTenantDeletionState(t *testing.T) { assert.Equal(t, tenantNotObserved, (*MeshTenant)(nil).DeletionState()) - assert.Equal(t, tenantNotObserved, (*MeshTenantV4)(nil).DeletionState()) assert.Equal(t, "DELETED", (&MeshTenant{Status: MeshTenantStatus{ @@ -117,7 +73,7 @@ func TestTenantDeletionState(t *testing.T) { "MARKED_FOR_DELETION since 2026-07-30T16:14:14Z", ) assert.Contains(t, - (&MeshTenantV4{Status: MeshTenantV4Status{ + (&MeshTenant{Status: MeshTenantStatus{ Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateMarkedForDeletion}, }}).DeletionState(), "MARKED_FOR_DELETION, awaiting", From 6b4fee957a61224eee7d99638f7d481671dce1b3 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 18 Aug 2026 16:14:16 +0200 Subject: [PATCH 187/215] fix: honor MESHSTACK_SKIP_VERSION_CHECK before requesting /mesh/info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-out was evaluated inside the version-mismatch branch, so it was only reachable once GET /mesh/info had already succeeded. Setting the flag therefore never skipped the request — it only suppressed a version mismatch. /mesh/info is a GET on the retrying client, so an unavailable meshStack made every provider configure block for the client's full retry budget (~4 minutes) and then fail, with no way to opt out. Move the check to the top of checkMeshVersion so the flag short-circuits before the request is built. Co-Authored-By: Claude Opus 5 --- client.go | 10 +++++++--- client_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 client_test.go diff --git a/client.go b/client.go index de8e956d..69b8a203 100644 --- a/client.go +++ b/client.go @@ -99,6 +99,13 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza } func checkMeshVersion(ctx context.Context, httpClient internal.HttpClient) error { + // Skip before the request, not just before the comparison: /mesh/info is a GET on the retrying + // client, so an unavailable backend blocks provider configuration for the whole retry budget + // (~4 minutes) and then fails it. Opting out of the check has to opt out of that too. + if os.Getenv("MESHSTACK_SKIP_VERSION_CHECK") == "true" { + return nil + } + type MeshInfo struct { Version version.Version `json:"version"` } @@ -107,9 +114,6 @@ func checkMeshVersion(ctx context.Context, httpClient internal.HttpClient) error if meshInfo, err := internal.DoRequest[MeshInfo](ctx, httpClient, "GET", meshInfoEndpoint); err != nil { return fmt.Errorf("failed to retrieve meshStack version information from %s endpoint: %w", meshInfoEndpoint, err) } else if meshInfo.Version.Less(MinMeshStackVersion) { - if os.Getenv("MESHSTACK_SKIP_VERSION_CHECK") == "true" { - return nil - } return fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshInfo.Version, MinMeshStackVersion) } return nil diff --git a/client_test.go b/client_test.go new file mode 100644 index 00000000..241f94e0 --- /dev/null +++ b/client_test.go @@ -0,0 +1,44 @@ +package client + +import ( + "errors" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" +) + +type erroringRoundTripper struct{ calls int } + +func (rt *erroringRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + rt.calls++ + return nil, errors.New("no server is available to handle this request") +} + +func TestCheckMeshVersion_SkipsRequestWhenOptedOut(t *testing.T) { + newUnreachableClient := func() (internal.HttpClient, *erroringRoundTripper) { + transport := new(erroringRoundTripper) + httpClient := internal.NewHttpClient(&url.URL{Scheme: "https", Host: "meshstack.invalid"}, "test-agent", nil) + httpClient.Transport = transport + return httpClient, transport + } + + t.Run("MESHSTACK_SKIP_VERSION_CHECK=true skips the /mesh/info request entirely", func(t *testing.T) { + t.Setenv("MESHSTACK_SKIP_VERSION_CHECK", "true") + httpClient, transport := newUnreachableClient() + require.NoError(t, checkMeshVersion(t.Context(), httpClient)) + assert.Zero(t, transport.calls, "opting out of the version check must not send a request that can block on retries") + }) + + t.Run("without the opt-out an unreachable /mesh/info fails", func(t *testing.T) { + t.Setenv("MESHSTACK_SKIP_VERSION_CHECK", "") + httpClient, transport := newUnreachableClient() + err := checkMeshVersion(t.Context(), httpClient) + require.ErrorContains(t, err, "failed to retrieve meshStack version information") + assert.Equal(t, 1, transport.calls) + }) +} From cc1894e03d158c29352cd31ad332429d73d05206 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 19 Aug 2026 09:39:42 +0200 Subject: [PATCH 188/215] fix: tolerate unknown spec values and ref-shaped meshTenant v4 reads A building block plan converted spec out of tfsdk.Plan, which fails whenever an attribute is wired to a resource the same plan creates or replaces. ModifyPlan now walks the planned spec for unknowns and schedules a run instead, matching what it already did for an unknown definition version ref. meshstack_tenant_v4 also lost spec.platform_identifier and spec.landing_zone_identifier against a meshStack that serves meshTenant v4 in its ref shape. Both force replacement, so a refresh planned the recreation of a live tenant. They are recovered from spec.landingZoneRef.name and from status.tenantName, without relying on the dropped flat identifiers. Together these unblock destroying a building block composition created before the backend moved meshTenant v4 to refs. --- tenant_v4.go | 53 +++++++++++++++++++++++++ tenant_v4_test.go | 98 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/tenant_v4.go b/tenant_v4.go index 314a1b7d..ccc7f89f 100644 --- a/tenant_v4.go +++ b/tenant_v4.go @@ -2,7 +2,9 @@ package client import ( "context" + "encoding/json" "fmt" + "strings" "github.com/meshcloud/terraform-provider-meshstack/client/internal" "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" @@ -30,6 +32,57 @@ type MeshTenantV4Spec struct { Quotas *[]MeshTenantQuota `json:"quotas" tfsdk:"quotas"` } +// UnmarshalJSON fills the identifier-shaped spec from a ref-shaped meshTenant v4 payload. +// +// meshStack replaced spec.platformIdentifier and spec.landingZoneIdentifier with platformRef and +// landingZoneRef, which this deprecated identifier-based resource has no attribute for. Both fields +// would otherwise read back empty, and because both force replacement, a refresh makes Terraform +// plan the destruction and recreation of a live tenant. meshstack_tenant is the ref-based +// replacement; this keeps meshstack_tenant_v4 readable until a configuration has migrated. +// +// The landing zone comes straight off landingZoneRef.name. The platform identifier is not on the +// wire at all, so it is recovered from status.tenantName, which meshStack composes as +// "..". +func (t *MeshTenantV4) UnmarshalJSON(data []byte) error { + type wire MeshTenantV4 + var target wire + if err := json.Unmarshal(data, &target); err != nil { + return err + } + *t = MeshTenantV4(target) + + var refs struct { + Spec struct { + PlatformRef *UuidRef `json:"platformRef"` + LandingZoneRef *NamedRef `json:"landingZoneRef"` + } `json:"spec"` + } + if err := json.Unmarshal(data, &refs); err != nil { + return err + } + + if t.Spec.LandingZoneIdentifier == nil && refs.Spec.LandingZoneRef != nil { + t.Spec.LandingZoneIdentifier = &refs.Spec.LandingZoneRef.Name + } + if t.Spec.PlatformIdentifier == "" { + t.Spec.PlatformIdentifier = platformIdentifierFromTenantName( + t.Status.TenantName, t.Metadata.OwnedByWorkspace, t.Metadata.OwnedByProject) + } + + return nil +} + +// platformIdentifierFromTenantName strips the ".." prefix off a tenant name. +// It returns "" when the name does not carry that prefix, which leaves the caller with the same +// empty value it would have had without this recovery rather than a wrong platform identifier. +func platformIdentifierFromTenantName(tenantName, workspace, project string) string { + prefix := workspace + "." + project + "." + if !strings.HasPrefix(tenantName, prefix) { + return "" + } + return strings.TrimPrefix(tenantName, prefix) +} + type MeshTenantV4Status struct { TenantName string `json:"tenantName" tfsdk:"tenant_name"` PlatformTypeIdentifier string `json:"platformTypeIdentifier" tfsdk:"platform_type_identifier"` diff --git a/tenant_v4_test.go b/tenant_v4_test.go index e94c11aa..4aa2fd75 100644 --- a/tenant_v4_test.go +++ b/tenant_v4_test.go @@ -1,9 +1,11 @@ package client import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMeshTenant_DeletionSuccessful(t *testing.T) { @@ -129,3 +131,99 @@ func TestTenantDeletionState(t *testing.T) { "has not acted on it", ) } + +// TestMeshTenantV4_UnmarshalJSON_RefShapedSpec covers the meshTenant v4 payload after meshStack +// dropped the deprecated flat identifiers. Both attributes force replacement on +// meshstack_tenant_v4, so reading them back empty would make a refresh plan the recreation of a +// live tenant. +func TestMeshTenantV4_UnmarshalJSON_RefShapedSpec(t *testing.T) { + const refShaped = `{ + "metadata": { + "uuid": "124b09ec-63b8-452e-a837-44afb382d5bd", + "ownedByWorkspace": "smoke-test", + "ownedByProject": "smoke-test-20260708163151-dev" + }, + "spec": { + "platformRef": { "uuid": "403af12b-fbd5-41f4-aad2-b8c5311bc651", "kind": "meshPlatform" }, + "landingZoneRef": { "name": "smoketest-ske-dev", "kind": "meshLandingZone" }, + "platformTenantId": "smoke-test-smoke-test-20260708163151-dev" + }, + "status": { + "tenantName": "smoke-test.smoke-test-20260708163151-dev.smoke-test-ske-platform.global" + } + }` + + var tenant MeshTenantV4 + require.NoError(t, json.Unmarshal([]byte(refShaped), &tenant)) + + assert.Equal(t, "smoke-test-ske-platform.global", tenant.Spec.PlatformIdentifier, + "platform identifier is recovered from status.tenantName") + require.NotNil(t, tenant.Spec.LandingZoneIdentifier) + assert.Equal(t, "smoketest-ske-dev", *tenant.Spec.LandingZoneIdentifier, + "landing zone identifier is recovered from spec.landingZoneRef.name") +} + +func TestMeshTenantV4_UnmarshalJSON_KeepsFlatIdentifiersWhenPresent(t *testing.T) { + const flat = `{ + "metadata": { "ownedByWorkspace": "ws", "ownedByProject": "proj" }, + "spec": { + "platformIdentifier": "flat-platform.global", + "landingZoneIdentifier": "flat-lz", + "landingZoneRef": { "name": "ref-lz", "kind": "meshLandingZone" } + }, + "status": { "tenantName": "ws.proj.tenant-name-platform.global" } + }` + + var tenant MeshTenantV4 + require.NoError(t, json.Unmarshal([]byte(flat), &tenant)) + + assert.Equal(t, "flat-platform.global", tenant.Spec.PlatformIdentifier) + require.NotNil(t, tenant.Spec.LandingZoneIdentifier) + assert.Equal(t, "flat-lz", *tenant.Spec.LandingZoneIdentifier) +} + +func TestPlatformIdentifierFromTenantName(t *testing.T) { + tests := []struct { + name string + tenantName string + workspace string + project string + want string + }{ + { + name: "platform identifier contains dots", + tenantName: "ws.proj.platform-name.global", + workspace: "ws", + project: "proj", + want: "platform-name.global", + }, + { + name: "workspace and project contain dots", + tenantName: "my.ws.my.proj.platform.global", + workspace: "my.ws", + project: "my.proj", + want: "platform.global", + }, + { + // A name that does not carry the expected prefix yields "" rather than a wrong platform. + name: "prefix does not match", + tenantName: "other.tenant.platform.global", + workspace: "ws", + project: "proj", + want: "", + }, + { + name: "empty tenant name", + tenantName: "", + workspace: "ws", + project: "proj", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, platformIdentifierFromTenantName(tt.tenantName, tt.workspace, tt.project)) + }) + } +} From 5a27467bd1581c6ba331e7b11da6138b037c8c9b Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Thu, 13 Aug 2026 13:01:12 +0200 Subject: [PATCH 189/215] feat: manage a landing zone's restricted flag via spec.restricted --- client.go | 2 +- landingzone.go | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/client.go b/client.go index 69b8a203..d6a63725 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.32.0") +var MinMeshStackVersion = version.MustParse("2026.34.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. diff --git a/landingzone.go b/landingzone.go index 259be05d..33f49689 100644 --- a/landingzone.go +++ b/landingzone.go @@ -19,10 +19,15 @@ type MeshLandingZoneMetadata struct { } type MeshLandingZoneSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Description string `json:"description" tfsdk:"description"` - AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` - AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + Description string `json:"description" tfsdk:"description"` + AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` + AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` + // Nullable in the API: absent means "keep the stored value". The schema defaults this to false, + // so the provider always *sends* a value — the pointer is only needed when *reading* a state + // file that an older version of this Terraform provider wrote, at a time when this field did + // not exist yet; the attribute reads back as null there. + Restricted *bool `json:"restricted,omitempty" tfsdk:"restricted"` InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` PlatformProperties *MeshLandingZonePlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` From 7eb18816469762d42c5bd887740c54ab6d551524 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Fri, 14 Aug 2026 10:10:32 +0200 Subject: [PATCH 190/215] refactor: model landing zone spec.restricted as a plain bool Co-Authored-By: Claude Opus 5 --- landingzone.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/landingzone.go b/landingzone.go index 33f49689..346c48f8 100644 --- a/landingzone.go +++ b/landingzone.go @@ -23,11 +23,10 @@ type MeshLandingZoneSpec struct { Description string `json:"description" tfsdk:"description"` AutomateDeletionApproval bool `json:"automateDeletionApproval" tfsdk:"automate_deletion_approval"` AutomateDeletionReplication bool `json:"automateDeletionReplication" tfsdk:"automate_deletion_replication"` - // Nullable in the API: absent means "keep the stored value". The schema defaults this to false, - // so the provider always *sends* a value — the pointer is only needed when *reading* a state - // file that an older version of this Terraform provider wrote, at a time when this field did - // not exist yet; the attribute reads back as null there. - Restricted *bool `json:"restricted,omitempty" tfsdk:"restricted"` + // Nullable in the API, where absent means "keep the stored value" — hence no `,omitempty`: the + // schema defaults this to false, so the provider always states the value it wants and never + // asks the backend to keep whatever is stored. + Restricted bool `json:"restricted" tfsdk:"restricted"` InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` PlatformProperties *MeshLandingZonePlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` From b732181e7593c1a3d9ddc5968162916e27e8fb93 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Thu, 13 Aug 2026 13:20:21 +0200 Subject: [PATCH 191/215] feat: add meshStack instance data source --- client.go | 15 ++++++------- mesh_info.go | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 mesh_info.go diff --git a/client.go b/client.go index d6a63725..e4b72b94 100644 --- a/client.go +++ b/client.go @@ -28,6 +28,7 @@ type Client struct { Integration MeshIntegrationClient LandingZone MeshLandingZoneClient Location MeshLocationClient + MeshInfo MeshInfoClient PaymentMethod MeshPaymentMethodClient Platform MeshPlatformClient PlatformType MeshPlatformTypeClient @@ -83,6 +84,7 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza Integration: newIntegrationClient(ctx, httpClient), LandingZone: newLandingZoneClient(ctx, httpClient), Location: newLocationClient(ctx, httpClient), + MeshInfo: newMeshInfoClient(httpClient), PaymentMethod: newPaymentMethodClient(ctx, httpClient), Platform: newPlatformClient(ctx, httpClient), PlatformType: newPlatformTypeClient(ctx, httpClient), @@ -106,15 +108,12 @@ func checkMeshVersion(ctx context.Context, httpClient internal.HttpClient) error return nil } - type MeshInfo struct { - Version version.Version `json:"version"` + dto, err := fetchMeshInfo(ctx, httpClient) + if err != nil { + return err } - - meshInfoEndpoint := httpClient.RootUrl.JoinPath("/mesh/info") - if meshInfo, err := internal.DoRequest[MeshInfo](ctx, httpClient, "GET", meshInfoEndpoint); err != nil { - return fmt.Errorf("failed to retrieve meshStack version information from %s endpoint: %w", meshInfoEndpoint, err) - } else if meshInfo.Version.Less(MinMeshStackVersion) { - return fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshInfo.Version, MinMeshStackVersion) + if dto.Version.Less(MinMeshStackVersion) { + return fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", dto.Version, MinMeshStackVersion) } return nil } diff --git a/mesh_info.go b/mesh_info.go new file mode 100644 index 00000000..a07bb83c --- /dev/null +++ b/mesh_info.go @@ -0,0 +1,63 @@ +package client + +import ( + "context" + "fmt" + + "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/terraform-provider-meshstack/client/version" +) + +// MeshInfo describes the meshStack instance the provider is configured against: the endpoint from +// the provider configuration, plus metadata from the public, unauthenticated /mesh/info endpoint. +type MeshInfo struct { + Endpoint string `tfsdk:"endpoint"` + Version string `tfsdk:"version"` + IsFourEyesEnabled bool `tfsdk:"is_four_eyes_enabled"` + Metadata map[string]string `tfsdk:"metadata"` + AdminWorkspaceIdentifier string `tfsdk:"admin_workspace_identifier"` +} + +// meshInfoDto is the raw /mesh/info response shape. +type meshInfoDto struct { + Version version.Version `json:"version"` + Is4EPEnabled bool `json:"is4EPEnabled"` + Metadata map[string]string `json:"metadata"` + AdminWorkspaceIdentifier string `json:"adminWorkspaceIdentifier"` +} + +type MeshInfoClient interface { + Read(ctx context.Context) (*MeshInfo, error) +} + +type meshInfoClient struct { + httpClient internal.HttpClient +} + +func newMeshInfoClient(httpClient internal.HttpClient) MeshInfoClient { + return meshInfoClient{httpClient: httpClient} +} + +func (c meshInfoClient) Read(ctx context.Context) (*MeshInfo, error) { + dto, err := fetchMeshInfo(ctx, c.httpClient) + if err != nil { + return nil, err + } + + return &MeshInfo{ + Endpoint: c.httpClient.RootUrl.String(), + Version: dto.Version.String(), + IsFourEyesEnabled: dto.Is4EPEnabled, + Metadata: dto.Metadata, + AdminWorkspaceIdentifier: dto.AdminWorkspaceIdentifier, + }, nil +} + +func fetchMeshInfo(ctx context.Context, httpClient internal.HttpClient) (meshInfoDto, error) { + meshInfoEndpoint := httpClient.RootUrl.JoinPath("/mesh/info") + dto, err := internal.DoRequest[meshInfoDto](ctx, httpClient, "GET", meshInfoEndpoint) + if err != nil { + return meshInfoDto{}, fmt.Errorf("failed to retrieve meshStack instance information from %s endpoint: %w", meshInfoEndpoint, err) + } + return dto, nil +} From dd03c54839e3830a3d22008e90e79ab37443431f Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Thu, 13 Aug 2026 11:59:36 +0200 Subject: [PATCH 192/215] refactor: address review comments regarding mesh_info struct and client --- client.go | 17 +++++++++++------ mesh_info.go | 42 ++++++++++-------------------------------- 2 files changed, 21 insertions(+), 38 deletions(-) diff --git a/client.go b/client.go index e4b72b94..10163711 100644 --- a/client.go +++ b/client.go @@ -69,7 +69,8 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza }, ) - if err := checkMeshVersion(ctx, httpClient); err != nil { + meshInfoClient := newMeshInfoClient(httpClient) + if err := checkMeshVersion(ctx, meshInfoClient); err != nil { return Client{}, err } @@ -84,7 +85,7 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza Integration: newIntegrationClient(ctx, httpClient), LandingZone: newLandingZoneClient(ctx, httpClient), Location: newLocationClient(ctx, httpClient), - MeshInfo: newMeshInfoClient(httpClient), + MeshInfo: meshInfoClient, PaymentMethod: newPaymentMethodClient(ctx, httpClient), Platform: newPlatformClient(ctx, httpClient), PlatformType: newPlatformTypeClient(ctx, httpClient), @@ -100,7 +101,7 @@ func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authoriza }, nil } -func checkMeshVersion(ctx context.Context, httpClient internal.HttpClient) error { +func checkMeshVersion(ctx context.Context, meshInfoClient MeshInfoClient) error { // Skip before the request, not just before the comparison: /mesh/info is a GET on the retrying // client, so an unavailable backend blocks provider configuration for the whole retry budget // (~4 minutes) and then fails it. Opting out of the check has to opt out of that too. @@ -108,12 +109,16 @@ func checkMeshVersion(ctx context.Context, httpClient internal.HttpClient) error return nil } - dto, err := fetchMeshInfo(ctx, httpClient) + info, err := meshInfoClient.Read(ctx) if err != nil { return err } - if dto.Version.Less(MinMeshStackVersion) { - return fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", dto.Version, MinMeshStackVersion) + meshVersion, err := version.Parse(info.Version) + if err != nil { + return fmt.Errorf("failed to parse meshStack version %q: %w", info.Version, err) + } + if meshVersion.Less(MinMeshStackVersion) { + return fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshVersion, MinMeshStackVersion) } return nil } diff --git a/mesh_info.go b/mesh_info.go index a07bb83c..52aa911f 100644 --- a/mesh_info.go +++ b/mesh_info.go @@ -5,25 +5,16 @@ import ( "fmt" "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/version" ) // MeshInfo describes the meshStack instance the provider is configured against: the endpoint from // the provider configuration, plus metadata from the public, unauthenticated /mesh/info endpoint. type MeshInfo struct { - Endpoint string `tfsdk:"endpoint"` - Version string `tfsdk:"version"` - IsFourEyesEnabled bool `tfsdk:"is_four_eyes_enabled"` - Metadata map[string]string `tfsdk:"metadata"` - AdminWorkspaceIdentifier string `tfsdk:"admin_workspace_identifier"` -} - -// meshInfoDto is the raw /mesh/info response shape. -type meshInfoDto struct { - Version version.Version `json:"version"` - Is4EPEnabled bool `json:"is4EPEnabled"` - Metadata map[string]string `json:"metadata"` - AdminWorkspaceIdentifier string `json:"adminWorkspaceIdentifier"` + Endpoint string `tfsdk:"endpoint" json:"-"` + Version string `tfsdk:"version" json:"version"` + IsFourEyesEnabled bool `tfsdk:"is_four_eyes_enabled" json:"is4EPEnabled"` + Metadata map[string]string `tfsdk:"metadata" json:"metadata"` + AdminWorkspaceIdentifier string `tfsdk:"admin_workspace_identifier" json:"adminWorkspaceIdentifier"` } type MeshInfoClient interface { @@ -39,25 +30,12 @@ func newMeshInfoClient(httpClient internal.HttpClient) MeshInfoClient { } func (c meshInfoClient) Read(ctx context.Context) (*MeshInfo, error) { - dto, err := fetchMeshInfo(ctx, c.httpClient) + meshInfoEndpoint := c.httpClient.RootUrl.JoinPath("/mesh/info") + info, err := internal.DoRequest[MeshInfo](ctx, c.httpClient, "GET", meshInfoEndpoint) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to retrieve meshStack instance information from %s endpoint: %w", meshInfoEndpoint, err) } - return &MeshInfo{ - Endpoint: c.httpClient.RootUrl.String(), - Version: dto.Version.String(), - IsFourEyesEnabled: dto.Is4EPEnabled, - Metadata: dto.Metadata, - AdminWorkspaceIdentifier: dto.AdminWorkspaceIdentifier, - }, nil -} - -func fetchMeshInfo(ctx context.Context, httpClient internal.HttpClient) (meshInfoDto, error) { - meshInfoEndpoint := httpClient.RootUrl.JoinPath("/mesh/info") - dto, err := internal.DoRequest[meshInfoDto](ctx, httpClient, "GET", meshInfoEndpoint) - if err != nil { - return meshInfoDto{}, fmt.Errorf("failed to retrieve meshStack instance information from %s endpoint: %w", meshInfoEndpoint, err) - } - return dto, nil + info.Endpoint = c.httpClient.RootUrl.String() + return &info, nil } From 7ff06961e608b29e20304d4aeede410ec9d330fb Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Thu, 13 Aug 2026 13:15:36 +0200 Subject: [PATCH 193/215] refactor: expose meshstack_instance four-eyes state as enabled_feature_flags Replaces the is_four_eyes_enabled bool with a more general enabled_feature_flags set-of-strings attribute (currently only four_eyes_role_approval), per PR review. --- mesh_info.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/mesh_info.go b/mesh_info.go index 52aa911f..817ceb34 100644 --- a/mesh_info.go +++ b/mesh_info.go @@ -7,12 +7,17 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/internal" ) +// FeatureFlagFourEyesRoleApproval is the only feature flag /mesh/info can currently report in +// MeshInfo.EnabledFeatureFlags: whether the four-eyes principle (role approval) is enabled. +const FeatureFlagFourEyesRoleApproval = "four_eyes_role_approval" + // MeshInfo describes the meshStack instance the provider is configured against: the endpoint from // the provider configuration, plus metadata from the public, unauthenticated /mesh/info endpoint. type MeshInfo struct { Endpoint string `tfsdk:"endpoint" json:"-"` Version string `tfsdk:"version" json:"version"` - IsFourEyesEnabled bool `tfsdk:"is_four_eyes_enabled" json:"is4EPEnabled"` + IsFourEyesEnabled bool `tfsdk:"-" json:"is4EPEnabled"` + EnabledFeatureFlags []string `tfsdk:"enabled_feature_flags" json:"-"` Metadata map[string]string `tfsdk:"metadata" json:"metadata"` AdminWorkspaceIdentifier string `tfsdk:"admin_workspace_identifier" json:"adminWorkspaceIdentifier"` } @@ -37,5 +42,9 @@ func (c meshInfoClient) Read(ctx context.Context) (*MeshInfo, error) { } info.Endpoint = c.httpClient.RootUrl.String() + if info.IsFourEyesEnabled { + info.EnabledFeatureFlags = []string{FeatureFlagFourEyesRoleApproval} + } + return &info, nil } From 2d757f72bb0d5385a7c1ddefb346d36079801b55 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Thu, 13 Aug 2026 15:40:53 +0200 Subject: [PATCH 194/215] fix: put CHANGELOG entry to new version as 0.24.4 is released already. --- client_test.go | 6 +++--- mesh_info.go | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/client_test.go b/client_test.go index 241f94e0..73539ccb 100644 --- a/client_test.go +++ b/client_test.go @@ -30,15 +30,15 @@ func TestCheckMeshVersion_SkipsRequestWhenOptedOut(t *testing.T) { t.Run("MESHSTACK_SKIP_VERSION_CHECK=true skips the /mesh/info request entirely", func(t *testing.T) { t.Setenv("MESHSTACK_SKIP_VERSION_CHECK", "true") httpClient, transport := newUnreachableClient() - require.NoError(t, checkMeshVersion(t.Context(), httpClient)) + require.NoError(t, checkMeshVersion(t.Context(), newMeshInfoClient(httpClient))) assert.Zero(t, transport.calls, "opting out of the version check must not send a request that can block on retries") }) t.Run("without the opt-out an unreachable /mesh/info fails", func(t *testing.T) { t.Setenv("MESHSTACK_SKIP_VERSION_CHECK", "") httpClient, transport := newUnreachableClient() - err := checkMeshVersion(t.Context(), httpClient) - require.ErrorContains(t, err, "failed to retrieve meshStack version information") + err := checkMeshVersion(t.Context(), newMeshInfoClient(httpClient)) + require.ErrorContains(t, err, "failed to retrieve meshStack instance information") assert.Equal(t, 1, transport.calls) }) } diff --git a/mesh_info.go b/mesh_info.go index 817ceb34..b70f09fb 100644 --- a/mesh_info.go +++ b/mesh_info.go @@ -42,8 +42,9 @@ func (c meshInfoClient) Read(ctx context.Context) (*MeshInfo, error) { } info.Endpoint = c.httpClient.RootUrl.String() + info.EnabledFeatureFlags = []string{} if info.IsFourEyesEnabled { - info.EnabledFeatureFlags = []string{FeatureFlagFourEyesRoleApproval} + info.EnabledFeatureFlags = append(info.EnabledFeatureFlags, FeatureFlagFourEyesRoleApproval) } return &info, nil From 2302b711a27c02bdf9705b39476d81a0f41c4e8c Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Mon, 24 Aug 2026 13:00:04 +0200 Subject: [PATCH 195/215] feat: support adopting an existing identity provider on entra id integrations New spec.config.entraid.idp_alias adopts an identity provider that already exists in a meshStack instance, instead of always creating one. Optional and Computed, so an existing integration plans no change. The alias is immutable. meshStack deletes the identity provider when the integration is deleted, so expressing a change as a destroy and recreate would delete a provider the customer may have configured themselves. A plan modifier rejects the change instead, as version_spec does in building_block_definition_resource.go, and rejects a change it cannot verify when state carries no alias to compare against. The framework ships nothing equivalent: stringplanmodifier has only RequiresReplace variants and UseStateForUnknown, and a validator cannot see prior state. --- client.go | 2 +- integration_config.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index 10163711..413ed9b5 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.34.0") +var MinMeshStackVersion = version.MustParse("2026.35.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. diff --git a/integration_config.go b/integration_config.go index 5e23cda5..6164bf5b 100644 --- a/integration_config.go +++ b/integration_config.go @@ -43,6 +43,7 @@ type MeshIntegrationEntraIdConfig struct { TenantId string `json:"tenantId" tfsdk:"tenant_id"` ClientId string `json:"clientId" tfsdk:"client_id"` ClientSecret types.Secret `json:"clientSecret" tfsdk:"client_secret"` + IdpAlias *string `json:"idpAlias,omitempty" tfsdk:"idp_alias"` RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` } From 350ef8952957f70df4d623421dc2faa9f65818ee Mon Sep 17 00:00:00 2001 From: Mohammad Alhussan Date: Mon, 24 Aug 2026 13:00:13 +0200 Subject: [PATCH 196/215] fix!: move the entra id redirect url to status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit meshStack derives the redirect URL and returns it inside spec, which configuration writes. A computed value there is unreachable under provider mocks and cannot be supplied by override_* either, so any module wiring that reads it is untestable (#272). It now lives at status.entraid.redirect_url, a fully computed container, nested per integration type the way status.workload_identity_federation nests per cloud. That also retires a trap: the attribute was Optional as well as Computed, but meshStack ignores a supplied value, so a configuration that set it failed the apply with "Provider produced inconsistent result after apply" — after the integration had been created. Derived through a local model struct rather than a json:"-" field on the client type, per the computed-only output field pattern in the resource-development skill. integrationStatus is shared by the resource and the data source. --- integration_config.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration_config.go b/integration_config.go index 6164bf5b..5c5a116d 100644 --- a/integration_config.go +++ b/integration_config.go @@ -44,7 +44,9 @@ type MeshIntegrationEntraIdConfig struct { ClientId string `json:"clientId" tfsdk:"client_id"` ClientSecret types.Secret `json:"clientSecret" tfsdk:"client_secret"` IdpAlias *string `json:"idpAlias,omitempty" tfsdk:"idp_alias"` - RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` + // meshStack derives this and returns it inside spec, which configuration writes. A computed value + // there is unreachable under provider mocks (issue #272), so Terraform reads it from status instead. + RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"-"` } type MeshIntegrationConfig struct { From 1d49c395a580d5756128e6359d41059d8961e38a Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Tue, 25 Aug 2026 12:26:05 +0200 Subject: [PATCH 197/215] feat: add an optional building block name template A building block definition can now carry a display_name_template, so meshStack names each ordered building block after the values it was ordered with while the definition keeps a clean display_name. Without the attribute a building block is named after display_name, and an empty string means the same thing. meshStack has to serve the field: an older one drops it from its response, so an apply that sets it fails Terraform's consistency check. Co-Authored-By: Claude Opus 5 (1M context) --- building_block_definition.go | 1 + 1 file changed, 1 insertion(+) diff --git a/building_block_definition.go b/building_block_definition.go index 9ef2e7f0..d7da2f38 100644 --- a/building_block_definition.go +++ b/building_block_definition.go @@ -24,6 +24,7 @@ type MeshBuildingBlockDefinitionMetadata struct { type MeshBuildingBlockDefinitionSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` + DisplayNameTemplate *string `json:"displayNameTemplate,omitempty" tfsdk:"display_name_template"` TargetType MeshBuildingBlockType `json:"targetType" tfsdk:"target_type"` Description string `json:"description" tfsdk:"description"` Readme *string `json:"readme,omitempty" tfsdk:"readme"` From 7601c584d77ccefb0136a5078ecf9460d274c511 Mon Sep 17 00:00:00 2001 From: Fabian Muscariello Date: Mon, 31 Aug 2026 10:20:32 +0200 Subject: [PATCH 198/215] feat: expose building block definition approvals and drift schedule The meshBuildingBlockDefinition meshObject API now carries the meshPanel Policies tab in spec.approvalPolicies and spec.schedule. They default to no approval gate and no schedule, which is what meshStack stores for a new definition. meshStack validates both against the implementation type of the definition's latest version, and it rejects a version implementation-type change while the stored policies are incompatible with the new type. So a policy the current type cannot honour has to be written after version_spec, and one the new type cannot honour has to be gone before it. Neutral policies satisfy every implementation type, so Create writes the definition neutral, writes the version, then writes the planned policies, and Update passes through neutral in between when the implementation type changes. That makes a combined implementation-type and policy change apply in one step. Two object validators repeat meshStack's rules at plan time, so an unsupported combination fails before anything is written. The mock client learns the same rules, so a unit-test run catches an ordering regression without a live backend. Co-Authored-By: Claude Opus 5 --- building_block_definition.go | 81 ++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/building_block_definition.go b/building_block_definition.go index d7da2f38..110eda0e 100644 --- a/building_block_definition.go +++ b/building_block_definition.go @@ -16,6 +16,24 @@ var ( MeshBuildingBlockTypeWorkspaceLevel = MeshBuildingBlockTypes.Entry("WORKSPACE_LEVEL") ) +type MeshBuildingBlockScheduleMode string + +var ( + MeshBuildingBlockScheduleModes = enum.Enum[MeshBuildingBlockScheduleMode]{} + MeshBuildingBlockScheduleModeDisabled = MeshBuildingBlockScheduleModes.Entry("DISABLED") + MeshBuildingBlockScheduleModeDriftDetection = MeshBuildingBlockScheduleModes.Entry("DRIFT_DETECTION") + MeshBuildingBlockScheduleModeDriftReconciliation = MeshBuildingBlockScheduleModes.Entry("DRIFT_RECONCILIATION") +) + +type MeshBuildingBlockScheduleFrequency string + +var ( + MeshBuildingBlockScheduleFrequencies = enum.Enum[MeshBuildingBlockScheduleFrequency]{} + MeshBuildingBlockScheduleFrequencyNone = MeshBuildingBlockScheduleFrequencies.Entry("NONE") + MeshBuildingBlockScheduleFrequencyDaily = MeshBuildingBlockScheduleFrequencies.Entry("DAILY") + MeshBuildingBlockScheduleFrequencyWeekly = MeshBuildingBlockScheduleFrequencies.Entry("WEEKLY") +) + type MeshBuildingBlockDefinitionMetadata struct { Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` @@ -23,21 +41,66 @@ type MeshBuildingBlockDefinitionMetadata struct { } type MeshBuildingBlockDefinitionSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - DisplayNameTemplate *string `json:"displayNameTemplate,omitempty" tfsdk:"display_name_template"` - TargetType MeshBuildingBlockType `json:"targetType" tfsdk:"target_type"` - Description string `json:"description" tfsdk:"description"` - Readme *string `json:"readme,omitempty" tfsdk:"readme"` - RunTransparency bool `json:"runTransparency" tfsdk:"run_transparency"` - UseInLandingZonesOnly bool `json:"useInLandingZonesOnly" tfsdk:"use_in_landing_zones_only"` - SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` - DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + DisplayNameTemplate *string `json:"displayNameTemplate,omitempty" tfsdk:"display_name_template"` + TargetType MeshBuildingBlockType `json:"targetType" tfsdk:"target_type"` + Description string `json:"description" tfsdk:"description"` + Readme *string `json:"readme,omitempty" tfsdk:"readme"` + RunTransparency bool `json:"runTransparency" tfsdk:"run_transparency"` + ApprovalPolicies MeshBuildingBlockDefinitionApprovalPolicies `json:"approvalPolicies" tfsdk:"approval_policies"` + Schedule MeshBuildingBlockDefinitionSchedule `json:"schedule" tfsdk:"schedule"` + UseInLandingZonesOnly bool `json:"useInLandingZonesOnly" tfsdk:"use_in_landing_zones_only"` + SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` + DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` // NotificationSubscribers can also specify emails with prefix 'email:', so it's not only usernames (as the JSON field name suggests)! NotificationSubscribers types.Set[string] `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` SupportedPlatforms types.Set[NamedRef] `json:"supportedPlatforms" tfsdk:"supported_platforms"` } +type MeshBuildingBlockDefinitionApprovalPolicies struct { + VersionUpgrade bool `json:"versionUpgrade" tfsdk:"version_upgrade"` + UserInputChanges bool `json:"userInputChanges" tfsdk:"user_input_changes"` + ManualTriggers bool `json:"manualTriggers" tfsdk:"manual_triggers"` + BuildingBlockCreation bool `json:"buildingBlockCreation" tfsdk:"building_block_creation"` + AnyInputChanges bool `json:"anyInputChanges" tfsdk:"any_input_changes"` +} + +// NothingRequiresApproval reports whether no approval gate is enabled. +func (a MeshBuildingBlockDefinitionApprovalPolicies) NothingRequiresApproval() bool { + return a == MeshBuildingBlockDefinitionApprovalPolicies{} +} + +type MeshBuildingBlockDefinitionSchedule struct { + Mode MeshBuildingBlockScheduleMode `json:"mode" tfsdk:"mode"` + Frequency MeshBuildingBlockScheduleFrequency `json:"frequency" tfsdk:"frequency"` + AutomaticApproval bool `json:"automaticApproval" tfsdk:"automatic_approval"` +} + +// DisabledSchedule is the only schedule meshStack accepts for every implementation. +func DisabledSchedule() MeshBuildingBlockDefinitionSchedule { + return MeshBuildingBlockDefinitionSchedule{ + Mode: MeshBuildingBlockScheduleModeDisabled.Unwrap(), + Frequency: MeshBuildingBlockScheduleFrequencyNone.Unwrap(), + } +} + +func (s MeshBuildingBlockDefinitionSchedule) IsDisabled() bool { + return s == DisabledSchedule() +} + +// HasNeutralPolicies reports whether the spec asks for no approval gate and no schedule. +func (s MeshBuildingBlockDefinitionSpec) HasNeutralPolicies() bool { + return s.ApprovalPolicies.NothingRequiresApproval() && s.Schedule.IsDisabled() +} + +// WithNeutralPolicies returns a copy of the spec without any required approvals and without a schedule. +func (s MeshBuildingBlockDefinitionSpec) WithNeutralPolicies() MeshBuildingBlockDefinitionSpec { + s.ApprovalPolicies = MeshBuildingBlockDefinitionApprovalPolicies{} + s.Schedule = DisabledSchedule() + return s +} + type MeshBuildingBlockDefinitionStatusVersion struct { VersionUuid string `json:"versionUuid"` VersionNumber int64 `json:"versionNumber"` From 6fb6fa8832a1922839c1c5ef879bb19a86f826b4 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Tue, 1 Sep 2026 15:21:41 +0200 Subject: [PATCH 199/215] feat: read a meshStack tag as a building block definition input meshStack lets a Building Block Definition input name a tag instead of asking users to re-type metadata meshStack already governs. Declaring one takes the new TAG assignment type, a CODE input type and an argument holding the `.` reference, so the only code change is the enum entry that the assignment_type validator builds its allowed set from. The rules that come with it - which targets a definition may read, and why the input type is fixed - are documented on the two attributes that carry them, and the terraform example shows the argument referencing a meshstack_tag_definition rather than a literal key, which also gets the destroy order right. The meshTagDefinition delete now refuses while a building block reads the tag, so the tag definition resource says so too. Co-Authored-By: Claude Opus 5 (1M context) --- building_block_definition_version.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 2f84dc9e..f9d2c317 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -66,8 +66,35 @@ var ( MeshBuildingBlockInputAssignmentTypeTenantBuildingBlockUuid = MeshBuildingBlockInputAssignmentTypes.Entry("TENANT_BUILDING_BLOCK_UUID") MeshBuildingBlockInputAssignmentTypeStatic = MeshBuildingBlockInputAssignmentTypes.Entry("STATIC") MeshBuildingBlockInputAssignmentTypeUserPermissions = MeshBuildingBlockInputAssignmentTypes.Entry("USER_PERMISSIONS") + MeshBuildingBlockInputAssignmentTypeTag = MeshBuildingBlockInputAssignmentTypes.Entry("TAG") ) +// MeshBuildingBlockTagInputTarget names the meshObject a tag input reads its tag from. It is the first +// half of the input's argument, `.`. +type MeshBuildingBlockTagInputTarget string + +var ( + MeshBuildingBlockTagInputTargets = enum.Enum[MeshBuildingBlockTagInputTarget]{} + MeshBuildingBlockTagInputTargetWorkspace = MeshBuildingBlockTagInputTargets.Entry("WORKSPACE") + MeshBuildingBlockTagInputTargetProject = MeshBuildingBlockTagInputTargets.Entry("PROJECT") + MeshBuildingBlockTagInputTargetPaymentMethod = MeshBuildingBlockTagInputTargets.Entry("PAYMENT_METHOD") + MeshBuildingBlockTagInputTargetLandingZone = MeshBuildingBlockTagInputTargets.Entry("LANDING_ZONE") +) + +// TagInputTargetSeparator splits the target from the tag key. Only the first one separates them, +// because a tag key may contain a dot itself. +const TagInputTargetSeparator = "." + +// TagInputTargetsFor answers which tags a building block of this target type can read. A workspace +// building block only ever runs in the context of a workspace; a tenant building block additionally +// sees its project, and that project's payment method and landing zone. +func TagInputTargetsFor(targetType MeshBuildingBlockType) enum.Enum[MeshBuildingBlockTagInputTarget] { + if targetType == MeshBuildingBlockTypeWorkspaceLevel.Unwrap() { + return enum.Of(MeshBuildingBlockTagInputTargetWorkspace) + } + return MeshBuildingBlockTagInputTargets +} + type MeshBuildingBlockDefinitionOutputAssignmentType string var ( From b188bbb71b2873622c6f27d0a48218ad1c2516a1 Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Thu, 20 Aug 2026 15:47:49 +0200 Subject: [PATCH 200/215] feat: support optional Building Block Definition inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An operator can now mark a definition input optional via version_spec.inputs.*.is_optional, so whoever fills the Building Block in may leave it blank and the implementation falls back to the default declared in its own code — a Terraform variable's default, a workflow input's default. Mirrors meshcloud/meshfed-release#10701. isOptional is sent with omitempty. false is the backend's default for an absent field, so a non-optional input serialises exactly as it did before the field existed: every already-stored content hash stays byte-identical (no currentHashVersion bump, so no released Building Block re-runs), and a backend that does not know the field still sees the same payload for configs that do not use the feature. A new fixture pins that equality so a later change cannot silently break it. MinMeshStackVersion deliberately stays put. Unlike a field the provider always sends, this one only reaches the backend for someone who opted in, so gating every user of the provider would cost more than it protects. ValidateConfig re-imposes the backend's four rules at plan time rather than letting them surface as a 400 during apply: no optional input on a MANUAL implementation (a person carries the block out, so there is no code to fall back to), only for the assignment types a person supplies, never for BOOLEAN (an unset boolean is indistinguishable from false where it is consumed), and never together with a meshStack default value. Checks whose value is unknown at plan are skipped, keeping the backend the authority. Co-Authored-By: Claude Opus 5 --- building_block_definition_version.go | 1 + client.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index f9d2c317..ce4f1c96 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -124,6 +124,7 @@ type MeshBuildingBlockDefinitionInput struct { Argument types.SecretOrAny `json:"argument" tfsdk:"argument"` DefaultValue types.SecretOrAny `json:"defaultValue" tfsdk:"default_value"` UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` + IsOptional bool `json:"isOptional,omitempty" tfsdk:"is_optional"` SelectableValues types.Set[string] `json:"selectableValues,omitempty" tfsdk:"selectable_values"` Description *string `json:"description,omitempty" tfsdk:"description"` ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` diff --git a/client.go b/client.go index 413ed9b5..a404ed72 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "github.com/meshcloud/terraform-provider-meshstack/client/version" ) -var MinMeshStackVersion = version.MustParse("2026.35.0") +var MinMeshStackVersion = version.MustParse("2026.36.0") // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. From fb71cad988cce51ac5075eabe969918ed9ddd161 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 2 Sep 2026 22:40:20 +0200 Subject: [PATCH 201/215] build: move to go 1.27 and pin golangci-lint as a go tool The two halves belong in one commit because the linter refuses to run when they are split: a golangci-lint built by go 1.26 rejects a module targeting 1.27 with "the Go language version (go1.26) used to build golangci-lint is lower than the targeted Go version (1.27)". Lint therefore runs differently now. golangci-lint's formatters use the go/format compiled into the binary, so the formatting they enforce comes from the Go release that BUILT the linter rather than the toolchain on PATH. Its version was pinned in two places that could drift - flake.nix took whatever nixpkgs packaged, CI asked for latest - and neither was tied to go.mod's Go. It joins tfplugindocs and gotestsum in the tool block instead, so go.mod and go.sum are the single pin and `task lint` runs `go tool golangci-lint run`, which builds it with the same Go the code is written against. That also works outside `nix develop`, which it did not before, so flake.nix no longer carries the package - one pin fewer to keep in step. CI keeps golangci-lint-action, because it annotates the pull request diff and a bare `run:` does not. It installs nothing though: the step before it runs `go install` for the pinned package and the action uses install-mode none, so the version it runs is the one in go.mod. The job also moves off `go-version: stable` onto go.mod's Go, for the same reason. Rebuilding the linter with go 1.27 changed one alignment group in client/internal/retry_test.go, which is the behaviour described above showing up in practice rather than an unrelated edit. The changelog entry opens a new v0.25.3 section. v0.25.2 is already tagged, so per the changelog-management skill a pending entry needs a new top section rather than an amendment to a released one. Two knock-on changes: - flake.lock had to move forward, because the June nixpkgs it pinned carries no go_1_27 attribute and `nix develop` failed outright with "undefined variable 'go_1_27'". - testify goes 1.11.1 -> 1.12.1, raised by MVS because golangci-lint requires at least that. Co-Authored-By: Claude Opus 5 (1M context) --- internal/retry_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/retry_test.go b/internal/retry_test.go index a643eddf..ffb9302b 100644 --- a/internal/retry_test.go +++ b/internal/retry_test.go @@ -43,10 +43,10 @@ func TestRetryAfterBackoff(t *testing.T) { want time.Duration }{ {"delay-seconds", "30", 30 * time.Second}, - {"zero seconds", "0", 0}, // RFC: retry immediately - {"capped at 5 minutes", "600", 5 * time.Minute}, // capped - {"empty header", "", 1 * time.Second}, // falls back - {"unparseable header", "not-a-number-or-date", 1 * time.Second}, // falls back + {"zero seconds", "0", 0}, // RFC: retry immediately + {"capped at 5 minutes", "600", 5 * time.Minute}, // capped + {"empty header", "", 1 * time.Second}, // falls back + {"unparseable header", "not-a-number-or-date", 1 * time.Second}, // falls back {"HTTP-date in the past", bubbleStart.Add(-10 * time.Second).Format(http.TimeFormat), 1 * time.Second}, // falls back {"HTTP-date in the future", bubbleStart.Add(45 * time.Second).Format(http.TimeFormat), 45 * time.Second}, } From a242aa171299edecf7f60eb5024b9d3d6ccae74c Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Thu, 27 Aug 2026 20:32:16 +0200 Subject: [PATCH 202/215] feat: support JSON_SCHEMA building block definition inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A definition input can now declare type JSON_SCHEMA together with the schema its value follows, matching the new backend input type. meshPanel renders a form from the schema, and the value reaches the building block as JSON text, exactly like a CODE input. JSON_SCHEMA is deliberately not an entry of MeshBuildingBlockIOTypes: that enum describes the type of a value, and a building block's own inputs keep reporting CODE for these. Only the definition input schema offers it, via MeshBuildingBlockDefinitionInputTypes. ValidateConfig re-imposes the pairing the framework cannot express — json_schema is required for the type and rejected for every other — so a mismatch fails at plan time rather than as a 400. Co-Authored-By: Claude Opus 5 (1M context) --- building_block_definition_version.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index ce4f1c96..d1e19d1a 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -41,6 +41,23 @@ var ( MeshBuildingBlockIOTypeList = MeshBuildingBlockIOTypes.Entry("LIST") MeshBuildingBlockIOTypeSingleSelect = MeshBuildingBlockIOTypes.Entry("SINGLE_SELECT") MeshBuildingBlockIOTypeMultiSelect = MeshBuildingBlockIOTypes.Entry("MULTI_SELECT") + + // Declaration only, so deliberately not an entry of MeshBuildingBlockIOTypes: a building block's own + // inputs report the type of the value, and a JSON_SCHEMA value is the JSON text a CODE value is. + MeshBuildingBlockIOTypeJsonSchema = enum.Entry[MeshBuildingBlockIOType]("JSON_SCHEMA") +) + +// The types a definition input may declare. +var MeshBuildingBlockDefinitionInputTypes = enum.Of( + MeshBuildingBlockIOTypeString, + MeshBuildingBlockIOTypeCode, + MeshBuildingBlockIOTypeInteger, + MeshBuildingBlockIOTypeBoolean, + MeshBuildingBlockIOTypeFile, + MeshBuildingBlockIOTypeList, + MeshBuildingBlockIOTypeSingleSelect, + MeshBuildingBlockIOTypeMultiSelect, + MeshBuildingBlockIOTypeJsonSchema, ) var MeshBuildingBlockOutputIOTypes = enum.Of( @@ -129,6 +146,8 @@ type MeshBuildingBlockDefinitionInput struct { Description *string `json:"description,omitempty" tfsdk:"description"` ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` + // JSON Schema describing the value, as a JSON string. Only for MeshBuildingBlockIOTypeJsonSchema. + JsonSchema *string `json:"jsonSchema,omitempty" tfsdk:"json_schema"` // No omitempty: a 0 (the schema default, and what an unknown plan value collapses to) must be sent so // the backend stores it verbatim. With omitempty the 0 would be dropped and the backend would assign // a position itself, making the applied value differ from the plan. From 6954a55cf37091e3e824f4da50aa58b2cfbe01bd Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Tue, 1 Sep 2026 09:53:39 +0200 Subject: [PATCH 203/215] refactor: derive definition input types from the IO types The list of types a definition input may declare is the full IO type set plus JSON_SCHEMA, so spell it that way instead of repeating all nine entries. A new Enum.With copies via slices.Concat rather than appending, so the package-level MeshBuildingBlockIOTypes can never be written into through its shared backing array. Co-Authored-By: Claude Opus 5 (1M context) --- building_block_definition_version.go | 12 +----------- types/enum/enum.go | 6 ++++++ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index d1e19d1a..66e4d748 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -48,17 +48,7 @@ var ( ) // The types a definition input may declare. -var MeshBuildingBlockDefinitionInputTypes = enum.Of( - MeshBuildingBlockIOTypeString, - MeshBuildingBlockIOTypeCode, - MeshBuildingBlockIOTypeInteger, - MeshBuildingBlockIOTypeBoolean, - MeshBuildingBlockIOTypeFile, - MeshBuildingBlockIOTypeList, - MeshBuildingBlockIOTypeSingleSelect, - MeshBuildingBlockIOTypeMultiSelect, - MeshBuildingBlockIOTypeJsonSchema, -) +var MeshBuildingBlockDefinitionInputTypes = MeshBuildingBlockIOTypes.With(MeshBuildingBlockIOTypeJsonSchema) var MeshBuildingBlockOutputIOTypes = enum.Of( MeshBuildingBlockIOTypeString, diff --git a/types/enum/enum.go b/types/enum/enum.go index 0f07fef5..fa0b8f62 100644 --- a/types/enum/enum.go +++ b/types/enum/enum.go @@ -2,6 +2,7 @@ package enum import ( "fmt" + "slices" "strings" ) @@ -11,6 +12,11 @@ func Of[T ~string](entries ...Entry[T]) Enum[T] { type Enum[T ~string] []Entry[T] +// With returns a copy of the enum extended by entries, leaving the receiver untouched. +func (e Enum[T]) With(entries ...Entry[T]) Enum[T] { + return slices.Concat(e, entries) +} + func (e *Enum[T]) Entry(v string) (ee Entry[T]) { ee = Entry[T](v) *e = append(*e, ee) From ce90235f3ff7694b7b1b1de9b51625b5ebc90384 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Tue, 1 Sep 2026 09:54:04 +0200 Subject: [PATCH 204/215] docs: describe JSON_SCHEMA inputs as the sub-form they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wording led with the value the input produces, which reads as "CODE with a schema attached" and hides what the type actually does: it gives the input a form of its own, declared by json_schema, that meshPanel renders in place of a single field. Lead with the form everywhere the type is described — the `type` attribute, `json_schema`, the ValidateConfig messages, the example and the changelog — and keep the CODE equivalence as the follow-up it is, since that is only true of what the form produces. Co-Authored-By: Claude Opus 5 (1M context) --- building_block_definition_version.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 66e4d748..82d30bfe 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -42,8 +42,10 @@ var ( MeshBuildingBlockIOTypeSingleSelect = MeshBuildingBlockIOTypes.Entry("SINGLE_SELECT") MeshBuildingBlockIOTypeMultiSelect = MeshBuildingBlockIOTypes.Entry("MULTI_SELECT") - // Declaration only, so deliberately not an entry of MeshBuildingBlockIOTypes: a building block's own - // inputs report the type of the value, and a JSON_SCHEMA value is the JSON text a CODE value is. + // A definition input declaring this type describes a form of its own, through the accompanying + // JsonSchema. That makes it a declaration-side type only, so deliberately not an entry of + // MeshBuildingBlockIOTypes: what the form produces is JSON text, which a building block's own inputs + // report as CODE. MeshBuildingBlockIOTypeJsonSchema = enum.Entry[MeshBuildingBlockIOType]("JSON_SCHEMA") ) @@ -136,7 +138,7 @@ type MeshBuildingBlockDefinitionInput struct { Description *string `json:"description,omitempty" tfsdk:"description"` ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` - // JSON Schema describing the value, as a JSON string. Only for MeshBuildingBlockIOTypeJsonSchema. + // The form this input is filled in through, as a JSON Schema string. Only for MeshBuildingBlockIOTypeJsonSchema. JsonSchema *string `json:"jsonSchema,omitempty" tfsdk:"json_schema"` // No omitempty: a 0 (the schema default, and what an unknown plan value collapses to) must be sent so // the backend stores it verbatim. With omitempty the 0 would be dropped and the backend would assign From fe5bd1612d4c2bba9617c49d174ce6fd3e63cf0a Mon Sep 17 00:00:00 2001 From: Jo Schwandke Date: Tue, 8 Sep 2026 16:05:49 +0200 Subject: [PATCH 205/215] feat: support conditional inputs CU-86cb59bn3 --- building_block_definition_version.go | 1 + 1 file changed, 1 insertion(+) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 82d30bfe..0adb3e73 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -140,6 +140,7 @@ type MeshBuildingBlockDefinitionInput struct { ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` // The form this input is filled in through, as a JSON Schema string. Only for MeshBuildingBlockIOTypeJsonSchema. JsonSchema *string `json:"jsonSchema,omitempty" tfsdk:"json_schema"` + Condition *string `json:"condition,omitempty" tfsdk:"condition"` // No omitempty: a 0 (the schema default, and what an unknown plan value collapses to) must be sent so // the backend stores it verbatim. With omitempty the 0 would be dropped and the backend would assign // a position itself, making the applied value differ from the plan. From cf78ea1d9c6f51195ae1f9673dee9abd622cf740 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Tue, 8 Sep 2026 16:39:30 +0200 Subject: [PATCH 206/215] refactor: rename the JSON_SCHEMA definition input type to JSON Follows the backend rename: the type names the value the input holds, and the schema stays a separate argument on it. The type ships first in meshStack 2026.37.0, which the changelog entry now states. Co-Authored-By: Claude Opus 5 (1M context) --- building_block_definition_version.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/building_block_definition_version.go b/building_block_definition_version.go index 0adb3e73..1a0f5ecb 100644 --- a/building_block_definition_version.go +++ b/building_block_definition_version.go @@ -46,11 +46,11 @@ var ( // JsonSchema. That makes it a declaration-side type only, so deliberately not an entry of // MeshBuildingBlockIOTypes: what the form produces is JSON text, which a building block's own inputs // report as CODE. - MeshBuildingBlockIOTypeJsonSchema = enum.Entry[MeshBuildingBlockIOType]("JSON_SCHEMA") + MeshBuildingBlockIOTypeJson = enum.Entry[MeshBuildingBlockIOType]("JSON") ) // The types a definition input may declare. -var MeshBuildingBlockDefinitionInputTypes = MeshBuildingBlockIOTypes.With(MeshBuildingBlockIOTypeJsonSchema) +var MeshBuildingBlockDefinitionInputTypes = MeshBuildingBlockIOTypes.With(MeshBuildingBlockIOTypeJson) var MeshBuildingBlockOutputIOTypes = enum.Of( MeshBuildingBlockIOTypeString, @@ -138,7 +138,7 @@ type MeshBuildingBlockDefinitionInput struct { Description *string `json:"description,omitempty" tfsdk:"description"` ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` - // The form this input is filled in through, as a JSON Schema string. Only for MeshBuildingBlockIOTypeJsonSchema. + // The form this input is filled in through, as a JSON Schema string. Only for MeshBuildingBlockIOTypeJson. JsonSchema *string `json:"jsonSchema,omitempty" tfsdk:"json_schema"` Condition *string `json:"condition,omitempty" tfsdk:"condition"` // No omitempty: a 0 (the schema default, and what an unknown plan value collapses to) must be sent so From 720acdb2e22d138e3bc0e3024e128a5684082340 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 18 Sep 2026 21:27:34 +0200 Subject: [PATCH 207/215] refactor: make the imported client a package of this module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client keeps its public shape, and a caller that held a `client.Client` still does. What changes is where it gets its parts from: the HTTP client, the request options and the retry policy move one directory up into `internal/http`, so that `internal/oidc` and `pkg/auth` can use them too — Go's internal rule closes `client/internal` to both. Authorization becomes an interface the client is handed rather than a login it performs itself, which is what lets one resolved session decide the endpoint and the token together. Bodies marshal with `encoding/json/v2`, and the `apiVersion` and `kind` members now come from an `,embed` tag rather than from a hand-written wrapper struct per request. This commit does not build on its own: `go.mod` arrives in the next one. CU-86cb61rzz Co-Authored-By: Claude Opus 5 (1M context) --- client/api_key.go | 12 +- client/building_block_definition.go | 23 +- client/building_block_definition_version.go | 41 +- ...block_definition_version_implementation.go | 40 +- .../building_block_definition_version_test.go | 12 +- client/building_block_run.go | 10 +- client/building_block_runner.go | 29 +- client/building_block_v2.go | 78 ++-- client/building_block_v2_test.go | 114 +----- client/buildingblock.go | 3 +- client/client.go | 122 ++---- client/client_kind_test.go | 2 +- client/client_logging.go | 11 - client/client_test.go | 44 -- client/integration.go | 4 +- client/integration_config.go | 23 +- client/internal/auth.go | 77 ---- client/internal/http_client.go | 133 ------ client/internal/http_client_test.go | 386 ------------------ client/internal/http_error.go | 32 -- client/internal/logging.go | 84 ---- .../{mesh_object_client.go => mesh_object.go} | 108 ++--- client/internal/options.go | 95 ----- client/internal/retry.go | 270 ------------ client/internal/retry_test.go | 64 --- client/landingzone.go | 9 +- client/location.go | 2 +- client/mesh_info.go | 63 +-- client/payment_method.go | 10 +- client/platform.go | 33 +- client/platform_config_aks.go | 8 +- client/platform_config_aws.go | 22 +- client/platform_config_azure.go | 26 +- client/platform_config_azurerg.go | 8 +- client/platform_config_custom.go | 4 +- client/platform_config_gcp.go | 20 +- client/platform_config_kubernetes.go | 4 +- client/platform_config_openshift.go | 8 +- client/platform_properties_azurerg.go | 2 +- client/platform_properties_gcp.go | 4 +- client/platform_type.go | 9 +- client/project.go | 13 +- client/project_binding.go | 2 + client/project_group_binding.go | 2 +- client/project_user_binding.go | 2 +- client/service_instance.go | 7 +- client/tag_definition.go | 34 +- client/tenant_v4.go | 10 +- client/types/clienttypes.go | 10 +- client/types/clienttypes_test.go | 42 +- client/types/variant/variant.go | 18 +- client/types/xurl/url.go | 73 ++++ client/version/version.go | 75 ---- client/version/version_test.go | 94 ----- client/workspace.go | 11 +- client/workspace_binding.go | 2 +- client/workspace_group_binding.go | 2 +- client/workspace_user_binding.go | 2 +- 58 files changed, 502 insertions(+), 1946 deletions(-) delete mode 100644 client/client_logging.go delete mode 100644 client/client_test.go delete mode 100644 client/internal/auth.go delete mode 100644 client/internal/http_client.go delete mode 100644 client/internal/http_client_test.go delete mode 100644 client/internal/http_error.go delete mode 100644 client/internal/logging.go rename client/internal/{mesh_object_client.go => mesh_object.go} (52%) delete mode 100644 client/internal/options.go delete mode 100644 client/internal/retry.go delete mode 100644 client/internal/retry_test.go create mode 100644 client/types/xurl/url.go delete mode 100644 client/version/version.go delete mode 100644 client/version/version_test.go diff --git a/client/api_key.go b/client/api_key.go index ec998904..cd2bd907 100644 --- a/client/api_key.go +++ b/client/api_key.go @@ -3,30 +3,30 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" ) type MeshApiKey struct { Metadata MeshApiKeyMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshApiKeySpec `json:"spec" tfsdk:"spec"` - Status *MeshApiKeyStatus `json:"status,omitempty" tfsdk:"status"` + Status *MeshApiKeyStatus `json:"status,omitzero" tfsdk:"status"` } type MeshApiKeyMetadata struct { - Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } type MeshApiKeySpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` Permissions types.Set[ApiPermission] `json:"permissions" tfsdk:"permissions"` - ExpiresAt *string `json:"expiresAt,omitempty" tfsdk:"expires_at"` + ExpiresAt *string `json:"expiresAt,omitzero" tfsdk:"expires_at"` } type MeshApiKeyStatus struct { ClientId string `json:"clientId" tfsdk:"client_id"` - ClientSecret *string `json:"clientSecret,omitempty" tfsdk:"client_secret"` + ClientSecret *string `json:"clientSecret,omitzero" tfsdk:"client_secret"` } type MeshApiKeyClient interface { diff --git a/client/building_block_definition.go b/client/building_block_definition.go index 110eda0e..f7ec8937 100644 --- a/client/building_block_definition.go +++ b/client/building_block_definition.go @@ -3,9 +3,10 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/types" - "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/internal/http" ) type MeshBuildingBlockType string @@ -35,26 +36,26 @@ var ( ) type MeshBuildingBlockDefinitionMetadata struct { - Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` Tags map[string][]string `json:"tags" tfsdk:"tags"` } type MeshBuildingBlockDefinitionSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` - DisplayNameTemplate *string `json:"displayNameTemplate,omitempty" tfsdk:"display_name_template"` + DisplayNameTemplate *string `json:"displayNameTemplate,omitzero" tfsdk:"display_name_template"` TargetType MeshBuildingBlockType `json:"targetType" tfsdk:"target_type"` Description string `json:"description" tfsdk:"description"` - Readme *string `json:"readme,omitempty" tfsdk:"readme"` + Readme *string `json:"readme,omitzero" tfsdk:"readme"` RunTransparency bool `json:"runTransparency" tfsdk:"run_transparency"` ApprovalPolicies MeshBuildingBlockDefinitionApprovalPolicies `json:"approvalPolicies" tfsdk:"approval_policies"` Schedule MeshBuildingBlockDefinitionSchedule `json:"schedule" tfsdk:"schedule"` UseInLandingZonesOnly bool `json:"useInLandingZonesOnly" tfsdk:"use_in_landing_zones_only"` - SupportURL *string `json:"supportUrl,omitempty" tfsdk:"support_url"` - DocumentationURL *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` + SupportURL *string `json:"supportUrl,omitzero" tfsdk:"support_url"` + DocumentationURL *string `json:"documentationUrl,omitzero" tfsdk:"documentation_url"` // NotificationSubscribers can also specify emails with prefix 'email:', so it's not only usernames (as the JSON field name suggests)! NotificationSubscribers types.Set[string] `json:"notificationSubscriberUsernames,omitempty" tfsdk:"notification_subscribers"` - Symbol *string `json:"symbol,omitempty" tfsdk:"symbol"` + Symbol *string `json:"symbol,omitzero" tfsdk:"symbol"` SupportedPlatforms types.Set[NamedRef] `json:"supportedPlatforms" tfsdk:"supported_platforms"` } @@ -119,7 +120,7 @@ type MeshBuildingBlockDefinitionStatus struct { type MeshBuildingBlockDefinition struct { Metadata MeshBuildingBlockDefinitionMetadata `json:"metadata"` Spec MeshBuildingBlockDefinitionSpec `json:"spec"` - Status *MeshBuildingBlockDefinitionStatus `json:"status,omitempty"` + Status *MeshBuildingBlockDefinitionStatus `json:"status,omitzero"` } type MeshBuildingBlockDefinitionClient interface { @@ -149,7 +150,7 @@ type meshBuildingBlockDefinitionListQuery struct { } func (c meshBuildingBlockDefinitionClient) List(ctx context.Context, workspaceIdentifier *string) ([]MeshBuildingBlockDefinition, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(meshBuildingBlockDefinitionListQuery{ + return c.meshObject.List(ctx, http.WithUrlQuery(meshBuildingBlockDefinitionListQuery{ IncludeAllPublished: true, OwnedByWorkspace: workspaceIdentifier, })) diff --git a/client/building_block_definition_version.go b/client/building_block_definition_version.go index 1a0f5ecb..98a6c77b 100644 --- a/client/building_block_definition_version.go +++ b/client/building_block_definition_version.go @@ -2,13 +2,13 @@ package client import ( "context" - "encoding/json" + "encoding/json/v2" "errors" - "fmt" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/types" - "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/internal/http" ) // Enums @@ -42,14 +42,13 @@ var ( MeshBuildingBlockIOTypeSingleSelect = MeshBuildingBlockIOTypes.Entry("SINGLE_SELECT") MeshBuildingBlockIOTypeMultiSelect = MeshBuildingBlockIOTypes.Entry("MULTI_SELECT") - // A definition input declaring this type describes a form of its own, through the accompanying - // JsonSchema. That makes it a declaration-side type only, so deliberately not an entry of - // MeshBuildingBlockIOTypes: what the form produces is JSON text, which a building block's own inputs - // report as CODE. + // MeshBuildingBlockIOTypeJson is deliberately not an entry of MeshBuildingBlockIOTypes: a + // definition input declaring it describes a form of its own, through the accompanying JsonSchema. + // What that form produces is JSON text, which a building block's own inputs report as CODE. MeshBuildingBlockIOTypeJson = enum.Entry[MeshBuildingBlockIOType]("JSON") ) -// The types a definition input may declare. +// MeshBuildingBlockDefinitionInputTypes are the types a definition input may declare. var MeshBuildingBlockDefinitionInputTypes = MeshBuildingBlockIOTypes.With(MeshBuildingBlockIOTypeJson) var MeshBuildingBlockOutputIOTypes = enum.Of( @@ -133,14 +132,14 @@ type MeshBuildingBlockDefinitionInput struct { Argument types.SecretOrAny `json:"argument" tfsdk:"argument"` DefaultValue types.SecretOrAny `json:"defaultValue" tfsdk:"default_value"` UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` - IsOptional bool `json:"isOptional,omitempty" tfsdk:"is_optional"` + IsOptional bool `json:"isOptional,omitzero" tfsdk:"is_optional"` SelectableValues types.Set[string] `json:"selectableValues,omitempty" tfsdk:"selectable_values"` - Description *string `json:"description,omitempty" tfsdk:"description"` - ValueValidationRegex *string `json:"valueValidationRegex,omitempty" tfsdk:"value_validation_regex"` - ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitempty" tfsdk:"validation_regex_error_message"` + Description *string `json:"description,omitzero" tfsdk:"description"` + ValueValidationRegex *string `json:"valueValidationRegex,omitzero" tfsdk:"value_validation_regex"` + ValidationRegexErrorMessage *string `json:"validationRegexErrorMessage,omitzero" tfsdk:"validation_regex_error_message"` // The form this input is filled in through, as a JSON Schema string. Only for MeshBuildingBlockIOTypeJson. - JsonSchema *string `json:"jsonSchema,omitempty" tfsdk:"json_schema"` - Condition *string `json:"condition,omitempty" tfsdk:"condition"` + JsonSchema *string `json:"jsonSchema,omitzero" tfsdk:"json_schema"` + Condition *string `json:"condition,omitzero" tfsdk:"condition"` // No omitempty: a 0 (the schema default, and what an unknown plan value collapses to) must be sent so // the backend stores it verbatim. With omitempty the 0 would be dropped and the backend would assign // a position itself, making the applied value differ from the plan. @@ -171,7 +170,7 @@ func (m *MeshBuildingBlockDefinitionInput) UnmarshalJSON(bytes []byte) error { moveXtoYIfPresent(&m.DefaultValue) return errors.Join(errs...) case m.Argument.HasY(), m.DefaultValue.HasY(): - return fmt.Errorf("got sensitive argument or default_value but variant Y is set instead") + return errors.New("got sensitive argument or default_value but variant Y is set instead") default: return nil } @@ -199,8 +198,8 @@ type MeshBuildingBlockDefinitionVersionSpec struct { DeletionMode BuildingBlockDeletionMode `json:"deletionMode" tfsdk:"deletion_mode"` Permissions types.Set[ApiPermission] `json:"permissions,omitempty" tfsdk:"permissions"` Outputs map[string]MeshBuildingBlockDefinitionOutput `json:"outputs" tfsdk:"outputs"` - VersionNumber *int64 `json:"versionNumber,omitempty" tfsdk:"version_number"` - State *MeshBuildingBlockDefinitionVersionState `json:"state,omitempty" tfsdk:"state"` + VersionNumber *int64 `json:"versionNumber,omitzero" tfsdk:"version_number"` + State *MeshBuildingBlockDefinitionVersionState `json:"state,omitzero" tfsdk:"state"` RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` // Replaces the deprecated bare-UUID dependencyDefinitionUuids; requires a backend serving it. DependencyDefinitionRefs types.Set[UuidRef] `json:"dependencyDefinitionRefs,omitempty" tfsdk:"dependency_refs"` @@ -216,7 +215,7 @@ type MeshBuildingBlockDefinitionVersionStatus struct { type MeshBuildingBlockDefinitionVersion struct { Metadata MeshBuildingBlockDefinitionVersionMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshBuildingBlockDefinitionVersionSpec `json:"spec" tfsdk:"spec"` - Status *MeshBuildingBlockDefinitionVersionStatus `json:"status,omitempty" tfsdk:"status"` + Status *MeshBuildingBlockDefinitionVersionStatus `json:"status,omitzero" tfsdk:"status"` } // MeshBuildingBlockDefinitionVersionClient manages a version of a building block definition. @@ -243,7 +242,7 @@ type meshBuildingBlockDefinitionVersionListQuery struct { } func (c meshBuildingBlockDefinitionVersionClient) List(ctx context.Context, buildingBlockDefinitionUuid string) ([]MeshBuildingBlockDefinitionVersion, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(meshBuildingBlockDefinitionVersionListQuery{ + return c.meshObject.List(ctx, http.WithUrlQuery(meshBuildingBlockDefinitionVersionListQuery{ BuildingBlockDefinitionUuid: buildingBlockDefinitionUuid, })) } diff --git a/client/building_block_definition_version_implementation.go b/client/building_block_definition_version_implementation.go index d8ae2dc9..7c2182d3 100644 --- a/client/building_block_definition_version_implementation.go +++ b/client/building_block_definition_version_implementation.go @@ -1,12 +1,12 @@ package client import ( - "encoding/json" + "encoding/json/v2" "fmt" "reflect" - "github.com/meshcloud/terraform-provider-meshstack/client/types" - "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" ) type MeshBuildingBlockImplementationType string @@ -30,12 +30,12 @@ type MeshBuildingBlockDefinitionTerraformImplementation struct { TerraformVersion string `json:"terraformVersion" tfsdk:"terraform_version"` RepositoryURL string `json:"repositoryUrl" tfsdk:"repository_url"` Async bool `json:"async" tfsdk:"async"` - RepositoryPath *string `json:"repositoryPath,omitempty" tfsdk:"repository_path"` - RefName *string `json:"refName,omitempty" tfsdk:"ref_name"` - SSHKnownHost *MeshBuildingBlockDefinitionSshKnownHost `json:"sshKnownHost,omitempty" tfsdk:"ssh_known_host"` + RepositoryPath *string `json:"repositoryPath,omitzero" tfsdk:"repository_path"` + RefName *string `json:"refName,omitzero" tfsdk:"ref_name"` + SSHKnownHost *MeshBuildingBlockDefinitionSshKnownHost `json:"sshKnownHost,omitzero" tfsdk:"ssh_known_host"` UseMeshHTTPBackendFallback bool `json:"useMeshHttpBackendFallback" tfsdk:"use_mesh_http_backend_fallback"` - SSHPrivateKey *types.Secret `json:"sshPrivateKey,omitempty" tfsdk:"ssh_private_key"` - PreRunScript *string `json:"preRunScript,omitempty" tfsdk:"pre_run_script"` + SSHPrivateKey *types.Secret `json:"sshPrivateKey,omitzero" tfsdk:"ssh_private_key"` + PreRunScript *string `json:"preRunScript,omitzero" tfsdk:"pre_run_script"` } type MeshBuildingBlockDefinitionGitHubWorkflowsImplementation struct { @@ -48,8 +48,7 @@ type MeshBuildingBlockDefinitionGitHubWorkflowsImplementation struct { IntegrationRef UuidRef `json:"integrationRef" tfsdk:"integration_ref"` } -type MeshBuildingBlockDefinitionManualImplementation struct { -} +type MeshBuildingBlockDefinitionManualImplementation struct{} type MeshBuildingBlockDefinitionGitLabPipelineImplementation struct { ProjectID string `json:"projectId" tfsdk:"project_id"` @@ -61,18 +60,18 @@ type MeshBuildingBlockDefinitionGitLabPipelineImplementation struct { type MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation struct { Project string `json:"project" tfsdk:"project"` PipelineID string `json:"pipelineId" tfsdk:"pipeline_id"` - RefName *string `json:"refName,omitempty" tfsdk:"ref_name"` + RefName *string `json:"refName,omitzero" tfsdk:"ref_name"` Async bool `json:"async" tfsdk:"async"` IntegrationRef UuidRef `json:"integrationRef" tfsdk:"integration_ref"` } type MeshBuildingBlockDefinitionImplementation struct { Type enum.Entry[MeshBuildingBlockImplementationType] `json:"type" tfsdk:"-"` - Manual *MeshBuildingBlockDefinitionManualImplementation `json:"manual,omitempty" tfsdk:"manual"` - GithubWorkflows *MeshBuildingBlockDefinitionGitHubWorkflowsImplementation `json:"githubWorkflows,omitempty" tfsdk:"github_workflows"` - AzureDevOpsPipeline *MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation `json:"azureDevOpsPipeline,omitempty" tfsdk:"azure_devops_pipeline"` - GitlabPipeline *MeshBuildingBlockDefinitionGitLabPipelineImplementation `json:"gitlabPipeline,omitempty" tfsdk:"gitlab_pipeline"` - Terraform *MeshBuildingBlockDefinitionTerraformImplementation `json:"terraform,omitempty" tfsdk:"terraform"` + Manual *MeshBuildingBlockDefinitionManualImplementation `json:"manual,omitzero" tfsdk:"manual"` + GithubWorkflows *MeshBuildingBlockDefinitionGitHubWorkflowsImplementation `json:"githubWorkflows,omitzero" tfsdk:"github_workflows"` + AzureDevOpsPipeline *MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation `json:"azureDevOpsPipeline,omitzero" tfsdk:"azure_devops_pipeline"` + GitlabPipeline *MeshBuildingBlockDefinitionGitLabPipelineImplementation `json:"gitlabPipeline,omitzero" tfsdk:"gitlab_pipeline"` + Terraform *MeshBuildingBlockDefinitionTerraformImplementation `json:"terraform,omitzero" tfsdk:"terraform"` } func (m MeshBuildingBlockDefinitionImplementation) InferTypeFromNonNilField() (result enum.Entry[MeshBuildingBlockImplementationType]) { @@ -97,11 +96,12 @@ func (m MeshBuildingBlockDefinitionImplementation) InferTypeFromNonNilField() (r } func (m MeshBuildingBlockDefinitionImplementation) MarshalJSON() ([]byte, error) { - if len(m.Type) == 0 { - m.Type = m.InferTypeFromNonNilField() - } type wrapped MeshBuildingBlockDefinitionImplementation - return json.Marshal(wrapped(m)) + w := wrapped(m) + if len(w.Type) == 0 { + w.Type = m.InferTypeFromNonNilField() + } + return json.Marshal(w, wireCompatibility) } func (m *MeshBuildingBlockDefinitionImplementation) UnmarshalJSON(bytes []byte) error { diff --git a/client/building_block_definition_version_test.go b/client/building_block_definition_version_test.go index 302b8c1f..ea0c46b9 100644 --- a/client/building_block_definition_version_test.go +++ b/client/building_block_definition_version_test.go @@ -2,20 +2,18 @@ package client import ( "embed" - "encoding/json" + "encoding/json/v2" "path" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/meshstack-cli/client/types" ) -var ( - //go:embed testdata/building_block_definition_version_input - bbdInputTestdata embed.FS -) +//go:embed testdata/building_block_definition_version_input +var bbdInputTestdata embed.FS func TestMeshBuildingBlockDefinitionInput_UnmarshalJSON(t *testing.T) { tests := []struct { @@ -29,7 +27,7 @@ func TestMeshBuildingBlockDefinitionInput_UnmarshalJSON(t *testing.T) { {"not_sensitive", false, types.SecretOrAny{Y: true}, types.SecretOrAny{Y: "some-string"}, assert.NoError}, {"not_sensitive_but_hash", false, types.SecretOrAny{Y: map[string]any{"hash": "some-hash-looks-like-secret"}}, types.SecretOrAny{}, assert.NoError}, {"sensitive", true, types.SecretOrAny{}, types.SecretOrAny{X: types.Secret{Hash: new("some-hash")}}, assert.NoError}, - {"sensitive_but_no_hash", true, types.SecretOrAny{Y: map[string]any{}}, types.SecretOrAny{}, func(t assert.TestingT, err error, msgAndArgs ...any) bool { + {"sensitive_but_no_hash", true, types.SecretOrAny{Y: map[string]any{}}, types.SecretOrAny{}, func(t assert.TestingT, err error, _ ...any) bool { return assert.ErrorContains(t, err, "got sensitive argument or default_value but variant Y is set instead") }}, } diff --git a/client/building_block_run.go b/client/building_block_run.go index 2083188d..f57c66e7 100644 --- a/client/building_block_run.go +++ b/client/building_block_run.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshBuildingBlockRun struct { @@ -50,11 +50,5 @@ func newBuildingBlockRunClient(ctx context.Context, httpClient internal.HttpClie } func (c meshBuildingBlockRunClient) GetLogs(ctx context.Context, runUuid string) (MeshBuildingBlockRunLogs, error) { - return internal.DoAuthorizedRequest[MeshBuildingBlockRunLogs]( - ctx, - c.meshObject.HttpClient, - "GET", - c.meshObject.ApiUrl.JoinPath(runUuid, "logs"), - internal.WithAccept(c.meshObject.MeshObjectMimeType()), - ) + return c.meshObject.GetAtPath[MeshBuildingBlockRunLogs](ctx, runUuid, "logs") } diff --git a/client/building_block_runner.go b/client/building_block_runner.go index 8376af36..fd549529 100644 --- a/client/building_block_runner.go +++ b/client/building_block_runner.go @@ -2,13 +2,14 @@ package client import ( "context" - "fmt" + "errors" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshBuildingBlockRunnerImplementationType string +// TODO Turn this into enum! const ( MeshBuildingBlockRunnerImplementationTypeTerraform MeshBuildingBlockRunnerImplementationType = "TERRAFORM" MeshBuildingBlockRunnerImplementationTypeGithubWorkflow MeshBuildingBlockRunnerImplementationType = "GITHUB_WORKFLOW" @@ -33,27 +34,27 @@ type MeshBuildingBlockRunner struct { } type MeshBuildingBlockRunnerMetadata struct { - Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - CreatedOn *string `json:"createdOn,omitempty" tfsdk:"created_on"` - LastSeen *string `json:"lastSeen,omitempty" tfsdk:"last_seen"` + CreatedOn *string `json:"createdOn,omitzero" tfsdk:"created_on"` + LastSeen *string `json:"lastSeen,omitzero" tfsdk:"last_seen"` } type MeshBuildingBlockRunnerSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` PublicKey string `json:"publicKey" tfsdk:"public_key"` ImplementationType string `json:"implementationType" tfsdk:"implementation_type"` - Restriction *string `json:"restriction,omitempty" tfsdk:"restriction"` - IsSelfHosted *bool `json:"isSelfHosted,omitempty" tfsdk:"is_self_hosted"` - WorkloadIdentityFederation *MeshRunnerWorkloadIdentityFed `json:"workloadIdentityFederation,omitempty" tfsdk:"workload_identity_federation"` + Restriction *string `json:"restriction,omitzero" tfsdk:"restriction"` + IsSelfHosted *bool `json:"isSelfHosted,omitzero" tfsdk:"is_self_hosted"` + WorkloadIdentityFederation *MeshRunnerWorkloadIdentityFed `json:"workloadIdentityFederation,omitzero" tfsdk:"workload_identity_federation"` } type MeshRunnerWorkloadIdentityFed struct { - Subject *string `json:"subject,omitempty" tfsdk:"subject"` - Issuer *string `json:"issuer,omitempty" tfsdk:"issuer"` - Gcp *MeshRunnerWifProviderConfig `json:"gcp,omitempty" tfsdk:"gcp"` - Aws *MeshRunnerWifProviderConfig `json:"aws,omitempty" tfsdk:"aws"` - Azure *MeshRunnerWifProviderConfig `json:"azure,omitempty" tfsdk:"azure"` + Subject *string `json:"subject,omitzero" tfsdk:"subject"` + Issuer *string `json:"issuer,omitzero" tfsdk:"issuer"` + Gcp *MeshRunnerWifProviderConfig `json:"gcp,omitzero" tfsdk:"gcp"` + Aws *MeshRunnerWifProviderConfig `json:"aws,omitzero" tfsdk:"aws"` + Azure *MeshRunnerWifProviderConfig `json:"azure,omitzero" tfsdk:"azure"` } type MeshRunnerWifProviderConfig struct { @@ -86,7 +87,7 @@ func (c meshBuildingBlockRunnerClient) Read(ctx context.Context, uuid string) (* func (c meshBuildingBlockRunnerClient) Update(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) { if runner.Metadata.Uuid == nil || *runner.Metadata.Uuid == "" { - return nil, fmt.Errorf("missing metadata.uuid") + return nil, errors.New("missing metadata.uuid") } return c.meshObject.Put(ctx, *runner.Metadata.Uuid, runner) diff --git a/client/building_block_v2.go b/client/building_block_v2.go index 32171d8b..c8e3eda4 100644 --- a/client/building_block_v2.go +++ b/client/building_block_v2.go @@ -2,14 +2,16 @@ package client import ( "context" - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "errors" "fmt" "slices" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/types" - "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/internal/http" ) type BuildingBlockLifecycleState string @@ -94,6 +96,15 @@ func (p *MeshBuildingBlockV2Parent) UnmarshalJSON(data []byte) error { return nil } +// wireCompatibility repeats the options internal/json marshals every request with: a v1-style +// MarshalJSON receives none of its caller's, so without it a nested value would go out in a +// different shape than the request around it. +var wireCompatibility = json.JoinOptions( + json.Deterministic(true), + json.FormatNilSliceAsNull(true), + json.FormatNilMapAsNull(true), +) + // MarshalJSON sends the parents under both field names: parentBuildingBlockRefs, and the deprecated // parentBuildingBlocks for a backend that does not know the new field yet. A newer backend accepts // both as long as they name the same building blocks, and an older one ignores the field it does not @@ -103,17 +114,15 @@ func (p *MeshBuildingBlockV2Parent) UnmarshalJSON(data []byte) error { // knows parentBuildingBlockRefs, both methods can go. func (s MeshBuildingBlockV2Spec) MarshalJSON() ([]byte, error) { type wire MeshBuildingBlockV2Spec - if len(s.ParentBuildingBlockRefs) == 0 { - s.ParentBuildingBlockRefs = parentRefsFromDeprecated(s.ParentBuildingBlocks) + w := wire(s) + if len(w.ParentBuildingBlockRefs) == 0 { + w.ParentBuildingBlockRefs = parentRefsFromDeprecated(w.ParentBuildingBlocks) } - encoded, err := json.Marshal(wire(s)) - if err != nil { + var fields map[string]jsontext.Value + if encoded, err := json.Marshal(w, wireCompatibility); err != nil { return nil, err - } - - var fields map[string]json.RawMessage - if err := json.Unmarshal(encoded, &fields); err != nil { + } else if err := json.Unmarshal(encoded, &fields); err != nil { return nil, err } @@ -127,11 +136,12 @@ func (s MeshBuildingBlockV2Spec) MarshalJSON() ([]byte, error) { BuildingBlockUuid string `json:"buildingBlockUuid"` }{BuildingBlockUuid: ref.Uuid}) } + var err error if fields["parentBuildingBlocks"], err = json.Marshal(parents); err != nil { return nil, err } - return json.Marshal(fields) + return json.Marshal(fields, wireCompatibility) } func parentRefsFromDeprecated(parents types.Set[MeshBuildingBlockV2Parent]) types.Set[UuidRef] { @@ -149,6 +159,7 @@ func (s *MeshBuildingBlockV2Spec) UnmarshalJSON(data []byte) error { type wire MeshBuildingBlockV2Spec var target struct { wire + ParentBuildingBlocks types.Set[MeshBuildingBlockV2Parent] `json:"parentBuildingBlocks"` } if err := json.Unmarshal(data, &target); err != nil { @@ -171,7 +182,7 @@ func (s *MeshBuildingBlockV2Spec) UnmarshalJSON(data []byte) error { type MeshBuildingBlockInput struct { Value types.SecretOrAny `json:"value" tfsdk:"value"` - ValueType *enum.Entry[MeshBuildingBlockIOType] `json:"valueType,omitempty" tfsdk:"-"` + ValueType *enum.Entry[MeshBuildingBlockIOType] `json:"valueType,omitzero" tfsdk:"-"` AssignmentType enum.Entry[MeshBuildingBlockInputAssignmentType] `json:"assignmentType,omitempty" tfsdk:"-"` // If IsSensitive is true, the [types.Variant] (typedef [types.SecretOrAny]) for Value field @@ -205,7 +216,7 @@ func (m *MeshBuildingBlockInput) UnmarshalJSON(bytes []byte) error { moveXtoYIfPresent(&m.Value) return errors.Join(errs...) case m.Value.HasY(): - return fmt.Errorf("got sensitive argument or default_value but variant Y is set instead") + return errors.New("got sensitive argument or default_value but variant Y is set instead") default: return nil } @@ -213,6 +224,7 @@ func (m *MeshBuildingBlockInput) UnmarshalJSON(bytes []byte) error { type MeshBuildingBlockV2DefinitionVersionRef struct { UuidRef + // ContentHash is a Terraform-only field (json:"-", never sent to or returned by the backend). // It lets a config signal that the referenced version's content changed so a rerun is triggered // even though the version uuid is unchanged. The building_block (v3) resource honors it via the @@ -235,11 +247,12 @@ type MeshBuildingBlockV2Status struct { Outputs map[string]MeshBuildingBlockOutput `json:"outputs" tfsdk:"outputs"` ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"-"` - // LatestRunUuid is nil if permissions don't allow reading the run (e.g. because run_transparency is false). - // It tracks the latest *modifying* (apply/destroy) run and excludes dry runs. + // LatestRunUuid tracks the latest *modifying* (apply/destroy) run and excludes dry runs. It is nil + // only when no such run exists: run_transparency gates reading the run and its system messages, not + // this identifier (MeshBuildingBlockV2RepresentationModelAssembler.resolveLatestRunUuid). LatestRunUuid *string `json:"latestRunUuid" tfsdk:"latest_run_uuid"` // LatestDryRunUuid is the latest dry (DETECT) run, but only when it is the newest run; nil otherwise. - // Same permission gating and nullability caveat as LatestRunUuid. + // Ungated like LatestRunUuid. LatestDryRunUuid *string `json:"latestDryRunUuid" tfsdk:"latest_dry_run_uuid"` } @@ -307,7 +320,7 @@ func (c meshBuildingBlockV2Client) ReadFunc(uuid string) func(ctx context.Contex } func (c meshBuildingBlockV2Client) List(ctx context.Context, filter MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(filter)) + return c.meshObject.List(ctx, http.WithUrlQuery(filter)) } func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { @@ -316,17 +329,16 @@ func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingB func (c meshBuildingBlockV2Client) Update(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { if bb.Metadata.Uuid == nil { - return nil, fmt.Errorf("cannot update building block without UUID") + return nil, errors.New("cannot update building block without UUID") } return c.meshObject.Put(ctx, *bb.Metadata.Uuid, bb) } func (c meshBuildingBlockV2Client) Delete(ctx context.Context, uuid string, purge bool) error { - var options []internal.RequestOption if purge { - options = append(options, internal.WithPathElems("purge")) + return c.meshObject.DeleteAtPath(ctx, uuid, "purge") } - return c.meshObject.Delete(ctx, uuid, options...) + return c.meshObject.Delete(ctx, uuid) } // IsWaitingForInput reports whether the building block run is paused awaiting @@ -351,7 +363,7 @@ func bbUuidOrUnknown(bb *MeshBuildingBlockV2) string { func (bb *MeshBuildingBlockV2) CreateSuccessful() (done bool, err error) { switch { case bb == nil: - err = fmt.Errorf("building block not found after creation") + err = errors.New("building block not found after creation") case bb.Status == nil: // no status yet — keep polling case bb.Status.Status == BuildingBlockStatusFailed, @@ -392,15 +404,11 @@ func (bb *MeshBuildingBlockV2) DeletionSuccessful() (done bool, err error) { return } -func (c meshBuildingBlockV2Client) TriggerRun(ctx context.Context, bbUuid string) error { - // trigger-run returns an empty 2xx body; use DoAuthorizedRequest[any] to signal no body expected. - // No body is sent, so the backend triggers a normal (non-dry) apply run. - _, err := internal.DoAuthorizedRequest[any]( - ctx, - c.meshObject.HttpClient, - "POST", - c.meshObject.ApiUrl.JoinPath(bbUuid, "trigger-run"), - internal.WithAccept(c.meshObject.MeshObjectMimeType()), - ) - return err +func (c meshBuildingBlockV2Client) TriggerRun(ctx context.Context, bbUuid string) (err error) { + // dryRun is not optional to the endpoint once a body is sent, so it goes out as false rather + // than being omitted. + _, err = c.meshObject.PostAtPath[any](ctx, struct { + DryRun bool `json:"dryRun"` + }{}, bbUuid, "trigger-run") + return } diff --git a/client/building_block_v2_test.go b/client/building_block_v2_test.go index 07440af4..0b0dd979 100644 --- a/client/building_block_v2_test.go +++ b/client/building_block_v2_test.go @@ -1,23 +1,12 @@ package client import ( - "encoding/json" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" -) - -const ( - testParentUuid = "11111111-1111-1111-1111-111111111111" - testParentDefinitionUuid = "22222222-2222-2222-2222-222222222222" - // testParentRef is the shape this provider sends for a parent in parentBuildingBlockRefs. - testParentRef = `{"kind": "meshBuildingBlock", "uuid": "` + testParentUuid + `"}` - // testDeprecatedParent is what the provider sends alongside it, for a backend that does not know - // parentBuildingBlockRefs yet. - testDeprecatedParent = `{"buildingBlockUuid": "` + testParentUuid + `"}` + "github.com/meshcloud/meshstack-cli/client/types/enum" ) func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { @@ -204,104 +193,3 @@ func TestMeshBuildingBlockV2_CreateSuccessful(t *testing.T) { }) } } - -// TestMeshBuildingBlockV2Parent_UnmarshalJSON covers every response shape. Terraform has to see the -// same {kind, uuid} against every backend, so that set hashing and UseStateForUnknown stay stable. -func TestMeshBuildingBlockV2Parent_UnmarshalJSON(t *testing.T) { - tests := []struct { - name string - response string - wantDefinitionUuid string - }{ - { - // An older backend reports the parent inside a buildingBlockRef envelope, which this provider - // does not read, so only the deprecated field carries the uuid. - name: "enveloped response without a top-level uuid", - response: `{ - "buildingBlockRef": {"kind": "meshBuildingBlock", "uuid": "` + testParentUuid + `"}, - "buildingBlockUuid": "` + testParentUuid + `", - "definitionUuid": "` + testParentDefinitionUuid + `" - }`, - wantDefinitionUuid: testParentDefinitionUuid, - }, - { - name: "flattened response that also carries a top-level uuid", - response: `{ - "kind": "meshBuildingBlock", - "uuid": "` + testParentUuid + `", - "buildingBlockUuid": "` + testParentUuid + `", - "definitionUuid": "` + testParentDefinitionUuid + `" - }`, - wantDefinitionUuid: testParentDefinitionUuid, - }, - { - name: "flattened response once the deprecated fields are gone", - response: `{"kind": "meshBuildingBlock", "uuid": "` + testParentUuid + `"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var parent MeshBuildingBlockV2Parent - require.NoError(t, json.Unmarshal([]byte(tt.response), &parent)) - assert.Equal(t, MeshBuildingBlockV2Parent{ - UuidRef: UuidRef{Kind: MeshObjectKind.BuildingBlock, Uuid: testParentUuid}, - BuildingBlockUuid: testParentUuid, - DefinitionUuid: tt.wantDefinitionUuid, - }, parent) - }) - } -} - -// TestMeshBuildingBlockV2Spec_ParentsRoundTrip goes through the whole spec, so a change to the json -// tag of parentBuildingBlockRefs or to the Set element type is caught too. -func TestMeshBuildingBlockV2Spec_ParentsRoundTrip(t *testing.T) { - const response = `{ - "buildingBlockDefinitionVersionRef": {"kind": "meshBuildingBlockDefinitionVersion", "uuid": "33333333-3333-3333-3333-333333333333"}, - "targetRef": {"kind": "meshWorkspace", "name": "my-workspace"}, - "displayName": "child", - "inputs": {}, - "parentBuildingBlockRefs": [` + testParentRef + `], - "parentBuildingBlocks": [{"buildingBlockUuid": "` + testParentUuid + `", "definitionUuid": "` + testParentDefinitionUuid + `"}] - }` - - var spec MeshBuildingBlockV2Spec - require.NoError(t, json.Unmarshal([]byte(response), &spec)) - require.Len(t, spec.ParentBuildingBlockRefs, 1) - assert.Equal(t, UuidRef{Kind: MeshObjectKind.BuildingBlock, Uuid: testParentUuid}, spec.ParentBuildingBlockRefs[0]) - require.Len(t, spec.ParentBuildingBlocks, 1) - assert.Equal(t, testParentDefinitionUuid, spec.ParentBuildingBlocks[0].DefinitionUuid) - - assertSentUnderBothFieldNames(t, spec) -} - -// TestMeshBuildingBlockV2Spec_ParentsFromDeprecatedFieldOnly covers a backend that does not serve -// parentBuildingBlockRefs yet, and the deprecated meshstack_building_block_v2 surfaces, which fill -// only the deprecated field. -func TestMeshBuildingBlockV2Spec_ParentsFromDeprecatedFieldOnly(t *testing.T) { - const response = `{ - "buildingBlockDefinitionVersionRef": {"kind": "meshBuildingBlockDefinitionVersion", "uuid": "33333333-3333-3333-3333-333333333333"}, - "targetRef": {"kind": "meshWorkspace", "name": "my-workspace"}, - "displayName": "child", - "inputs": {}, - "parentBuildingBlocks": [{"buildingBlockUuid": "` + testParentUuid + `", "definitionUuid": "` + testParentDefinitionUuid + `"}] - }` - - var spec MeshBuildingBlockV2Spec - require.NoError(t, json.Unmarshal([]byte(response), &spec)) - require.Len(t, spec.ParentBuildingBlockRefs, 1) - assert.Equal(t, UuidRef{Kind: MeshObjectKind.BuildingBlock, Uuid: testParentUuid}, spec.ParentBuildingBlockRefs[0]) - - assertSentUnderBothFieldNames(t, spec) -} - -func assertSentUnderBothFieldNames(t *testing.T, spec MeshBuildingBlockV2Spec) { - t.Helper() - - out, err := json.Marshal(spec) - require.NoError(t, err) - var request map[string]json.RawMessage - require.NoError(t, json.Unmarshal(out, &request)) - assert.JSONEq(t, "["+testParentRef+"]", string(request["parentBuildingBlockRefs"])) - assert.JSONEq(t, "["+testDeprecatedParent+"]", string(request["parentBuildingBlocks"])) -} diff --git a/client/buildingblock.go b/client/buildingblock.go index c8db2312..94dd4e1c 100644 --- a/client/buildingblock.go +++ b/client/buildingblock.go @@ -3,9 +3,10 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) +//nolint:staticcheck // ST1003: deprecated names, kept as published; the v2 building block client and the IO type enum supersede them const ( MESH_BUILDING_BLOCK_IO_TYPE_STRING = "STRING" MESH_BUILDING_BLOCK_IO_TYPE_INTEGER = "INTEGER" diff --git a/client/client.go b/client/client.go index a404ed72..c22709f3 100644 --- a/client/client.go +++ b/client/client.go @@ -2,20 +2,24 @@ package client import ( "context" - "fmt" - "net/url" - "os" - "time" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/version" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/version" ) var MinMeshStackVersion = version.MustParse("2026.36.0") +// Version is re-exported because MinMeshStackVersion is one and internal/version is closed to +// another module. +type Version = version.Version + // HttpError represents an HTTP error response with status code. // This error is returned when an HTTP request fails with a non-2XX status code. -type HttpError = internal.HttpError +type HttpError = http.Error + +type Authorization = http.Authorization type Client struct { ApiKey MeshApiKeyClient @@ -41,84 +45,42 @@ type Client struct { Workspace MeshWorkspaceClient WorkspaceGroupBinding MeshWorkspaceGroupBindingClient WorkspaceUserBinding MeshWorkspaceUserBindingClient -} - -type Authorization = internal.Authorization - -func NewApiTokenAuthorization(apiToken string) Authorization { - return internal.BearerTokenAuthorization{Token: apiToken} -} - -const apiLoginPath = "/api/login" -func NewApiKeyAuthorization(apiKey, apiSecret string) Authorization { - return internal.NewClientSecretAuthorization(apiLoginPath, apiKey, apiSecret) + // Endpoint is read by the Terraform provider's meshstack_instance data source. + Endpoint xurl.URL } -func New(ctx context.Context, rootUrl *url.URL, userAgent string, auth Authorization) (Client, error) { - httpClient := internal.WithRetry( - internal.NewHttpClient(rootUrl, userAgent, auth), - internal.RetryOptions{ - // Sized to ride out a full meshStack backend restart (e.g. an OOMKill followed by a - // Spring Boot cold start), which can leave the gateway returning 503 for ~2-3 minutes — - // well beyond the previous ~75s budget. This backoff sequence sums to ~4 minutes: - // 1+2+4+8+16+30*7 seconds. - MaxRetries: 12, - Backoff: internal.ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 30 * time.Second}, - WhitelistedPaths: map[string][]string{"POST": {apiLoginPath}}, - }, - ) - - meshInfoClient := newMeshInfoClient(httpClient) - if err := checkMeshVersion(ctx, meshInfoClient); err != nil { - return Client{}, err +func New(ctx context.Context, endpoint xurl.URL, userAgent string, auth Authorization) Client { + client := http.NewClient(userAgent) + authorizedClient := internal.HttpClient{ + AuthorizedClient: client.WithAuthorization(auth), + EndpointUrl: endpoint, } - return Client{ - ApiKey: newApiKeyClient(ctx, httpClient), - BuildingBlock: newBuildingBlockClient(ctx, httpClient), - BuildingBlockV2: newBuildingBlockV2Client(ctx, httpClient), - BuildingBlockRun: newBuildingBlockRunClient(ctx, httpClient), - BuildingBlockDefinition: newBuildingBlockDefinitionClient(ctx, httpClient), - BuildingBlockDefinitionVersion: newBuildingBlockDefinitionVersionClient(ctx, httpClient), - BuildingBlockRunner: newBuildingBlockRunnerClient(ctx, httpClient), - Integration: newIntegrationClient(ctx, httpClient), - LandingZone: newLandingZoneClient(ctx, httpClient), - Location: newLocationClient(ctx, httpClient), - MeshInfo: meshInfoClient, - PaymentMethod: newPaymentMethodClient(ctx, httpClient), - Platform: newPlatformClient(ctx, httpClient), - PlatformType: newPlatformTypeClient(ctx, httpClient), - Project: newProjectClient(ctx, httpClient), - ProjectGroupBinding: newProjectGroupBindingClient(ctx, httpClient), - ProjectUserBinding: newProjectUserBindingClient(ctx, httpClient), - ServiceInstance: newServiceInstanceClient(ctx, httpClient), - TagDefinition: newTagDefinitionClient(ctx, httpClient), - Tenant: newTenantClient(ctx, httpClient), - Workspace: newWorkspaceClient(ctx, httpClient), - WorkspaceGroupBinding: newWorkspaceGroupBindingClient(ctx, httpClient), - WorkspaceUserBinding: newWorkspaceUserBindingClient(ctx, httpClient), - }, nil -} + ApiKey: newApiKeyClient(ctx, authorizedClient), + BuildingBlock: newBuildingBlockClient(ctx, authorizedClient), + BuildingBlockV2: newBuildingBlockV2Client(ctx, authorizedClient), + BuildingBlockRun: newBuildingBlockRunClient(ctx, authorizedClient), + BuildingBlockDefinition: newBuildingBlockDefinitionClient(ctx, authorizedClient), + BuildingBlockDefinitionVersion: newBuildingBlockDefinitionVersionClient(ctx, authorizedClient), + BuildingBlockRunner: newBuildingBlockRunnerClient(ctx, authorizedClient), + Integration: newIntegrationClient(ctx, authorizedClient), + LandingZone: newLandingZoneClient(ctx, authorizedClient), + Location: newLocationClient(ctx, authorizedClient), + MeshInfo: NewMeshInfoClient(client, endpoint), + PaymentMethod: newPaymentMethodClient(ctx, authorizedClient), + Platform: newPlatformClient(ctx, authorizedClient), + PlatformType: newPlatformTypeClient(ctx, authorizedClient), + Project: newProjectClient(ctx, authorizedClient), + ProjectGroupBinding: newProjectGroupBindingClient(ctx, authorizedClient), + ProjectUserBinding: newProjectUserBindingClient(ctx, authorizedClient), + ServiceInstance: newServiceInstanceClient(ctx, authorizedClient), + TagDefinition: newTagDefinitionClient(ctx, authorizedClient), + Tenant: newTenantClient(ctx, authorizedClient), + Workspace: newWorkspaceClient(ctx, authorizedClient), + WorkspaceGroupBinding: newWorkspaceGroupBindingClient(ctx, authorizedClient), + WorkspaceUserBinding: newWorkspaceUserBindingClient(ctx, authorizedClient), -func checkMeshVersion(ctx context.Context, meshInfoClient MeshInfoClient) error { - // Skip before the request, not just before the comparison: /mesh/info is a GET on the retrying - // client, so an unavailable backend blocks provider configuration for the whole retry budget - // (~4 minutes) and then fails it. Opting out of the check has to opt out of that too. - if os.Getenv("MESHSTACK_SKIP_VERSION_CHECK") == "true" { - return nil - } - - info, err := meshInfoClient.Read(ctx) - if err != nil { - return err - } - meshVersion, err := version.Parse(info.Version) - if err != nil { - return fmt.Errorf("failed to parse meshStack version %q: %w", info.Version, err) - } - if meshVersion.Less(MinMeshStackVersion) { - return fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshVersion, MinMeshStackVersion) + Endpoint: endpoint, } - return nil } diff --git a/client/client_kind_test.go b/client/client_kind_test.go index 31a6b20f..7d195720 100644 --- a/client/client_kind_test.go +++ b/client/client_kind_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) func TestKind(t *testing.T) { diff --git a/client/client_logging.go b/client/client_logging.go deleted file mode 100644 index ed6979a7..00000000 --- a/client/client_logging.go +++ /dev/null @@ -1,11 +0,0 @@ -package client - -import "github.com/meshcloud/terraform-provider-meshstack/client/internal" - -// Logger exposes logging for client operations within this package (including internal). -type Logger = internal.Logger - -// SetLogger allows setting the client logger. By default, no logging happens. -func SetLogger(logger Logger) { - internal.Log = logger -} diff --git a/client/client_test.go b/client/client_test.go deleted file mode 100644 index 73539ccb..00000000 --- a/client/client_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package client - -import ( - "errors" - "net/http" - "net/url" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/meshcloud/terraform-provider-meshstack/client/internal" -) - -type erroringRoundTripper struct{ calls int } - -func (rt *erroringRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { - rt.calls++ - return nil, errors.New("no server is available to handle this request") -} - -func TestCheckMeshVersion_SkipsRequestWhenOptedOut(t *testing.T) { - newUnreachableClient := func() (internal.HttpClient, *erroringRoundTripper) { - transport := new(erroringRoundTripper) - httpClient := internal.NewHttpClient(&url.URL{Scheme: "https", Host: "meshstack.invalid"}, "test-agent", nil) - httpClient.Transport = transport - return httpClient, transport - } - - t.Run("MESHSTACK_SKIP_VERSION_CHECK=true skips the /mesh/info request entirely", func(t *testing.T) { - t.Setenv("MESHSTACK_SKIP_VERSION_CHECK", "true") - httpClient, transport := newUnreachableClient() - require.NoError(t, checkMeshVersion(t.Context(), newMeshInfoClient(httpClient))) - assert.Zero(t, transport.calls, "opting out of the version check must not send a request that can block on retries") - }) - - t.Run("without the opt-out an unreachable /mesh/info fails", func(t *testing.T) { - t.Setenv("MESHSTACK_SKIP_VERSION_CHECK", "") - httpClient, transport := newUnreachableClient() - err := checkMeshVersion(t.Context(), newMeshInfoClient(httpClient)) - require.ErrorContains(t, err, "failed to retrieve meshStack instance information") - assert.Equal(t, 1, transport.calls) - }) -} diff --git a/client/integration.go b/client/integration.go index 17536475..582a224f 100644 --- a/client/integration.go +++ b/client/integration.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshIntegration struct { @@ -13,7 +13,7 @@ type MeshIntegration struct { } type MeshIntegrationMetadata struct { - Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } diff --git a/client/integration_config.go b/client/integration_config.go index 5c5a116d..874553eb 100644 --- a/client/integration_config.go +++ b/client/integration_config.go @@ -1,12 +1,12 @@ package client import ( - "encoding/json" + "encoding/json/v2" "fmt" "reflect" - "github.com/meshcloud/terraform-provider-meshstack/client/types" - "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" ) type MeshIntegrationConfigType string @@ -43,18 +43,18 @@ type MeshIntegrationEntraIdConfig struct { TenantId string `json:"tenantId" tfsdk:"tenant_id"` ClientId string `json:"clientId" tfsdk:"client_id"` ClientSecret types.Secret `json:"clientSecret" tfsdk:"client_secret"` - IdpAlias *string `json:"idpAlias,omitempty" tfsdk:"idp_alias"` + IdpAlias *string `json:"idpAlias,omitzero" tfsdk:"idp_alias"` // meshStack derives this and returns it inside spec, which configuration writes. A computed value // there is unreachable under provider mocks (issue #272), so Terraform reads it from status instead. - RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"-"` + RedirectUrl *string `json:"redirectUrl,omitzero" tfsdk:"-"` } type MeshIntegrationConfig struct { Type enum.Entry[MeshIntegrationConfigType] `json:"type" tfsdk:"-"` - Github *MeshIntegrationGithubConfig `json:"github,omitempty" tfsdk:"github"` - Gitlab *MeshIntegrationGitlabConfig `json:"gitlab,omitempty" tfsdk:"gitlab"` - AzureDevops *MeshIntegrationAzureDevopsConfig `json:"azuredevops,omitempty" tfsdk:"azuredevops"` - EntraId *MeshIntegrationEntraIdConfig `json:"entraid,omitempty" tfsdk:"entraid"` + Github *MeshIntegrationGithubConfig `json:"github,omitzero" tfsdk:"github"` + Gitlab *MeshIntegrationGitlabConfig `json:"gitlab,omitzero" tfsdk:"gitlab"` + AzureDevops *MeshIntegrationAzureDevopsConfig `json:"azuredevops,omitzero" tfsdk:"azuredevops"` + EntraId *MeshIntegrationEntraIdConfig `json:"entraid,omitzero" tfsdk:"entraid"` } func (m MeshIntegrationConfig) InferTypeFromNonNilField() (result enum.Entry[MeshIntegrationConfigType]) { @@ -77,10 +77,11 @@ func (m MeshIntegrationConfig) InferTypeFromNonNilField() (result enum.Entry[Mes } func (m MeshIntegrationConfig) MarshalJSON() ([]byte, error) { - m.Type = m.InferTypeFromNonNilField() // Using wrapped type avoids calling MarshalJSON recursively! type wrapped MeshIntegrationConfig - return json.Marshal(wrapped(m)) + w := wrapped(m) + w.Type = m.InferTypeFromNonNilField() + return json.Marshal(w, wireCompatibility) } func (m *MeshIntegrationConfig) UnmarshalJSON(bytes []byte) error { diff --git a/client/internal/auth.go b/client/internal/auth.go deleted file mode 100644 index 86341000..00000000 --- a/client/internal/auth.go +++ /dev/null @@ -1,77 +0,0 @@ -package internal - -import ( - "context" - "fmt" - "net/http" - "sync" - "time" -) - -type Authorization interface { - Header(ctx context.Context, client HttpClient) (string, error) -} - -func NewClientSecretAuthorization(loginApiPath, clientId, clientSecret string) Authorization { - return &clientSecretAuthorization{ - LoginApiPath: loginApiPath, - ClientId: clientId, - ClientSecret: clientSecret, - } -} - -type BearerTokenAuthorization struct { - Token string -} - -func (auth BearerTokenAuthorization) Header(_ context.Context, _ HttpClient) (string, error) { - return fmt.Sprintf("Bearer %s", auth.Token), nil -} - -type clientSecretAuthorization struct { - BearerTokenAuthorization - LoginApiPath string - ClientId string - ClientSecret string - ExpiresAt time.Time - mu sync.Mutex -} - -func (auth *clientSecretAuthorization) Header(ctx context.Context, client HttpClient) (string, error) { - auth.mu.Lock() - defer auth.mu.Unlock() - if err := auth.ensureValidToken(ctx, client); err != nil { - return "", err - } - return auth.BearerTokenAuthorization.Header(ctx, client) -} - -func (auth *clientSecretAuthorization) ensureValidToken(ctx context.Context, client HttpClient) error { - const minimumTokenLifetime = 30 * time.Second - if auth.Token != "" && time.Until(auth.ExpiresAt) > minimumTokenLifetime { - return nil - } - - loginApiUrl := client.RootUrl.JoinPath(auth.LoginApiPath) - - type loginRequest struct { - ClientId string `json:"clientId"` - ClientSecret string `json:"clientSecret"` - } - - type loginResponse struct { - Token string `json:"access_token"` - ExpireSec int `json:"expires_in"` - } - - loginResult, err := DoRequest[loginResponse](ctx, client, http.MethodPost, loginApiUrl, - withPayload(loginRequest{ClientId: auth.ClientId, ClientSecret: auth.ClientSecret}, "application/json"), - ) - if err != nil { - return fmt.Errorf("login at %s with client id '%s' failed: %w", loginApiUrl, auth.ClientId, err) - } - auth.Token = loginResult.Token - auth.ExpiresAt = time.Now().Add(time.Duration(loginResult.ExpireSec) * time.Second) - Log.Debug(ctx, "login successful", "url", loginApiUrl, "clientId", auth.ClientId, "expiresAt", auth.ExpiresAt) - return nil -} diff --git a/client/internal/http_client.go b/client/internal/http_client.go deleted file mode 100644 index f7cecd9d..00000000 --- a/client/internal/http_client.go +++ /dev/null @@ -1,133 +0,0 @@ -package internal - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "reflect" - "slices" - "time" -) - -// NewHttpClient creates a new client with an underlying http.Client being a pointer to be modified by WithRetry. -func NewHttpClient(rootUrl *url.URL, userAgent string, auth Authorization) HttpClient { - return HttpClient{&http.Client{Timeout: 5 * time.Minute}, rootUrl, userAgent, auth} -} - -// HttpClient wraps [http.Client] with convenient request handling thanks to RequestOption. -type HttpClient struct { - *http.Client - RootUrl *url.URL - UserAgent string - Authorization Authorization -} - -func DoAuthorizedRequest[R any](ctx context.Context, c HttpClient, method string, url *url.URL, options ...RequestOption) (result R, err error) { - if c.Authorization == nil { - return result, fmt.Errorf("cannot do authorized request with unconfigured authorization") - } - authHeader, err := c.Authorization.Header(ctx, c) - if err != nil { - return result, err - } - return DoRequest[R](ctx, c, method, url, append(options, withHeader("Authorization", authHeader))...) -} - -func DoRequest[R any](ctx context.Context, c HttpClient, method string, url *url.URL, options ...RequestOption) (result R, err error) { - var body []byte - body, err = c.doRequest(ctx, method, url, options) - if err != nil { - return - } - if len(body) == 0 { - // An empty body is expected only for no-content calls, which are typed DoRequest[any] (e.g. - // trigger-run, delete) and ignore the result. For a call that expects an object (a pointer or a - // concrete struct), an empty 2xx body is unexpected — fail loudly instead of returning a nil/zero - // value that the caller would dereference or mistake for a 404/"not found". - if t := reflect.TypeFor[R](); t.Kind() == reflect.Interface && t.NumMethod() == 0 { - return - } - err = fmt.Errorf("unexpected empty response body from %s %s", method, url) - return - } - err = json.Unmarshal(body, &result) - return -} - -func (c HttpClient) doRequest(ctx context.Context, method string, url *url.URL, options []RequestOption) ([]byte, error) { - options = slices.Insert(options, 0, - withHeader("User-Agent", c.UserAgent), - ) - opts := requestOptions{} - for _, option := range options { - option(&opts) - } - if opts.optionErr != nil { - return nil, opts.optionErr - } - req, err := c.buildRequest(ctx, method, *url, opts) - if err != nil { - return nil, err - } - res, err := c.Do(req) - if err != nil { - return nil, err - } - defer func() { - _ = res.Body.Close() - }() - return c.readBodyAndCheckSuccess(ctx, res) -} - -func (c HttpClient) readBodyAndCheckSuccess(ctx context.Context, res *http.Response) ([]byte, error) { - responseBody, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) - } - Log.Debug(ctx, "response", "status", res.StatusCode, "body", loggedBody{bytes.NewBuffer(responseBody)}) - - if res.StatusCode >= 200 && res.StatusCode <= 299 { - return responseBody, nil - } - - return responseBody, HttpError{ - StatusCode: res.StatusCode, - ResponseBody: responseBody, - } -} - -func (c HttpClient) buildRequest(ctx context.Context, method string, url url.URL, opts requestOptions) (*http.Request, error) { - if len(opts.extraPathElems) > 0 { - url = *url.JoinPath(opts.extraPathElems...) - } - - if len(opts.urlQueryParams) > 0 { - query := url.Query() - for k, v := range opts.urlQueryParams { - query.Set(k, v) - } - url.RawQuery = query.Encode() - } - - var requestBody io.ReadWriter - if opts.requestPayload != nil { - requestBody = new(bytes.Buffer) - if err := json.NewEncoder(requestBody).Encode(opts.requestPayload); err != nil { - return nil, fmt.Errorf("failed to encode request body payload: %w", err) - } - } - - req, err := http.NewRequestWithContext(ctx, method, url.String(), requestBody) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - for _, requestModifier := range opts.requestModifiers { - requestModifier(req) - } - Log.Debug(ctx, "request", "url", req.URL.String(), "method", req.Method, "headers", loggedHeaders(req.Header), "body", loggedBody{requestBody}) - return req, err -} diff --git a/client/internal/http_client_test.go b/client/internal/http_client_test.go deleted file mode 100644 index baceaec3..00000000 --- a/client/internal/http_client_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package internal - -import ( - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestHttpClient(t *testing.T) { - t.Run("DoRequest success", func(t *testing.T) { - testLogger := installTestLogger(t) - client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(http.StatusOK) - _, _ = resp.Write([]byte(`"some-answer"`)) - assert.Equal(t, "/get", req.URL.Path) - assert.Equal(t, http.MethodGet, req.Method) - assert.Equal(t, "test-agent", req.Header.Get("User-Agent")) - }) - resp, err := DoRequest[string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) - require.NoError(t, err) - assert.Equal(t, "some-answer", resp) - assert.Equal(t, []string{ - fmt.Sprintf("request [url %s/get method GET headers User-Agent=test-agent body ]", client.RootUrl), - `response [status 200 body "some-answer"]`, - }, testLogger.Debugs) - assert.Empty(t, testLogger.Warns) - }) - - t.Run("DoRequest object call with empty 2xx body errors", func(t *testing.T) { - client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(http.StatusOK) - }) - _, err := DoRequest[*string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) - require.Error(t, err) - assert.ErrorContains(t, err, "unexpected empty response body") - }) - - t.Run("DoRequest no-content call (any) tolerates an empty 2xx body", func(t *testing.T) { - client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(http.StatusAccepted) // empty body by design (trigger-run/delete) - }) - _, err := DoRequest[any](t.Context(), client, http.MethodPost, client.RootUrl.JoinPath("trigger-run")) - require.NoError(t, err) - }) - - t.Run("DoRequest with successful retry", func(t *testing.T) { - for _, retryableStatusCode := range []int{429, 502, 503, 504} { - t.Run(fmt.Sprintf("after code %d", retryableStatusCode), func(t *testing.T) { - nowUTC := mockTimeNowAsUTC(t) - - testLogger := installTestLogger(t) - retryTestBackoff := retryTestBackoff{WaitTime: 1 * time.Second} - retried := false - client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - if !retried { - if retryableStatusCode == 429 { - resp.Header().Set("Retry-After", nowUTC.Add(1*time.Second).Format(http.TimeFormat)) - } - resp.WriteHeader(retryableStatusCode) - retried = true - return - } - resp.WriteHeader(http.StatusOK) - _, _ = resp.Write([]byte(`{}`)) - }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff}) - - _, err := DoRequest[any](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) - require.NoError(t, err) - if retryableStatusCode == 429 { - assert.Equal(t, 0, retryTestBackoff.Called) - } else { - assert.Equal(t, 1, retryTestBackoff.Called) - } - assert.Equal(t, []string{ - fmt.Sprintf("retrying request [status %d method GET path /get attempt 1/3 waitTime 1s]", retryableStatusCode), - }, testLogger.Warns) - }) - } - }) - - t.Run("DoRequest with 2 retries exhausted", func(t *testing.T) { - testLogger := installTestLogger(t) - retryTestBackoff := retryTestBackoff{} - client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(502) - }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff}) - _, err := DoRequest[any](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("get")) - var httpErr HttpError - require.ErrorAs(t, err, &httpErr) - assert.Equal(t, 502, httpErr.StatusCode) - assert.Equal(t, 2, retryTestBackoff.Called) - assert.Equal(t, []string{ - "retrying request [status 502 method GET path /get attempt 1/2 waitTime 0s]", - "retrying request [status 502 method GET path /get attempt 2/2 waitTime 0s]", - }, testLogger.Warns) - assert.Equal(t, []string{ - fmt.Sprintf("request [url %s/get method GET headers User-Agent=test-agent body ]", client.RootUrl), - "response [status 502 body ]", - }, testLogger.Debugs) - - }) - - t.Run("DoRequest with context cancelled during backoff", func(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(502) - cancel() // cancel context so the backoff wait is interrupted - }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) - _, err := DoRequest[any](ctx, client, http.MethodGet, client.RootUrl.JoinPath("get")) - require.ErrorIs(t, err, context.Canceled) - }) - - t.Run("DoRequest with PATCH (not retried)", func(t *testing.T) { - attempts := 0 - client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - attempts++ - resp.WriteHeader(502) - }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) - _, err := DoRequest[any](t.Context(), client, http.MethodPatch, client.RootUrl) - require.Error(t, err) - assert.Equal(t, 1, attempts, "PATCH must not be retried") - }) - - t.Run("DoRequest with DELETE (retried, idempotent)", func(t *testing.T) { - attempts := 0 - client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - attempts++ - if attempts == 1 { - resp.WriteHeader(503) - return - } - resp.WriteHeader(http.StatusNoContent) - }), RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}}) - _, err := DoRequest[any](t.Context(), client, http.MethodDelete, client.RootUrl.JoinPath("delete")) - require.NoError(t, err) - assert.Equal(t, 2, attempts, "DELETE must be retried after a 503") - }) - - t.Run("DoRequest with PUT replays body on retry", func(t *testing.T) { - attempt := 0 - client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - body, _ := io.ReadAll(req.Body) - assert.JSONEq(t, `{"key":"value"}`, string(body)) - attempt++ - if attempt == 1 { - resp.WriteHeader(502) - return - } - resp.WriteHeader(200) - }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff{}}) - _, err := DoRequest[any](t.Context(), client, http.MethodPut, client.RootUrl, withPayload(map[string]string{"key": "value"}, "application/json")) - require.NoError(t, err) - assert.Equal(t, 2, attempt) - }) - - t.Run("DoAuthorizedRequest with BearerTokenAuthorization", func(t *testing.T) { - client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - assert.Equal(t, "Bearer my-static-token", req.Header.Get("Authorization")) - resp.WriteHeader(http.StatusAccepted) - }) - client.Authorization = BearerTokenAuthorization{Token: "my-static-token"} - _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPost, client.RootUrl.JoinPath("create"), withPayload("content", "text/plain")) - require.NoError(t, err) - }) - - t.Run("DoAuthorizedRequest with clientSecretAuthorization and retries", func(t *testing.T) { - t.Run("succeeds after second attempt", func(t *testing.T) { - retryTestBackoff := retryTestBackoff{} - requestsSeen := map[string]int{} // key is request path - client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - defer func() { - requestsSeen[req.URL.Path]++ - }() - if requestsSeen[req.URL.Path] == 0 { - resp.WriteHeader(502) - return - } - switch req.URL.Path { - case "/login": - resp.WriteHeader(http.StatusOK) - // expires_in must be less than minimumTokenLifetime to trigger relogin on second doAuthorizedRequest call - _, _ = resp.Write([]byte(`{"access_token":"some-token", "expires_in": 10}`)) - case "/edit": - assert.Equal(t, "Bearer some-token", req.Header.Get("Authorization")) - resp.WriteHeader(http.StatusAccepted) - default: - t.Fatal("unexpected request", req.URL.Path) - } - }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) - client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") - resp, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) - require.NoError(t, err) - _ = resp - assert.Equal(t, map[string]int{ - "/login": 2, - "/edit": 2, - }, requestsSeen) - - t.Run("expired token is refreshed with relogin", func(t *testing.T) { - _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) - require.NoError(t, err) - assert.Equal(t, 2, retryTestBackoff.Called) - assert.Equal(t, map[string]int{ - "/login": 3, - "/edit": 3, - }, requestsSeen) - }) - - // two different paths with one retry each, so backoff called twice in total - assert.Equal(t, 2, retryTestBackoff.Called) - }) - - t.Run("succeeds after redirect and retries", func(t *testing.T) { - retryTestBackoff := retryTestBackoff{} - requestsSeen := map[string]int{} - client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - defer func() { - requestsSeen[req.URL.Path]++ - }() - if requestsSeen[req.URL.Path] == 0 { - resp.WriteHeader(502) - return - } - switch req.URL.Path { - case "/login": - body, _ := io.ReadAll(req.Body) - assert.JSONEq(t, `{"clientId":"test-client","clientSecret":"test-client-secret"}`, string(body)) - http.Redirect(resp, req, "/login-target", http.StatusTemporaryRedirect) - case "/login-target": - body, _ := io.ReadAll(req.Body) - assert.JSONEq(t, `{"clientId":"test-client","clientSecret":"test-client-secret"}`, string(body)) - resp.WriteHeader(http.StatusOK) - _, _ = resp.Write([]byte(`{"access_token":"redirected-token", "expires_in": 3600}`)) - case "/edit": - assert.Equal(t, "Bearer redirected-token", req.Header.Get("Authorization")) - resp.WriteHeader(http.StatusAccepted) - default: - t.Fatal("unexpected request", req.URL.Path) - } - }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) - client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") - _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) - require.NoError(t, err) - assert.Equal(t, map[string]int{ - "/login": 2, // 1st: 502, 2nd: 307 redirect - "/login-target": 2, // 1st: 502, 2nd: 200 - "/edit": 2, // 1st: 502, 2nd: 202 - }, requestsSeen) - assert.Equal(t, 3, retryTestBackoff.Called) // one retry each for /login, /login-target, /edit - }) - - t.Run("fails constantly at login", func(t *testing.T) { - retryTestBackoff := retryTestBackoff{} - client := WithRetry(newTestClientWithServer(t, func(resp http.ResponseWriter, r *http.Request) { - resp.WriteHeader(503) - }), RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff, WhitelistedPaths: map[string][]string{http.MethodPost: {"/login"}}}) - client.Authorization = NewClientSecretAuthorization("login", "test-client", "test-client-secret") - _, err := DoAuthorizedRequest[any](t.Context(), client, http.MethodPut, client.RootUrl.JoinPath("edit")) - require.ErrorContains(t, err, fmt.Sprintf("login at %s/login with client id 'test-client' failed", client.RootUrl)) - var httpErr HttpError - require.ErrorAs(t, err, &httpErr) - assert.Equal(t, 503, httpErr.StatusCode) - assert.Equal(t, 2, retryTestBackoff.Called) - }) - - }) -} - -func TestUrlQueryOptions(t *testing.T) { - queryFrom := func(t *testing.T, query any) url.Values { - t.Helper() - var gotQuery url.Values - client := newTestClientWithServer(t, func(resp http.ResponseWriter, req *http.Request) { - gotQuery = req.URL.Query() - resp.WriteHeader(http.StatusOK) - _, _ = resp.Write([]byte(`"ok"`)) - }) - _, err := DoRequest[string](t.Context(), client, http.MethodGet, client.RootUrl.JoinPath("list"), - WithUrlQuery(query), - ) - require.NoError(t, err) - return gotQuery - } - - t.Run("a map is sent verbatim", func(t *testing.T) { - got := queryFrom(t, map[string]string{"definitionUuid": "abc", "status": "SUCCEEDED"}) - assert.Equal(t, "abc", got.Get("definitionUuid")) - assert.Equal(t, "SUCCEEDED", got.Get("status")) - }) - - t.Run("map values are kept even when zero", func(t *testing.T) { - got := queryFrom(t, map[string]any{"page": 0}) - assert.Equal(t, "0", got.Get("page")) - }) - - t.Run("struct fields are named by json tag and zero fields are dropped", func(t *testing.T) { - type filter struct { - Identifier *string `json:"identifier"` - Name string `json:"name"` - Restricted *bool `json:"restricted"` - } - got := queryFrom(t, filter{Identifier: new("abc")}) - assert.Equal(t, "abc", got.Get("identifier")) - assert.False(t, got.Has("name"), "zero string field must be dropped") - assert.False(t, got.Has("restricted"), "nil pointer field must be dropped") - }) - - t.Run("a zero-value struct adds no params", func(t *testing.T) { - type filter struct { - Identifier *string `json:"identifier"` - } - got := queryFrom(t, &filter{}) - assert.Empty(t, got) - }) -} - -func mockTimeNowAsUTC(t *testing.T) time.Time { - t.Helper() - now := time.Now().UTC().Truncate(time.Second) - timeNow = func() time.Time { return now } - t.Cleanup(func() { - timeNow = time.Now - }) - return now -} - -func newTestClientWithServer(t *testing.T, handlerFunc http.HandlerFunc) HttpClient { - t.Helper() - server := httptest.NewServer(handlerFunc) - t.Cleanup(server.Close) - rootUrl, err := url.Parse(server.URL) - require.NoError(t, err) - client := server.Client() - return HttpClient{ - Client: client, - RootUrl: rootUrl, - UserAgent: "test-agent", - } -} - -func installTestLogger(t *testing.T) *testLogger { - t.Helper() - testLogger := &testLogger{} - previousLog := Log - Log = testLogger - t.Cleanup(func() { - Log = previousLog - }) - return testLogger -} - -type testLogger struct { - Debugs []string - Infos []string - Warns []string -} - -func (c *testLogger) Debug(_ context.Context, msg string, args ...any) { - c.Debugs = append(c.Debugs, fmt.Sprintf("%s %v", msg, args)) -} - -func (c *testLogger) Info(_ context.Context, msg string, args ...any) { - c.Infos = append(c.Infos, fmt.Sprintf("%s %v", msg, args)) -} - -func (c *testLogger) Warn(_ context.Context, msg string, args ...any) { - c.Warns = append(c.Warns, fmt.Sprintf("%s %v", msg, args)) -} - -type retryTestBackoff struct { - WaitTime time.Duration - Called int -} - -func (b *retryTestBackoff) Calculate(int) time.Duration { - b.Called++ - return b.WaitTime -} diff --git a/client/internal/http_error.go b/client/internal/http_error.go deleted file mode 100644 index 55cc32c8..00000000 --- a/client/internal/http_error.go +++ /dev/null @@ -1,32 +0,0 @@ -package internal - -import ( - "fmt" - "net/http" -) - -// HttpError represents an HTTP error response with status code. -// This error is returned when an HTTP request fails with a non-2XX status code. -type HttpError struct { - StatusCode int - ResponseBody []byte -} - -func (e HttpError) Error() string { - return fmt.Sprintf("http error %d, response '%s'", e.StatusCode, string(e.ResponseBody)) -} - -// IsForbidden returns true if the error is a 403 Forbidden response. -func (e HttpError) IsForbidden() bool { - return e.StatusCode == http.StatusForbidden -} - -// IsNotFound returns true if the error is a 404 Not Found response. -func (e HttpError) IsNotFound() bool { - return e.StatusCode == http.StatusNotFound -} - -// IsConflict returns true if the error is a 409 Conflict response. -func (e HttpError) IsConflict() bool { - return e.StatusCode == http.StatusConflict -} diff --git a/client/internal/logging.go b/client/internal/logging.go deleted file mode 100644 index d82cda89..00000000 --- a/client/internal/logging.go +++ /dev/null @@ -1,84 +0,0 @@ -package internal - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "maps" - "net/http" - "slices" - "strings" -) - -var Log Logger = noopLogger{} - -// Logger supports Debug, Info, and Warn log levels. -// Note that msg is a short, descriptive statement what is logged, and args are key value pairs (values are string or implement fmt.Stringer). -type Logger interface { - Debug(ctx context.Context, msg string, args ...any) - Info(ctx context.Context, msg string, args ...any) - Warn(ctx context.Context, msg string, args ...any) -} - -type noopLogger struct{} - -func (n noopLogger) Debug(context.Context, string, ...any) { - // do nothing -} - -func (n noopLogger) Info(context.Context, string, ...any) { - // do nothing -} - -func (n noopLogger) Warn(context.Context, string, ...any) { - // do nothing -} - -type loggedHeaders http.Header - -var _ fmt.Stringer = loggedHeaders(nil) - -func (l loggedHeaders) String() string { - var lines []string - for _, k := range slices.Sorted(maps.Keys(l)) { - for _, v := range l[k] { - // Avoid printing that longish JWT Bearer token (which is also a secret) - if k == "Authorization" { - v = "[REDACTED]" - } - lines = append(lines, fmt.Sprintf("%s=%s", k, v)) - } - } - return strings.Join(lines, "\n") -} - -type loggedBody struct { - io.Reader -} - -var _ fmt.Stringer = loggedBody{} - -func (l loggedBody) String() string { - if buffer, ok := l.Reader.(*bytes.Buffer); ok { - return bytesToPrettyJson(buffer.Bytes()) - } else if buffer == nil { - return "" - } - return fmt.Sprintf(" %v", l.Reader) -} - -func bytesToPrettyJson(data []byte) string { - if len(data) == 0 { - return "" - } - var decoded any - if err := json.Unmarshal(data, &decoded); err == nil { - if indented, err := json.MarshalIndent(decoded, "", " "); err == nil { - return string(indented) - } - } - // should never happen as we should only transfer JSON in request/responses - return fmt.Sprintf(" %s", len(data), string(data)) -} diff --git a/client/internal/mesh_object_client.go b/client/internal/mesh_object.go similarity index 52% rename from client/internal/mesh_object_client.go rename to client/internal/mesh_object.go index 9cf8e5f8..2e33cc78 100644 --- a/client/internal/mesh_object_client.go +++ b/client/internal/mesh_object.go @@ -2,34 +2,42 @@ package internal import ( "context" - "encoding/json" "errors" "fmt" - "net/http" + "log/slog" "net/url" "reflect" "regexp" "slices" "strings" "unicode" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" ) +type HttpClient struct { + http.AuthorizedClient + + EndpointUrl xurl.URL +} + // MeshObjectClient provides typed CRUD operations for meshStack API objects. -// It embeds [HttpClient] and adds meshObject-specific functionality including automatic +// It embeds [http.AuthorizedClient] and adds meshObject-specific functionality including automatic // MIME type handling and pagination. -// Also handles authentication in doAuthorizedRequest using the ApiKey/ApiSecret values, -// which are embedded in HttpClient for convenient construction with NewMeshObjectClient. type MeshObjectClient[M any] struct { - HttpClient + http.AuthorizedClient + Kind string ApiVersion string ApiUrl *url.URL } // NewMeshObjectClient creates a new [MeshObjectClient] for a specific meshObject type with automatic URL path inference. -// The meshObject kind is inferred from type M. T +// The meshObject kind is inferred from type M. // The API URL is constructed from explicitApiPathElems if provided, -// otherwise the pluralized and lowercased kind is used as a single element. +// otherwise the pluralized and lowercased kind is used as a single element, a convention only the +// workspace and project user/group binding APIs break. func NewMeshObjectClient[M any](ctx context.Context, httpClient HttpClient, apiVersion string, explicitApiPathElems ...string) MeshObjectClient[M] { kind := InferKind[M]() @@ -37,9 +45,9 @@ func NewMeshObjectClient[M any](ctx context.Context, httpClient HttpClient, apiV explicitApiPathElems = []string{strings.ToLower(pluralizeKind(kind))} } explicitApiPathElems = slices.Insert(explicitApiPathElems, 0, "/api/meshobjects") - apiUrl := httpClient.RootUrl.JoinPath(explicitApiPathElems...) - Log.Info(ctx, fmt.Sprintf("initialized %s client", reflect.TypeFor[M]().Name()), "url", apiUrl.String(), "kind", kind, "version", apiVersion) - return MeshObjectClient[M]{httpClient, kind, apiVersion, apiUrl} + apiUrl := httpClient.EndpointUrl.JoinPath(explicitApiPathElems...) + slog.DebugContext(ctx, fmt.Sprintf("initialized %s client", reflect.TypeFor[M]().Name()), "url", apiUrl.String(), "kind", kind, "version", apiVersion) + return MeshObjectClient[M]{httpClient.AuthorizedClient, kind, apiVersion, apiUrl} } var versionSuffixRe = regexp.MustCompile(`V\d+$`) @@ -75,63 +83,59 @@ func (c MeshObjectClient[M]) MeshObjectMimeType() string { // Get retrieves a meshObject by ID. Returns nil if not found. func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (resp *M, err error) { - resp, err = DoAuthorizedRequest[*M](ctx, c.HttpClient, http.MethodGet, c.ApiUrl.JoinPath(id), WithAccept(c.MeshObjectMimeType())) - if httpErr, ok := errors.AsType[HttpError](err); ok && httpErr.IsNotFound() { + resp, err = c.GetAtPath[*M](ctx, id) + if httpErr, ok := errors.AsType[http.Error](err); ok && httpErr.IsNotFound() { + //nolint:nilnil // MeshObject clients return nil on 404, which is how Terraform handles a resource that does not exist return nil, nil } return } +func (c MeshObjectClient[M]) GetAtPath[R any](ctx context.Context, id string, extraPath ...string) (R, error) { + return c.DoRequest[R](ctx, http.MethodGet, c.ApiUrl.JoinPath(id).JoinPath(extraPath...), http.WithAccept(c.MeshObjectMimeType())) +} + // Post creates a new meshObject with the given payload. // Automatically injects apiVersion and kind into the JSON payload. -func (c MeshObjectClient[M]) Post(ctx context.Context, payload any, options ...RequestOption) (*M, error) { - return DoAuthorizedRequest[*M]( - ctx, - c.HttpClient, - http.MethodPost, - c.ApiUrl, - append(options, c.withMeshObjectPayload(payload))..., - ) +func (c MeshObjectClient[M]) Post[P any](ctx context.Context, payload P) (*M, error) { + return c.PostAtPath[*M](ctx, payload) +} + +func (c MeshObjectClient[M]) PostAtPath[R, P any](ctx context.Context, payload P, extraPath ...string) (R, error) { + return c.DoRequest[R](ctx, http.MethodPost, c.ApiUrl.JoinPath(extraPath...), + http.WithAccept(c.MeshObjectMimeType()), c.withMeshObjectPayload(payload)) } // Put updates an existing meshObject by ID with the given payload. // Automatically injects apiVersion and kind into the JSON payload. -func (c MeshObjectClient[M]) Put(ctx context.Context, id string, payload any) (*M, error) { - return DoAuthorizedRequest[*M](ctx, c.HttpClient, http.MethodPut, c.ApiUrl.JoinPath(id), c.withMeshObjectPayload(payload)) -} - -// withMeshObjectPayload returns a RequestOption that sets the payload with apiVersion and kind injected, -// using the meshObject MIME type for content negotiation. -// Panics on marshal errors which indicates a programming error (payload is always a well-typed struct). -// -// The double marshal/unmarshal round-trip converts the typed struct to a map[string]any so we can -// inject the top-level apiVersion and kind fields without coupling the struct type to those fields. -func (c MeshObjectClient[M]) withMeshObjectPayload(payload any) RequestOption { - intermediate, err := json.Marshal(payload) - if err != nil { - panic(fmt.Sprintf("failed to marshal %T: %v", payload, err)) - } - - var m map[string]any - if err := json.Unmarshal(intermediate, &m); err != nil { - panic(fmt.Sprintf("failed to unmarshal %T to map: %v", payload, err)) - } - - m["apiVersion"] = c.ApiVersion - m["kind"] = c.Kind +func (c MeshObjectClient[M]) Put[P any](ctx context.Context, id string, payload P) (*M, error) { + return c.DoRequest[*M](ctx, http.MethodPut, c.ApiUrl.JoinPath(id), c.withMeshObjectPayload(payload), http.Retryable()) +} - return withPayload(m, c.MeshObjectMimeType()) +// withMeshObjectPayload injects apiVersion and kind as top-level members. P carries the payload's +// own type because the `,embed` tag takes a struct, a string-keyed map or a jsontext.Value, never +// an any. +func (c MeshObjectClient[M]) withMeshObjectPayload[P any](payload P) http.RequestOption { + return http.WithJsonPayload(struct { + ApiVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Payload P `json:",embed"` + }{c.ApiVersion, c.Kind, payload}, c.MeshObjectMimeType()) } // Delete removes a meshObject by ID. -func (c MeshObjectClient[M]) Delete(ctx context.Context, id string, options ...RequestOption) (err error) { - _, err = DoAuthorizedRequest[any](ctx, c.HttpClient, http.MethodDelete, c.ApiUrl.JoinPath(id), append(options, WithAccept(c.MeshObjectMimeType()))...) +func (c MeshObjectClient[M]) Delete(ctx context.Context, id string) (err error) { + return c.DeleteAtPath(ctx, id) +} + +func (c MeshObjectClient[M]) DeleteAtPath(ctx context.Context, id string, extraPath ...string) (err error) { + _, err = c.DoRequest[any](ctx, http.MethodDelete, c.ApiUrl.JoinPath(id).JoinPath(extraPath...), http.Retryable(), http.WithAccept(c.MeshObjectMimeType())) return } // List retrieves all meshObjects with automatic pagination handling. -// Accepts optional [RequestOption] parameters for filtering and querying. -func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) ([]M, error) { +// Accepts optional [http.RequestOption] parameters for filtering and querying. +func (c MeshObjectClient[M]) List(ctx context.Context, options ...http.RequestOption) ([]M, error) { var result []M embeddedKey := pluralizeKind(c.Kind) pageNumber := 0 @@ -144,9 +148,9 @@ func (c MeshObjectClient[M]) List(ctx context.Context, options ...RequestOption) Number int `json:"number"` } `json:"page"` } - response, err := DoAuthorizedRequest[paginatedResponse](ctx, c.HttpClient, http.MethodGet, c.ApiUrl, append(options, - WithAccept(c.MeshObjectMimeType()), - WithUrlQuery(map[string]any{"page": pageNumber}), + response, err := c.DoRequest[paginatedResponse](ctx, http.MethodGet, c.ApiUrl, append(options, + http.WithAccept(c.MeshObjectMimeType()), + http.WithUrlQuery(map[string]any{"page": pageNumber}), )...) if err != nil { return result, fmt.Errorf("error getting page %d: %w", pageNumber, err) diff --git a/client/internal/options.go b/client/internal/options.go deleted file mode 100644 index c06e8ba7..00000000 --- a/client/internal/options.go +++ /dev/null @@ -1,95 +0,0 @@ -package internal - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "reflect" -) - -type ( - // RequestOption is a functional option for configuring HTTP requests. - RequestOption func(opts *requestOptions) - - requestOptions struct { - urlQueryParams map[string]string - extraPathElems []string - requestPayload any - requestModifiers []requestModifier - // optionErr holds the first error produced while applying options (e.g. an unmarshalable - // query); doRequest surfaces it instead of building a request from partial options. - optionErr error - } - requestModifier func(req *http.Request) -) - -// WithUrlQuery adds URL query parameters from a query value. -// -// The value is JSON-marshalled and decoded into a flat map, so each field becomes a query param -// named by its `json` tag. A struct passed by value is the common case: its zero-value fields are -// dropped (an implicit `omitempty`), so an unset filter needs neither a pointer nor an `omitempty` -// tag and a zero-value struct adds no params at all. A map[string]string / map[string]any is taken -// verbatim — every entry is sent, including deliberate zero values such as page=0. -// -// Values are stringified with fmt.Sprintf("%v", ...); nested objects or arrays are not supported. -func WithUrlQuery(query any) RequestOption { - return func(opts *requestOptions) { - data, err := json.Marshal(query) - if err != nil { - opts.optionErr = fmt.Errorf("cannot marshal url query of type %T: %w", query, err) - return - } - // UseNumber keeps integers (e.g. page) from becoming float64 and gaining a ".0" or exponent. - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.UseNumber() - var params map[string]any - if err := decoder.Decode(¶ms); err != nil { - opts.optionErr = fmt.Errorf("cannot decode url query of type %T into a flat map: %w", query, err) - return - } - // Drop zero-value fields only for a struct (passed by value, not by pointer); a map is - // passed through as given. - skipZero := reflect.ValueOf(query).Kind() == reflect.Struct - for key, value := range params { - if value == nil || (skipZero && reflect.ValueOf(value).IsZero()) { - continue - } - if opts.urlQueryParams == nil { - opts.urlQueryParams = map[string]string{} - } - opts.urlQueryParams[key] = fmt.Sprintf("%v", value) - } - } -} - -// WithPathElems appends path elements to the request URL path. -func WithPathElems(pathElems ...string) RequestOption { - return func(opts *requestOptions) { - opts.extraPathElems = append(opts.extraPathElems, pathElems...) - } -} - -func appendRequestModifier(modifier requestModifier) RequestOption { - return func(opts *requestOptions) { - opts.requestModifiers = append(opts.requestModifiers, modifier) - } -} - -func WithAccept(accept string) RequestOption { - return withHeader("Accept", accept) -} - -func withHeader(key, value string) RequestOption { - return appendRequestModifier(func(req *http.Request) { - req.Header.Set(key, value) - }) -} - -func withPayload(payload any, contentType string) RequestOption { - return func(opts *requestOptions) { - WithAccept(contentType)(opts) - withHeader("Content-Type", contentType)(opts) - opts.requestPayload = payload - } -} diff --git a/client/internal/retry.go b/client/internal/retry.go deleted file mode 100644 index 5d382781..00000000 --- a/client/internal/retry.go +++ /dev/null @@ -1,270 +0,0 @@ -package internal - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "math" - "net/http" - "strconv" - "sync" - "time" -) - -// WithRetry sets up the given client to retry certain requests. -// The idempotent methods GET, PUT and DELETE are retried by default, POST only if the path is -// explicitly whitelisted. See RetryOptions. -func WithRetry(c HttpClient, options RetryOptions) HttpClient { - next := http.DefaultTransport - if c.Transport != nil { - next = c.Transport - } - whitelistedByMethodAndUrl := func() (m map[string]*sync.Map) { - m = make(map[string]*sync.Map) - for method, paths := range options.WhitelistedPaths { - m[method] = new(sync.Map) - for _, path := range paths { - m[method].Store(c.RootUrl.JoinPath(path).String(), nil) - } - } - return - }() - c.Transport = &retryRoundTripper{ - Next: next, - MaxRetries: options.MaxRetries, - // ShouldRetryRequest checks if the request method/path is eligible for retry. - ShouldRetryRequest: func(req *http.Request) (retry bool) { - if options.Backoff == nil { - return false - } - switch req.Method { - case http.MethodGet, http.MethodPut, http.MethodDelete: - // Idempotent methods are safe to retry: replaying them cannot create duplicate - // side effects. A DELETE that actually succeeded server-side before a proxy 503 - // simply yields a 404 on replay, which delete handlers already treat as done. - return true - } - if whitelisted, found := whitelistedByMethodAndUrl[req.Method]; found { - _, retry = whitelisted.Load(req.URL.String()) - } - return - }, - // ShouldRetryResponse returns the backoff policy if the response/error indicates a retryable condition, - // otherwise nil is returned to indicate no retry. - ShouldRetryResponse: func(resp *http.Response, err error) RetryBackoff { - if err != nil { - return options.Backoff - } - switch resp.StatusCode { - case http.StatusTooManyRequests, http.StatusServiceUnavailable: - return retryAfterBackoff{Response: resp, Fallback: options.Backoff} - case http.StatusBadGateway, http.StatusGatewayTimeout: - return options.Backoff - case http.StatusTemporaryRedirect, http.StatusPermanentRedirect: - if locationRedirectUrl, _ := resp.Request.URL.Parse(resp.Header.Get("Location")); locationRedirectUrl != nil { - if whitelisted, found := whitelistedByMethodAndUrl[resp.Request.Method]; found { - whitelisted.Store(locationRedirectUrl.String(), nil) - } - } - return nil - default: - return nil - } - }, - } - return c // for fluent API -} - -// RetryOptions configure WithRetry. -type RetryOptions struct { - // MaxRetries limits the attempts to retries. If zero, retries will never be attempted. - MaxRetries int - // Backoff to use when retrying. If nil, retries will never be attempted. - Backoff RetryBackoff - // WhitelistedPaths allow methods beyond GET and PUT to be retried as well, see WithRetry. - WhitelistedPaths map[string][]string -} - -// RetryBackoff calculates the duration to wait before the next retry attempt. -type RetryBackoff interface { - Calculate(attempt int) time.Duration -} - -// ExponentialBackoff increases the backoff exponentially: minWait * 2^(attempt-1). -type ExponentialBackoff struct { - MinWait, MaxWait time.Duration -} - -func (b ExponentialBackoff) Calculate(attempt int) time.Duration { - nextWait := time.Duration(math.Pow(2, float64(attempt-1))) * b.MinWait - if b.MaxWait > 0 && nextWait > b.MaxWait { - return b.MaxWait - } - return nextWait -} - -var timeNow = time.Now - -type retryAfterBackoff struct { - Response *http.Response - Fallback RetryBackoff -} - -func (b retryAfterBackoff) Calculate(attempt int) (waitTime time.Duration) { - defer func() { - const maxRetryAfterWaitTime = 5 * time.Minute - if waitTime < 0 { - waitTime = b.Fallback.Calculate(attempt) - } else if waitTime > maxRetryAfterWaitTime { - waitTime = maxRetryAfterWaitTime - } - }() - - // Parse the Retry-After header from a response. - // It supports both delay-seconds and HTTP-date formats (RFC 7231 §7.1.3). - - header := b.Response.Header.Get("Retry-After") - if header == "" { - return -1 - } - - // Try as delay-seconds first. - if seconds, err := strconv.ParseInt(header, 10, 64); err == nil { - return time.Duration(seconds) * time.Second - } - - // Try as HTTP-date (RFC 7231). - if date, err := http.ParseTime(header); err == nil { - return date.Sub(timeNow()) - } - return -1 -} - -// retryRoundTripper wraps an http.RoundTripper to retry failed requests. -// See WithRetry for which methods are retried. -type retryRoundTripper struct { - Next http.RoundTripper - MaxRetries int - ShouldRetryRequest func(req *http.Request) bool - ShouldRetryResponse func(resp *http.Response, err error) RetryBackoff -} - -func (r *retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - if !r.ShouldRetryRequest(req) { - return r.Next.RoundTrip(req) - } - req = makeRequestBodyRetryable(req) - for attempt := 1; ; attempt++ { - resp, err := r.Next.RoundTrip(req) - if errors.Is(err, errRetryableBodyClose) { - return resp, err - } - backoff := r.ShouldRetryResponse(resp, err) - // No retry needed or no more retries left — return as-is. - if backoff == nil || attempt > r.MaxRetries { - return resp, err - } - drainAndCloseResponseBody(req.Context(), resp) - if req.GetBody != nil { - if body, err := req.GetBody(); err != nil { - return nil, err - } else { - req.Body = body - } - } - waitTime := backoff.Calculate(attempt) - Log.Warn(req.Context(), "retrying request", append( - func() []any { - if err != nil { - return []any{"error", err.Error()} - } - return []any{"status", resp.StatusCode} - }(), - "method", req.Method, - "path", req.URL.Path, - "attempt", fmt.Sprintf("%d/%d", attempt, r.MaxRetries), - "waitTime", waitTime, - )...) - timer := time.NewTimer(waitTime) - select { - case <-req.Context().Done(): - timer.Stop() - return nil, req.Context().Err() - case <-timer.C: - } - } -} - -func makeRequestBodyRetryable(req *http.Request) *http.Request { - if req.Body == nil { - return req - } - // If GetBody already returns independent readers (e.g. set by http.NewRequestWithContext - // for *bytes.Buffer, *bytes.Reader, *strings.Reader), use it as-is for retries. - if req.GetBody != nil { - return req - } - body := retryableBody{Closer: req.Body} - body.Reader = io.TeeReader(req.Body, &body.Buffer) - result := req.Clone(req.Context()) - result.Body = &body - result.GetBody = nil - return result -} - -// retryableBody lazily captures request body bytes on the first read and replays them on retries. -// Buffer is filled via TeeReader as the transport reads during the first request. On Close, the -// source is released and subsequent reads replay from Buffer via bytes.NewReader. -type retryableBody struct { - io.Reader - io.Closer - Buffer appendWriter -} - -var errRetryableBodyClose = errors.New("retryableBody failed to close") - -func (b *retryableBody) Close() error { - // Drain remaining bytes through the TeeReader to ensure Buffer captures the full body, - // even if the transport only partially read it (e.g. connection reset mid-write). - if _, err := io.Copy(io.Discard, b.Reader); err != nil { - return errors.Join(err, errRetryableBodyClose) - } - // On first close, close the Body and use the b.Buffer from now on - if b.Closer != nil { - if err := b.Closer.Close(); err != nil { - return errors.Join(err, errRetryableBodyClose) - } - } - b.Closer = nil - b.Reader = bytes.NewReader(b.Buffer) - return nil -} - -// appendWriter is an io.Writer that appends to a []byte slice. -// Helper for retryableBody.Buffer. -type appendWriter []byte - -func (w *appendWriter) Write(p []byte) (int, error) { - *w = append(*w, p...) - return len(p), nil -} - -// drainAndCloseResponseBody reads up to maxBytes from the response body before closing it. -// Draining enables Go's http.Transport to reuse the underlying TCP connection for -// subsequent requests. The maxBytes limit prevents getting stuck on large or slow -// responses — if the body exceeds this limit, the connection won't be reused, but -// we won't block indefinitely either. -func drainAndCloseResponseBody(ctx context.Context, resp *http.Response) { - const maxBytes = 16 * 1024 - if resp != nil && resp.Body != nil { - drainedBytes, err := io.CopyN(io.Discard, resp.Body, maxBytes) - if err != nil && !errors.Is(err, io.EOF) { - Log.Debug(ctx, fmt.Sprintf("failed to drain response body: %s", err.Error())) - } - if err := resp.Body.Close(); err != nil { - Log.Debug(ctx, fmt.Sprintf("failed to close response body after draining %d bytes: %s", drainedBytes, err.Error())) - } - } -} diff --git a/client/internal/retry_test.go b/client/internal/retry_test.go deleted file mode 100644 index ffb9302b..00000000 --- a/client/internal/retry_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package internal - -import ( - "fmt" - "net/http" - "testing" - "testing/synctest" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestExponentialBackoff_Calculate(t *testing.T) { - tests := []struct { - attempt int - want time.Duration - }{ - {1, 1 * time.Second}, - {2, 2 * time.Second}, - {3, 4 * time.Second}, - {4, 5 * time.Second}, - {5, 5 * time.Second}, - } - for _, tt := range tests { - t.Run(fmt.Sprintf("attempt %d", tt.attempt), func(t *testing.T) { - b := ExponentialBackoff{ - MinWait: 1 * time.Second, - MaxWait: 5 * time.Second, - } - assert.Equalf(t, tt.want, b.Calculate(tt.attempt), "Calculate(%v)", tt.attempt) - }) - } -} - -func TestRetryAfterBackoff(t *testing.T) { - // synctest bubble starts at 2000-01-01T00:00:00Z - bubbleStart := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) - fallback := ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 10 * time.Second} - - tests := []struct { - name string - header string - want time.Duration - }{ - {"delay-seconds", "30", 30 * time.Second}, - {"zero seconds", "0", 0}, // RFC: retry immediately - {"capped at 5 minutes", "600", 5 * time.Minute}, // capped - {"empty header", "", 1 * time.Second}, // falls back - {"unparseable header", "not-a-number-or-date", 1 * time.Second}, // falls back - {"HTTP-date in the past", bubbleStart.Add(-10 * time.Second).Format(http.TimeFormat), 1 * time.Second}, // falls back - {"HTTP-date in the future", bubbleStart.Add(45 * time.Second).Format(http.TimeFormat), 45 * time.Second}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - b := retryAfterBackoff{ - Response: &http.Response{Header: http.Header{"Retry-After": {tt.header}}}, - Fallback: fallback, - } - assert.Equal(t, tt.want, b.Calculate(1)) - }) - }) - } -} diff --git a/client/landingzone.go b/client/landingzone.go index 346c48f8..2a9a461f 100644 --- a/client/landingzone.go +++ b/client/landingzone.go @@ -3,7 +3,8 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/internal/http" ) type MeshLandingZone struct { @@ -27,9 +28,9 @@ type MeshLandingZoneSpec struct { // schema defaults this to false, so the provider always states the value it wants and never // asks the backend to keep whatever is stored. Restricted bool `json:"restricted" tfsdk:"restricted"` - InfoLink *string `json:"infoLink,omitempty" tfsdk:"info_link"` + InfoLink *string `json:"infoLink,omitzero" tfsdk:"info_link"` PlatformRef UuidRef `json:"platformRef" tfsdk:"platform_ref"` - PlatformProperties *MeshLandingZonePlatformProperties `json:"platformProperties,omitempty" tfsdk:"platform_properties"` + PlatformProperties *MeshLandingZonePlatformProperties `json:"platformProperties,omitzero" tfsdk:"platform_properties"` Quotas []MeshLandingZoneQuota `json:"quotas" tfsdk:"quotas"` MandatoryBuildingBlockRefs []UuidRef `json:"mandatoryBuildingBlockRefs" tfsdk:"mandatory_building_block_refs"` RecommendedBuildingBlockRefs []UuidRef `json:"recommendedBuildingBlockRefs" tfsdk:"recommended_building_block_refs"` @@ -93,7 +94,7 @@ func (c meshLandingZoneClient) Read(ctx context.Context, name string) (*MeshLand } func (c meshLandingZoneClient) List(ctx context.Context, query MeshLandingZoneListQuery) ([]MeshLandingZone, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(query)) + return c.meshObject.List(ctx, http.WithUrlQuery(query)) } func (c meshLandingZoneClient) Create(ctx context.Context, landingZone *MeshLandingZoneCreate) (*MeshLandingZone, error) { diff --git a/client/location.go b/client/location.go index d3e3e0a6..c2935caf 100644 --- a/client/location.go +++ b/client/location.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshLocation struct { diff --git a/client/mesh_info.go b/client/mesh_info.go index b70f09fb..018e6083 100644 --- a/client/mesh_info.go +++ b/client/mesh_info.go @@ -4,48 +4,57 @@ import ( "context" "fmt" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/version" ) -// FeatureFlagFourEyesRoleApproval is the only feature flag /mesh/info can currently report in -// MeshInfo.EnabledFeatureFlags: whether the four-eyes principle (role approval) is enabled. -const FeatureFlagFourEyesRoleApproval = "four_eyes_role_approval" +// MeshFeatureFlag names an optional meshStack capability. /mesh/info reports each one as a bool of +// its own, so a consumer that wants a list of names maps them itself. +type MeshFeatureFlag string -// MeshInfo describes the meshStack instance the provider is configured against: the endpoint from -// the provider configuration, plus metadata from the public, unauthenticated /mesh/info endpoint. +var ( + MeshFeatureFlags = enum.Enum[MeshFeatureFlag]{} + MeshFeatureFlagFourEyesRoleApproval = MeshFeatureFlags.Entry("four_eyes_role_approval") +) + +// MeshInfo is the public, unauthenticated /mesh/info document, as the endpoint returns it. type MeshInfo struct { - Endpoint string `tfsdk:"endpoint" json:"-"` - Version string `tfsdk:"version" json:"version"` - IsFourEyesEnabled bool `tfsdk:"-" json:"is4EPEnabled"` - EnabledFeatureFlags []string `tfsdk:"enabled_feature_flags" json:"-"` - Metadata map[string]string `tfsdk:"metadata" json:"metadata"` - AdminWorkspaceIdentifier string `tfsdk:"admin_workspace_identifier" json:"adminWorkspaceIdentifier"` + Version string `json:"version" tfsdk:"version"` + // Is4EPEnabled means "Is four-eyes principle enabled" + Is4EPEnabled bool `json:"is4EPEnabled" tfsdk:"-"` + Metadata map[string]string `json:"metadata" tfsdk:"metadata"` + AdminWorkspaceIdentifier string `json:"adminWorkspaceIdentifier" tfsdk:"admin_workspace_identifier"` + Issuer xurl.URL `json:"issuer" tfsdk:"-"` + CliClientId string `json:"cliClientId" tfsdk:"-"` } type MeshInfoClient interface { - Read(ctx context.Context) (*MeshInfo, error) + Read(ctx context.Context) (MeshInfo, error) } type meshInfoClient struct { - httpClient internal.HttpClient + http.Client + + Endpoint xurl.URL } -func newMeshInfoClient(httpClient internal.HttpClient) MeshInfoClient { - return meshInfoClient{httpClient: httpClient} +func NewMeshInfoClient(client http.Client, endpoint xurl.URL) MeshInfoClient { + return meshInfoClient{client, endpoint} } -func (c meshInfoClient) Read(ctx context.Context) (*MeshInfo, error) { - meshInfoEndpoint := c.httpClient.RootUrl.JoinPath("/mesh/info") - info, err := internal.DoRequest[MeshInfo](ctx, c.httpClient, "GET", meshInfoEndpoint) +func (c meshInfoClient) Read(ctx context.Context) (MeshInfo, error) { + return c.DoRequest[MeshInfo](ctx, "GET", c.Endpoint.JoinPath("/mesh/info"), http.WithAccept("application/json")) +} + +func (info MeshInfo) CheckVersion() error { + meshVersion, err := version.Parse(info.Version) if err != nil { - return nil, fmt.Errorf("failed to retrieve meshStack instance information from %s endpoint: %w", meshInfoEndpoint, err) + return fmt.Errorf("failed to parse meshStack version %q: %w", info.Version, err) } - - info.Endpoint = c.httpClient.RootUrl.String() - info.EnabledFeatureFlags = []string{} - if info.IsFourEyesEnabled { - info.EnabledFeatureFlags = append(info.EnabledFeatureFlags, FeatureFlagFourEyesRoleApproval) + if meshVersion.Less(MinMeshStackVersion) { + return fmt.Errorf("unsupported meshStack version: meshStack is running version %s, but this client requires version %s or higher", meshVersion, MinMeshStackVersion) } - - return &info, nil + return nil } diff --git a/client/payment_method.go b/client/payment_method.go index f32ffc11..05f9f3ab 100644 --- a/client/payment_method.go +++ b/client/payment_method.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshPaymentMethod struct { @@ -20,8 +20,8 @@ type MeshPaymentMethodMetadata struct { type MeshPaymentMethodSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` - ExpirationDate *string `json:"expirationDate,omitempty" tfsdk:"expiration_date"` - Amount *int64 `json:"amount,omitempty" tfsdk:"amount"` + ExpirationDate *string `json:"expirationDate,omitzero" tfsdk:"expiration_date"` + Amount *int64 `json:"amount,omitzero" tfsdk:"amount"` Tags map[string][]string `json:"tags,omitempty" tfsdk:"tags"` } @@ -36,7 +36,7 @@ type MeshPaymentMethodCreateMetadata struct { } type MeshPaymentMethodClient interface { - Read(ctx context.Context, workspace string, identifier string) (*MeshPaymentMethod, error) + Read(ctx context.Context, identifier string) (*MeshPaymentMethod, error) Create(ctx context.Context, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) Update(ctx context.Context, identifier string, paymentMethod *MeshPaymentMethodCreate) (*MeshPaymentMethod, error) Delete(ctx context.Context, identifier string) error @@ -50,7 +50,7 @@ func newPaymentMethodClient(ctx context.Context, httpClient internal.HttpClient) return meshPaymentMethodClient{internal.NewMeshObjectClient[MeshPaymentMethod](ctx, httpClient, "v2")} } -func (c meshPaymentMethodClient) Read(ctx context.Context, workspace string, identifier string) (*MeshPaymentMethod, error) { +func (c meshPaymentMethodClient) Read(ctx context.Context, identifier string) (*MeshPaymentMethod, error) { return c.meshObject.Get(ctx, identifier) } diff --git a/client/platform.go b/client/platform.go index 9c0bbad8..a797d004 100644 --- a/client/platform.go +++ b/client/platform.go @@ -3,8 +3,9 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/internal/http" ) type MeshPlatform struct { @@ -15,21 +16,21 @@ type MeshPlatform struct { type MeshPlatformMetadata struct { Name string `json:"name" tfsdk:"name"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` } type MeshPlatformSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` Description string `json:"description" tfsdk:"description"` Endpoint string `json:"endpoint" tfsdk:"endpoint"` - SupportUrl *string `json:"supportUrl,omitempty" tfsdk:"support_url"` - DocumentationUrl *string `json:"documentationUrl,omitempty" tfsdk:"documentation_url"` - AccessInformation *string `json:"accessInformation,omitempty" tfsdk:"access_information"` + SupportUrl *string `json:"supportUrl,omitzero" tfsdk:"support_url"` + DocumentationUrl *string `json:"documentationUrl,omitzero" tfsdk:"documentation_url"` + AccessInformation *string `json:"accessInformation,omitzero" tfsdk:"access_information"` LocationRef NamedRef `json:"locationRef" tfsdk:"location_ref"` ContributingWorkspaces types.Set[string] `json:"contributingWorkspaces" tfsdk:"contributing_workspaces"` Availability PlatformAvailability `json:"availability" tfsdk:"availability"` // Config is nullable in responses: redacted (omitted) for marketplace-consumer callers. Required on write. - Config *PlatformConfig `json:"config,omitempty" tfsdk:"config"` + Config *PlatformConfig `json:"config,omitzero" tfsdk:"config"` QuotaDefinitions types.Set[QuotaDefinition] `json:"quotaDefinitions" tfsdk:"quota_definitions"` } @@ -51,14 +52,14 @@ type PlatformAvailability struct { type PlatformConfig struct { Type string `json:"type" tfsdk:"type"` - Custom *CustomPlatformConfig `json:"custom,omitempty" tfsdk:"custom"` - Aws *AwsPlatformConfig `json:"aws,omitempty" tfsdk:"aws"` - Aks *AksPlatformConfig `json:"aks,omitempty" tfsdk:"aks"` - Azure *AzurePlatformConfig `json:"azure,omitempty" tfsdk:"azure"` - AzureRg *AzureRgPlatformConfig `json:"azurerg,omitempty" tfsdk:"azurerg"` - Gcp *GcpPlatformConfig `json:"gcp,omitempty" tfsdk:"gcp"` - Kubernetes *KubernetesPlatformConfig `json:"kubernetes,omitempty" tfsdk:"kubernetes"` - OpenShift *OpenShiftPlatformConfig `json:"openshift,omitempty" tfsdk:"openshift"` + Custom *CustomPlatformConfig `json:"custom,omitzero" tfsdk:"custom"` + Aws *AwsPlatformConfig `json:"aws,omitzero" tfsdk:"aws"` + Aks *AksPlatformConfig `json:"aks,omitzero" tfsdk:"aks"` + Azure *AzurePlatformConfig `json:"azure,omitzero" tfsdk:"azure"` + AzureRg *AzureRgPlatformConfig `json:"azurerg,omitzero" tfsdk:"azurerg"` + Gcp *GcpPlatformConfig `json:"gcp,omitzero" tfsdk:"gcp"` + Kubernetes *KubernetesPlatformConfig `json:"kubernetes,omitzero" tfsdk:"kubernetes"` + OpenShift *OpenShiftPlatformConfig `json:"openshift,omitzero" tfsdk:"openshift"` } type MeshPlatformMeteringProcessingConfig struct { @@ -112,7 +113,7 @@ func (c meshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatfor } func (c meshPlatformClient) List(ctx context.Context, query MeshPlatformListQuery) ([]MeshPlatform, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(query)) + return c.meshObject.List(ctx, http.WithUrlQuery(query)) } func (c meshPlatformClient) Create(ctx context.Context, platform MeshPlatform) (*MeshPlatform, error) { diff --git a/client/platform_config_aks.go b/client/platform_config_aks.go index 8521a308..23848149 100644 --- a/client/platform_config_aks.go +++ b/client/platform_config_aks.go @@ -1,12 +1,12 @@ package client -import "github.com/meshcloud/terraform-provider-meshstack/client/types" +import "github.com/meshcloud/meshstack-cli/client/types" type AksPlatformConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` Replication *AksReplicationConfig `json:"replication" tfsdk:"replication"` - Metering *AksMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` + Metering *AksMeteringConfig `json:"metering,omitzero" tfsdk:"metering"` } type AksReplicationConfig struct { @@ -17,10 +17,10 @@ type AksReplicationConfig struct { AksSubscriptionId string `json:"aksSubscriptionId" tfsdk:"aks_subscription_id"` AksClusterName string `json:"aksClusterName" tfsdk:"aks_cluster_name"` AksResourceGroup string `json:"aksResourceGroup" tfsdk:"aks_resource_group"` - RedirectUrl *string `json:"redirectUrl,omitempty" tfsdk:"redirect_url"` + RedirectUrl *string `json:"redirectUrl,omitzero" tfsdk:"redirect_url"` SendAzureInvitationMail bool `json:"sendAzureInvitationMail" tfsdk:"send_azure_invitation_mail"` UserLookupStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitzero" tfsdk:"administrative_unit_id"` } type AksServicePrincipalConfig struct { diff --git a/client/platform_config_aws.go b/client/platform_config_aws.go index 55fa2053..ff57bda7 100644 --- a/client/platform_config_aws.go +++ b/client/platform_config_aws.go @@ -1,26 +1,26 @@ package client -import "github.com/meshcloud/terraform-provider-meshstack/client/types" +import "github.com/meshcloud/meshstack-cli/client/types" type AwsPlatformConfig struct { Region string `json:"region,omitempty" tfsdk:"region"` - Replication *AwsReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` - Metering *AwsMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` + Replication *AwsReplicationConfig `json:"replication,omitzero" tfsdk:"replication"` + Metering *AwsMeteringConfig `json:"metering,omitzero" tfsdk:"metering"` } type AwsReplicationConfig struct { AccessConfig AwsAccessConfig `json:"accessConfig" tfsdk:"access_config"` WaitForExternalAvm bool `json:"waitForExternalAvm" tfsdk:"wait_for_external_avm"` AutomationAccountRole string `json:"automationAccountRole" tfsdk:"automation_account_role"` - AutomationAccountExternalId *string `json:"automationAccountExternalId,omitempty" tfsdk:"automation_account_external_id"` + AutomationAccountExternalId *string `json:"automationAccountExternalId,omitzero" tfsdk:"automation_account_external_id"` AccountAccessRole string `json:"accountAccessRole" tfsdk:"account_access_role"` AccountAliasPattern string `json:"accountAliasPattern" tfsdk:"account_alias_pattern"` EnforceAccountAlias bool `json:"enforceAccountAlias" tfsdk:"enforce_account_alias"` AccountEmailPattern string `json:"accountEmailPattern" tfsdk:"account_email_pattern"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` - AwsSso *AwsSsoConfig `json:"awsSso,omitempty" tfsdk:"aws_sso"` - AwsIdentityStore *AwsIdentityStoreConfig `json:"awsIdentityStore,omitempty" tfsdk:"aws_identity_store"` - EnrollmentConfiguration *AwsEnrollmentConfiguration `json:"enrollmentConfiguration,omitempty" tfsdk:"enrollment_configuration"` + TenantTags *MeshTenantTags `json:"tenantTags,omitzero" tfsdk:"tenant_tags"` + AwsSso *AwsSsoConfig `json:"awsSso,omitzero" tfsdk:"aws_sso"` + AwsIdentityStore *AwsIdentityStoreConfig `json:"awsIdentityStore,omitzero" tfsdk:"aws_identity_store"` + EnrollmentConfiguration *AwsEnrollmentConfiguration `json:"enrollmentConfiguration,omitzero" tfsdk:"enrollment_configuration"` SelfDowngradeAccessRole bool `json:"selfDowngradeAccessRole" tfsdk:"self_downgrade_access_role"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` AllowHierarchicalOrganizationalUnitAssignment bool `json:"allowHierarchicalOrganizationalUnitAssignment" tfsdk:"allow_hierarchical_organizational_unit_assignment"` @@ -28,14 +28,14 @@ type AwsReplicationConfig struct { type AwsAccessConfig struct { OrganizationRootAccountRole string `json:"organizationRootAccountRole" tfsdk:"organization_root_account_role"` - OrganizationRootAccountExternalId *string `json:"organizationRootAccountExternalId,omitempty" tfsdk:"organization_root_account_external_id"` + OrganizationRootAccountExternalId *string `json:"organizationRootAccountExternalId,omitzero" tfsdk:"organization_root_account_external_id"` Auth AwsAuth `json:"auth" tfsdk:"auth"` } type AwsAuth struct { Type string `json:"type" tfsdk:"type"` - Credential *AwsServiceUserCredential `json:"credential,omitempty" tfsdk:"credential"` - WorkloadIdentity *AwsWorkloadIdentityCredential `json:"workloadIdentity,omitempty" tfsdk:"workload_identity"` + Credential *AwsServiceUserCredential `json:"credential,omitzero" tfsdk:"credential"` + WorkloadIdentity *AwsWorkloadIdentityCredential `json:"workloadIdentity,omitzero" tfsdk:"workload_identity"` } type AwsServiceUserCredential struct { diff --git a/client/platform_config_azure.go b/client/platform_config_azure.go index b5d68aa7..d6399bf3 100644 --- a/client/platform_config_azure.go +++ b/client/platform_config_azure.go @@ -1,25 +1,25 @@ package client -import "github.com/meshcloud/terraform-provider-meshstack/client/types" +import "github.com/meshcloud/meshstack-cli/client/types" type AzurePlatformConfig struct { EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` - Replication *AzureReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` - Metering *AzureMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` + Replication *AzureReplicationConfig `json:"replication,omitzero" tfsdk:"replication"` + Metering *AzureMeteringConfig `json:"metering,omitzero" tfsdk:"metering"` } type AzureReplicationConfig struct { ServicePrincipal AzureServicePrincipalConfig `json:"servicePrincipal" tfsdk:"service_principal"` UpdateSubscriptionName bool `json:"updateSubscriptionName" tfsdk:"update_subscription_name"` - Provisioning *AzureSubscriptionProvisioningConfig `json:"provisioning,omitempty" tfsdk:"provisioning"` - B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + Provisioning *AzureSubscriptionProvisioningConfig `json:"provisioning,omitzero" tfsdk:"provisioning"` + B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitzero" tfsdk:"b2b_user_invitation"` SubscriptionNamePattern string `json:"subscriptionNamePattern" tfsdk:"subscription_name_pattern"` GroupNamePattern string `json:"groupNamePattern" tfsdk:"group_name_pattern"` AzureRoleMappings types.Set[AzureRoleMapping] `json:"azureRoleMappings" tfsdk:"azure_role_mappings"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + TenantTags *MeshTenantTags `json:"tenantTags,omitzero" tfsdk:"tenant_tags"` UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitzero" tfsdk:"administrative_unit_id"` AllowHierarchicalManagementGroupAssignment bool `json:"allowHierarchicalManagementGroupAssignment" tfsdk:"allow_hierarchical_management_group_assignment"` } @@ -31,7 +31,7 @@ type AzureServicePrincipalConfig struct { type AzureAuthConfig struct { Type string `json:"type" tfsdk:"type"` - Credential *types.Secret `json:"credential,omitempty" tfsdk:"credential"` + Credential *types.Secret `json:"credential,omitzero" tfsdk:"credential"` } type AzureGraphApiCredentials struct { @@ -41,16 +41,16 @@ type AzureGraphApiCredentials struct { type AzureSubscriptionProvisioningConfig struct { SubscriptionOwnerObjectIds types.Set[string] `json:"subscriptionOwnerObjectIds" tfsdk:"subscription_owner_object_ids"` - EnterpriseEnrollment *AzureEnterpriseEnrollmentConfig `json:"enterpriseEnrollment,omitempty" tfsdk:"enterprise_enrollment"` - CustomerAgreement *AzureCustomerAgreementConfig `json:"customerAgreement,omitempty" tfsdk:"customer_agreement"` - PreProvisioned *AzurePreProvisionedSubscriptionConfig `json:"preProvisioned,omitempty" tfsdk:"pre_provisioned"` + EnterpriseEnrollment *AzureEnterpriseEnrollmentConfig `json:"enterpriseEnrollment,omitzero" tfsdk:"enterprise_enrollment"` + CustomerAgreement *AzureCustomerAgreementConfig `json:"customerAgreement,omitzero" tfsdk:"customer_agreement"` + PreProvisioned *AzurePreProvisionedSubscriptionConfig `json:"preProvisioned,omitzero" tfsdk:"pre_provisioned"` } type AzureEnterpriseEnrollmentConfig struct { EnrollmentAccountId string `json:"enrollmentAccountId" tfsdk:"enrollment_account_id"` SubscriptionOfferType string `json:"subscriptionOfferType" tfsdk:"subscription_offer_type"` UseLegacySubscriptionEnrollment bool `json:"useLegacySubscriptionEnrollment" tfsdk:"use_legacy_subscription_enrollment"` - SubscriptionCreationErrorCooldownSec *int64 `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` + SubscriptionCreationErrorCooldownSec *int64 `json:"subscriptionCreationErrorCooldownSec,omitzero" tfsdk:"subscription_creation_error_cooldown_sec"` } type AzureCustomerAgreementConfig struct { @@ -58,7 +58,7 @@ type AzureCustomerAgreementConfig struct { DestinationEntraId string `json:"destinationEntraId" tfsdk:"destination_entra_id"` SourceEntraTenant string `json:"sourceEntraTenant" tfsdk:"source_entra_tenant"` BillingScope string `json:"billingScope" tfsdk:"billing_scope"` - SubscriptionCreationErrorCooldownSec *int64 `json:"subscriptionCreationErrorCooldownSec,omitempty" tfsdk:"subscription_creation_error_cooldown_sec"` + SubscriptionCreationErrorCooldownSec *int64 `json:"subscriptionCreationErrorCooldownSec,omitzero" tfsdk:"subscription_creation_error_cooldown_sec"` } type AzurePreProvisionedSubscriptionConfig struct { diff --git a/client/platform_config_azurerg.go b/client/platform_config_azurerg.go index dab2f733..0df73057 100644 --- a/client/platform_config_azurerg.go +++ b/client/platform_config_azurerg.go @@ -2,7 +2,7 @@ package client type AzureRgPlatformConfig struct { EntraTenant string `json:"entraTenant" tfsdk:"entra_tenant"` - Replication *AzureRgReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` + Replication *AzureRgReplicationConfig `json:"replication,omitzero" tfsdk:"replication"` } type AzureRgReplicationConfig struct { @@ -10,9 +10,9 @@ type AzureRgReplicationConfig struct { Subscription string `json:"subscription" tfsdk:"subscription"` ResourceGroupNamePattern string `json:"resourceGroupNamePattern" tfsdk:"resource_group_name_pattern"` UserGroupNamePattern string `json:"userGroupNamePattern" tfsdk:"user_group_name_pattern"` - B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitempty" tfsdk:"b2b_user_invitation"` + B2bUserInvitation *AzureInviteB2BUserConfig `json:"b2bUserInvitation,omitzero" tfsdk:"b2b_user_invitation"` UserLookUpStrategy string `json:"userLookUpStrategy" tfsdk:"user_lookup_strategy"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + TenantTags *MeshTenantTags `json:"tenantTags,omitzero" tfsdk:"tenant_tags"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` - AdministrativeUnitId *string `json:"administrativeUnitId,omitempty" tfsdk:"administrative_unit_id"` + AdministrativeUnitId *string `json:"administrativeUnitId,omitzero" tfsdk:"administrative_unit_id"` } diff --git a/client/platform_config_custom.go b/client/platform_config_custom.go index 03a632d3..e00e0e9f 100644 --- a/client/platform_config_custom.go +++ b/client/platform_config_custom.go @@ -2,9 +2,9 @@ package client type CustomPlatformConfig struct { PlatformTypeRef NamedRef `json:"platformTypeRef" tfsdk:"platform_type_ref"` - Metering *CustomMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` + Metering *CustomMeteringConfig `json:"metering,omitzero" tfsdk:"metering"` } type CustomMeteringConfig struct { - Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitempty" tfsdk:"processing"` + Processing *MeshPlatformMeteringProcessingConfig `json:"processing,omitzero" tfsdk:"processing"` } diff --git a/client/platform_config_gcp.go b/client/platform_config_gcp.go index 3c27767f..44ea68a3 100644 --- a/client/platform_config_gcp.go +++ b/client/platform_config_gcp.go @@ -1,10 +1,10 @@ package client -import "github.com/meshcloud/terraform-provider-meshstack/client/types" +import "github.com/meshcloud/meshstack-cli/client/types" type GcpPlatformConfig struct { - Replication *GcpReplicationConfig `json:"replication,omitempty" tfsdk:"replication"` - Metering *GcpMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` + Replication *GcpReplicationConfig `json:"replication,omitzero" tfsdk:"replication"` + Metering *GcpMeteringConfig `json:"metering,omitzero" tfsdk:"metering"` } type GcpReplicationConfig struct { @@ -16,17 +16,17 @@ type GcpReplicationConfig struct { ProjectIdPattern string `json:"projectIdPattern" tfsdk:"project_id_pattern"` BillingAccountId string `json:"billingAccountId" tfsdk:"billing_account_id"` UserLookupStrategy string `json:"userLookupStrategy" tfsdk:"user_lookup_strategy"` - UsedExternalIdType *string `json:"usedExternalIdType,omitempty" tfsdk:"used_external_id_type"` + UsedExternalIdType *string `json:"usedExternalIdType,omitzero" tfsdk:"used_external_id_type"` GcpRoleMappings types.Set[GcpPlatformRoleMapping] `json:"gcpRoleMappings" tfsdk:"gcp_role_mappings"` AllowHierarchicalFolderAssignment bool `json:"allowHierarchicalFolderAssignment" tfsdk:"allow_hierarchical_folder_assignment"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + TenantTags *MeshTenantTags `json:"tenantTags,omitzero" tfsdk:"tenant_tags"` SkipUserGroupPermissionCleanup bool `json:"skipUserGroupPermissionCleanup" tfsdk:"skip_user_group_permission_cleanup"` } type GcpServiceAccountConfig struct { Type string `json:"type" tfsdk:"type"` - Credential *types.Secret `json:"credential,omitempty" tfsdk:"credential"` - WorkloadIdentity *GcpServiceAccountWorkloadIdentityConfig `json:"workloadIdentity,omitempty" tfsdk:"workload_identity"` + Credential *types.Secret `json:"credential,omitzero" tfsdk:"credential"` + WorkloadIdentity *GcpServiceAccountWorkloadIdentityConfig `json:"workloadIdentity,omitzero" tfsdk:"workload_identity"` } type GcpServiceAccountWorkloadIdentityConfig struct { @@ -42,9 +42,9 @@ type GcpPlatformRoleMapping struct { type GcpMeteringConfig struct { ServiceAccount GcpServiceAccountConfig `json:"serviceAccount" tfsdk:"service_account"` BigqueryTable string `json:"bigqueryTable" tfsdk:"bigquery_table"` - BigqueryTableForCarbonFootprint *string `json:"bigqueryTableForCarbonFootprint,omitempty" tfsdk:"bigquery_table_for_carbon_footprint"` - CarbonFootprintDataCollectionStartMonth *string `json:"carbonFootprintDataCollectionStartMonth,omitempty" tfsdk:"carbon_footprint_data_collection_start_month"` + BigqueryTableForCarbonFootprint *string `json:"bigqueryTableForCarbonFootprint,omitzero" tfsdk:"bigquery_table_for_carbon_footprint"` + CarbonFootprintDataCollectionStartMonth *string `json:"carbonFootprintDataCollectionStartMonth,omitzero" tfsdk:"carbon_footprint_data_collection_start_month"` PartitionTimeColumn string `json:"partitionTimeColumn" tfsdk:"partition_time_column"` - AdditionalFilter *string `json:"additionalFilter,omitempty" tfsdk:"additional_filter"` + AdditionalFilter *string `json:"additionalFilter,omitzero" tfsdk:"additional_filter"` Processing MeshPlatformMeteringProcessingConfig `json:"processing" tfsdk:"processing"` } diff --git a/client/platform_config_kubernetes.go b/client/platform_config_kubernetes.go index e4b34cf5..bdb541b0 100644 --- a/client/platform_config_kubernetes.go +++ b/client/platform_config_kubernetes.go @@ -1,12 +1,12 @@ package client -import "github.com/meshcloud/terraform-provider-meshstack/client/types" +import "github.com/meshcloud/meshstack-cli/client/types" type KubernetesPlatformConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` Replication *KubernetesReplicationConfig `json:"replication" tfsdk:"replication"` - Metering *KubernetesMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` + Metering *KubernetesMeteringConfig `json:"metering,omitzero" tfsdk:"metering"` } type KubernetesReplicationConfig struct { diff --git a/client/platform_config_openshift.go b/client/platform_config_openshift.go index 123c6d85..09034c7f 100644 --- a/client/platform_config_openshift.go +++ b/client/platform_config_openshift.go @@ -1,21 +1,21 @@ package client -import "github.com/meshcloud/terraform-provider-meshstack/client/types" +import "github.com/meshcloud/meshstack-cli/client/types" type OpenShiftPlatformConfig struct { BaseUrl string `json:"baseUrl" tfsdk:"base_url"` DisableSslValidation bool `json:"disableSslValidation" tfsdk:"disable_ssl_validation"` Replication *OpenShiftReplicationConfig `json:"replication" tfsdk:"replication"` - Metering *OpenShiftMeteringConfig `json:"metering,omitempty" tfsdk:"metering"` + Metering *OpenShiftMeteringConfig `json:"metering,omitzero" tfsdk:"metering"` } type OpenShiftReplicationConfig struct { ClientConfig KubernetesClientConfig `json:"clientConfig" tfsdk:"client_config"` - WebConsoleUrl *string `json:"webConsoleUrl,omitempty" tfsdk:"web_console_url"` + WebConsoleUrl *string `json:"webConsoleUrl,omitzero" tfsdk:"web_console_url"` ProjectNamePattern string `json:"projectNamePattern" tfsdk:"project_name_pattern"` OpenshiftRoleMappings types.Set[OpenShiftPlatformRoleMapping] `json:"openshiftRoleMappings" tfsdk:"openshift_role_mappings"` IdentityProviderName string `json:"identityProviderName" tfsdk:"identity_provider_name"` - TenantTags *MeshTenantTags `json:"tenantTags,omitempty" tfsdk:"tenant_tags"` + TenantTags *MeshTenantTags `json:"tenantTags,omitzero" tfsdk:"tenant_tags"` } type OpenShiftMeteringConfig struct { diff --git a/client/platform_properties_azurerg.go b/client/platform_properties_azurerg.go index 4bc2b700..079ba259 100644 --- a/client/platform_properties_azurerg.go +++ b/client/platform_properties_azurerg.go @@ -3,7 +3,7 @@ package client type AzureRgPlatformProperties struct { AzureRgLocation string `json:"azureRgLocation" tfsdk:"azure_rg_location"` AzureRgRoleMappings []AzureRgRoleMapping `json:"azureRgRoleMappings" tfsdk:"azure_rg_role_mappings"` - AzureFunction *AzureFunction `json:"azureFunction,omitempty" tfsdk:"azure_function"` + AzureFunction *AzureFunction `json:"azureFunction,omitzero" tfsdk:"azure_function"` } type AzureRgRoleMapping struct { diff --git a/client/platform_properties_gcp.go b/client/platform_properties_gcp.go index f10ae346..b1680c06 100644 --- a/client/platform_properties_gcp.go +++ b/client/platform_properties_gcp.go @@ -1,8 +1,8 @@ package client type GcpPlatformProperties struct { - GcpCloudFunctionUrl *string `json:"gcpCloudFunctionUrl,omitempty" tfsdk:"gcp_cloud_function_url"` - GcpFolderId *string `json:"gcpFolderId,omitempty" tfsdk:"gcp_folder_id"` + GcpCloudFunctionUrl *string `json:"gcpCloudFunctionUrl,omitzero" tfsdk:"gcp_cloud_function_url"` + GcpFolderId *string `json:"gcpFolderId,omitzero" tfsdk:"gcp_folder_id"` GcpRoleMappings []GcpRoleMapping `json:"gcpRoleMappings" tfsdk:"gcp_role_mappings"` } diff --git a/client/platform_type.go b/client/platform_type.go index 270a5c61..69cb3bfe 100644 --- a/client/platform_type.go +++ b/client/platform_type.go @@ -3,7 +3,8 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/internal/http" ) type MeshPlatformType struct { @@ -23,13 +24,13 @@ type MeshPlatformTypeLifecycle struct { type MeshPlatformTypeMetadata struct { Name string `json:"name" tfsdk:"name"` OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` - Uuid *string `json:"uuid,omitempty" tfsdk:"uuid"` + Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` } type MeshPlatformTypeSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` Category string `json:"category" tfsdk:"category"` - DefaultEndpoint *string `json:"defaultEndpoint,omitempty" tfsdk:"default_endpoint"` + DefaultEndpoint *string `json:"defaultEndpoint,omitzero" tfsdk:"default_endpoint"` Icon string `json:"icon" tfsdk:"icon"` } @@ -81,7 +82,7 @@ type meshPlatformTypeListQuery struct { } func (c meshPlatformTypeClient) List(ctx context.Context, category *string, lifecycleStatus *string) ([]MeshPlatformType, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(meshPlatformTypeListQuery{ + return c.meshObject.List(ctx, http.WithUrlQuery(meshPlatformTypeListQuery{ Category: category, LifecycleStatus: lifecycleStatus, })) diff --git a/client/project.go b/client/project.go index 1f9078bc..5da6d19b 100644 --- a/client/project.go +++ b/client/project.go @@ -3,7 +3,8 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/internal/http" ) type MeshProject struct { @@ -51,10 +52,6 @@ func newProjectClient(ctx context.Context, httpClient internal.HttpClient) MeshP return meshProjectClient{internal.NewMeshObjectClient[MeshProject](ctx, httpClient, "v2")} } -func (c meshProjectClient) projectId(workspace string, name string) string { - return workspace + "." + name -} - func (c meshProjectClient) Read(ctx context.Context, workspace string, name string) (*MeshProject, error) { return c.meshObject.Get(ctx, c.projectId(workspace, name)) } @@ -65,7 +62,7 @@ type meshProjectListQuery struct { } func (c meshProjectClient) List(ctx context.Context, workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(meshProjectListQuery{ + return c.meshObject.List(ctx, http.WithUrlQuery(meshProjectListQuery{ WorkspaceIdentifier: workspaceIdentifier, PaymentIdentifier: paymentMethodIdentifier, })) @@ -82,3 +79,7 @@ func (c meshProjectClient) Update(ctx context.Context, project *MeshProjectCreat func (c meshProjectClient) Delete(ctx context.Context, workspace string, name string) error { return c.meshObject.Delete(ctx, c.projectId(workspace, name)) } + +func (c meshProjectClient) projectId(workspace string, name string) string { + return workspace + "." + name +} diff --git a/client/project_binding.go b/client/project_binding.go index df1529d4..e4a5ca55 100644 --- a/client/project_binding.go +++ b/client/project_binding.go @@ -11,6 +11,8 @@ type MeshProjectBindingMetadata struct { Name string `json:"name" tfsdk:"name"` } +// MeshProjectRoleRef names a role by its name alone. +// // Deprecated: Use NamedRef if possible. The convention is to also provide the `kind`, // so this struct should only be used for meshobjects that violate our API conventions. type MeshProjectRoleRef struct { diff --git a/client/project_group_binding.go b/client/project_group_binding.go index 90eda95e..85872ef2 100644 --- a/client/project_group_binding.go +++ b/client/project_group_binding.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshProjectGroupBinding struct { diff --git a/client/project_user_binding.go b/client/project_user_binding.go index d6ed6ca6..2b5b418b 100644 --- a/client/project_user_binding.go +++ b/client/project_user_binding.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshProjectUserBinding struct { diff --git a/client/service_instance.go b/client/service_instance.go index 74918e76..3b111413 100644 --- a/client/service_instance.go +++ b/client/service_instance.go @@ -3,8 +3,9 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/types" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/internal/http" ) type MeshServiceInstance struct { @@ -53,5 +54,5 @@ func (c meshServiceInstanceClient) Read(ctx context.Context, instanceId string) } func (c meshServiceInstanceClient) List(ctx context.Context, filter MeshServiceInstanceFilter) ([]MeshServiceInstance, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(filter)) + return c.meshObject.List(ctx, http.WithUrlQuery(filter)) } diff --git a/client/tag_definition.go b/client/tag_definition.go index 2844d0cd..0e002266 100644 --- a/client/tag_definition.go +++ b/client/tag_definition.go @@ -3,11 +3,9 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) -const API_VERSION_TAG_DEFINITION = "v1" - type MeshTagDefinition struct { Metadata MeshTagDefinitionMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshTagDefinitionSpec `json:"spec" tfsdk:"spec"` @@ -27,44 +25,44 @@ type MeshTagDefinitionSpec struct { Mandatory bool `json:"mandatory" tfsdk:"mandatory"` Immutable bool `json:"immutable" tfsdk:"immutable"` Restricted bool `json:"restricted" tfsdk:"restricted"` - ReplicationKey *string `json:"replicationKey,omitempty" tfsdk:"replication_key"` + ReplicationKey *string `json:"replicationKey,omitzero" tfsdk:"replication_key"` } type MeshTagDefinitionValueType struct { - String *TagValueString `json:"string,omitempty" tfsdk:"string"` - Email *TagValueEmail `json:"email,omitempty" tfsdk:"email"` - Integer *TagValueInteger `json:"integer,omitempty" tfsdk:"integer"` - Number *TagValueNumber `json:"number,omitempty" tfsdk:"number"` - SingleSelect *TagValueSingleSelect `json:"singleSelect,omitempty" tfsdk:"single_select"` - MultiSelect *TagValueMultiSelect `json:"multiSelect,omitempty" tfsdk:"multi_select"` + String *TagValueString `json:"string,omitzero" tfsdk:"string"` + Email *TagValueEmail `json:"email,omitzero" tfsdk:"email"` + Integer *TagValueInteger `json:"integer,omitzero" tfsdk:"integer"` + Number *TagValueNumber `json:"number,omitzero" tfsdk:"number"` + SingleSelect *TagValueSingleSelect `json:"singleSelect,omitzero" tfsdk:"single_select"` + MultiSelect *TagValueMultiSelect `json:"multiSelect,omitzero" tfsdk:"multi_select"` } type TagValueString struct { - DefaultValue *string `json:"defaultValue,omitempty" tfsdk:"default_value"` - ValidationRegex *string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` + DefaultValue *string `json:"defaultValue,omitzero" tfsdk:"default_value"` + ValidationRegex *string `json:"validationRegex,omitzero" tfsdk:"validation_regex"` } type TagValueEmail struct { - DefaultValue *string `json:"defaultValue,omitempty" tfsdk:"default_value"` - ValidationRegex *string `json:"validationRegex,omitempty" tfsdk:"validation_regex"` + DefaultValue *string `json:"defaultValue,omitzero" tfsdk:"default_value"` + ValidationRegex *string `json:"validationRegex,omitzero" tfsdk:"validation_regex"` } type TagValueInteger struct { - DefaultValue *int64 `json:"defaultValue,omitempty" tfsdk:"default_value"` + DefaultValue *int64 `json:"defaultValue,omitzero" tfsdk:"default_value"` } type TagValueNumber struct { - DefaultValue *float64 `json:"defaultValue,omitempty" tfsdk:"default_value"` + DefaultValue *float64 `json:"defaultValue,omitzero" tfsdk:"default_value"` } type TagValueSingleSelect struct { Options []string `json:"options,omitempty" tfsdk:"options"` - DefaultValue *string `json:"defaultValue,omitempty" tfsdk:"default_value"` + DefaultValue *string `json:"defaultValue,omitzero" tfsdk:"default_value"` } type TagValueMultiSelect struct { Options []string `json:"options,omitempty" tfsdk:"options"` - DefaultValue *[]string `json:"defaultValue,omitempty" tfsdk:"default_value"` + DefaultValue *[]string `json:"defaultValue,omitzero" tfsdk:"default_value"` } type MeshTagDefinitionClient interface { diff --git a/client/tenant_v4.go b/client/tenant_v4.go index 195f2701..4158f467 100644 --- a/client/tenant_v4.go +++ b/client/tenant_v4.go @@ -2,10 +2,12 @@ package client import ( "context" + "errors" "fmt" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" - "github.com/meshcloud/terraform-provider-meshstack/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/internal/http" ) type TenantLifecycleState string @@ -142,7 +144,7 @@ func (c meshTenantClient) Create(ctx context.Context, tenant *MeshTenantCreate) } func (c meshTenantClient) List(ctx context.Context, query MeshTenantQuery) ([]MeshTenant, error) { - return c.meshObject.List(ctx, internal.WithUrlQuery(query)) + return c.meshObject.List(ctx, http.WithUrlQuery(query)) } func (c meshTenantClient) Delete(ctx context.Context, uuid string) error { @@ -152,7 +154,7 @@ func (c meshTenantClient) Delete(ctx context.Context, uuid string) error { func (tenant *MeshTenant) CreationSuccessful() (done bool, err error) { switch { case tenant == nil: - err = fmt.Errorf("tenant not found after creation") + err = errors.New("tenant not found after creation") case tenant.Spec.PlatformTenantId != nil && *tenant.Spec.PlatformTenantId != "": // Creation is complete (platformTenantId is set and not empty) done = true diff --git a/client/types/clienttypes.go b/client/types/clienttypes.go index e0ec3204..733f067a 100644 --- a/client/types/clienttypes.go +++ b/client/types/clienttypes.go @@ -4,7 +4,7 @@ import ( "reflect" "strings" - "github.com/meshcloud/terraform-provider-meshstack/client/types/variant" + "github.com/meshcloud/meshstack-cli/client/types/variant" ) type ( @@ -12,9 +12,9 @@ type ( Secret struct { // Plaintext is optionally set if secret is initially created (or rotated later) - Plaintext *string `json:"plaintext,omitempty" tfsdk:"plaintext"` + Plaintext *string `json:"plaintext,omitzero" tfsdk:"plaintext"` // Hash is always present in responses (Plaintext is never returned) and set in requests if secret is supposed to be kept. - Hash *string `json:"hash,omitempty" tfsdk:"-"` + Hash *string `json:"hash,omitzero" tfsdk:"-"` } SecretOrAny = variant.Variant[Secret, any] @@ -24,9 +24,7 @@ type ( // IsSet returns true if the given type uses the generic Set type, ignoring the concrete container type T. func IsSet(other reflect.Type) bool { - var ( - setType = reflect.TypeFor[Set[any]]() - ) + setType := reflect.TypeFor[Set[any]]() if other.PkgPath() == setType.PkgPath() { stripGenerics := func(s string) string { if startIdx := strings.Index(s, "["); startIdx > 0 { diff --git a/client/types/clienttypes_test.go b/client/types/clienttypes_test.go index 569bf723..18ad6954 100644 --- a/client/types/clienttypes_test.go +++ b/client/types/clienttypes_test.go @@ -1,50 +1,12 @@ package types import ( - "encoding/json" "reflect" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestSecretOrAny(t *testing.T) { - type testCase struct { - name string - json string - v SecretOrAny - - wantX, wantY bool - } - tests := []testCase{ - {"empty", `null`, SecretOrAny{}, false, false}, - {"X plaintext", `{"plaintext":"some-secret"}`, SecretOrAny{X: Secret{Plaintext: new("some-secret")}}, true, false}, - {"Y string", `"some-string"`, SecretOrAny{Y: "some-string"}, false, true}, - {"Y bool", `true`, SecretOrAny{Y: true}, false, true}, - {"Y number", `1.23123`, SecretOrAny{Y: 1.23123}, false, true}, - {"Y empty string", `""`, SecretOrAny{Y: ""}, false, true}, - {"Y other struct", `{"A":"aa","B":"bb"}`, SecretOrAny{Y: map[string]any{"A": "aa", "B": "bb"}}, false, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Run("unmarshal", func(t *testing.T) { - var unmarshalled SecretOrAny - require.NoError(t, json.Unmarshal([]byte(tt.json), &unmarshalled)) - assert.Equal(t, tt.v, unmarshalled) - assert.Equal(t, tt.wantX, unmarshalled.HasX()) - assert.Equal(t, tt.wantY, unmarshalled.HasY()) - }) - - t.Run("marshal", func(t *testing.T) { - marshalled, err := json.Marshal(tt.v) - require.NoError(t, err) - assert.Equal(t, tt.json, string(marshalled)) - }) - }) - } -} - func TestIsSet(t *testing.T) { type ( someStruct struct { @@ -60,10 +22,10 @@ func TestIsSet(t *testing.T) { }{ {"bool", reflect.TypeFor[bool](), false}, {"any", reflect.TypeFor[any](), false}, - {"int", reflect.TypeFor[any](), false}, + {"int", reflect.TypeFor[int](), false}, {"some set (not supported)", reflect.TypeFor[someSet](), false}, {"set of string", reflect.TypeFor[Set[string]](), true}, - {"set of int", reflect.TypeFor[Set[string]](), true}, + {"set of int", reflect.TypeFor[Set[int]](), true}, {"set of struct", reflect.TypeFor[Set[someStruct]](), true}, {"set of some string", reflect.TypeFor[Set[someString]](), true}, } diff --git a/client/types/variant/variant.go b/client/types/variant/variant.go index a7f3f66a..de601918 100644 --- a/client/types/variant/variant.go +++ b/client/types/variant/variant.go @@ -1,7 +1,7 @@ package variant import ( - "encoding/json" + "encoding/json/v2" "errors" "fmt" "reflect" @@ -21,14 +21,22 @@ var ( _ json.Marshaler = Variant[int, string]{} ) +// wireCompatibility repeats the options internal/json marshals every request with: a v1-style +// MarshalJSON receives none of its caller's. A Y decoded from JSON is a map, so without +// Deterministic it would leave this method in a random member order. +var wireCompatibility = json.JoinOptions( + json.Deterministic(true), + json.FormatNilSliceAsNull(true), + json.FormatNilMapAsNull(true), +) + func (v Variant[X, Y]) MarshalJSON() ([]byte, error) { if v.HasX() { - return json.Marshal(v.X) + return json.Marshal(v.X, wireCompatibility) } else if v.HasY() { - return json.Marshal(v.Y) - } else { - return json.Marshal(nil) + return json.Marshal(v.Y, wireCompatibility) } + return json.Marshal(nil) } func has[T any](xy any) bool { diff --git a/client/types/xurl/url.go b/client/types/xurl/url.go new file mode 100644 index 00000000..2ef1aa92 --- /dev/null +++ b/client/types/xurl/url.go @@ -0,0 +1,73 @@ +package xurl + +import ( + "encoding" + "errors" + "fmt" + "net" + "net/url" + "strings" +) + +var ( + _ encoding.TextUnmarshaler = &URL{} + _ encoding.TextMarshaler = URL{} +) + +type URL struct { + *url.URL +} + +func MustParsef(format string, args ...any) (result URL) { + if err := result.UnmarshalText([]byte(fmt.Sprintf(format, args...))); err != nil { + panic(err) + } + return +} + +// UnmarshalText validates and canonicalizes the URL as well. +// +//goland:noinspection GoMixedReceiverTypes +func (u *URL) UnmarshalText(text []byte) (err error) { + u.URL, err = url.ParseRequestURI(string(text)) + if err != nil { + return + } + if u.Path == "/" { + u.Path = "" + } + if !u.IsAbs() { + return fmt.Errorf("unmarshaled URL '%s' is not absolute", u) + } + u.Host = strings.ToLower(u.Host) + if u.Scheme != "https" && (u.Scheme != "http" || !isLoopback(u.Hostname())) { + return errors.New("URLs must start with 'https://' unless the host is localhost or another loopback address") + } + return +} + +func isLoopback(hostname string) bool { + if hostname == "localhost" { + return true + } + ip := net.ParseIP(hostname) + return ip != nil && ip.IsLoopback() +} + +func (u URL) Equal(other URL) bool { + if u.URL == nil || other.URL == nil { + return false + } + return u.String() == other.String() +} + +func (u URL) MarshalText() ([]byte, error) { + if u.URL == nil { + return nil, errors.New("a zero URL cannot be marshaled; declare an optional URL field as *URL") + } + return []byte(u.String()), nil +} + +func (u URL) Clone() URL { + return URL{u.URL.Clone()} +} diff --git a/client/version/version.go b/client/version/version.go deleted file mode 100644 index 5a259585..00000000 --- a/client/version/version.go +++ /dev/null @@ -1,75 +0,0 @@ -package version - -import ( - "cmp" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" -) - -type Version struct { - Major, Minor, Patch int -} - -func Parse(s string) (Version, error) { - parts := strings.Split(s, ".") - if len(parts) != 3 { - return Version{}, fmt.Errorf("cannot parse '%s' as version: expected 3, got %d fields separated by '.'", s, len(parts)) - } - var errs []error - partTo := func(i int, target *int) { - parsed, err := strconv.Atoi(parts[i]) - if err == nil && parsed < 0 { - err = fmt.Errorf("negative number '%d' not allowed", parsed) - } - if err != nil { - errs = append(errs, fmt.Errorf("part i=%d: %w", i, err)) - } else { - *target = parsed - } - } - var result Version - partTo(0, &result.Major) - partTo(1, &result.Minor) - partTo(2, &result.Patch) - if len(errs) > 0 { - return Version{}, fmt.Errorf("cannot parse '%s' as version: %w", s, errors.Join(errs...)) - } - return result, nil -} - -func MustParse(s string) Version { - version, err := Parse(s) - if err != nil { - panic(err) - } - return version -} - -func (v Version) Compare(other Version) int { - if major := cmp.Compare(v.Major, other.Major); major != 0 { - return major - } else if minor := cmp.Compare(v.Minor, other.Minor); minor != 0 { - return minor - } - return cmp.Compare(v.Patch, other.Patch) -} - -func (v Version) Less(other Version) bool { - return v.Compare(other) < 0 -} - -func (v Version) String() string { - return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) -} - -func (v *Version) UnmarshalJSON(bytes []byte) (err error) { - var s string - if err = json.Unmarshal(bytes, &s); err != nil { - return - } - *v, err = Parse(s) - return -} diff --git a/client/version/version_test.go b/client/version/version_test.go deleted file mode 100644 index aa7911b6..00000000 --- a/client/version/version_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package version - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParse(t *testing.T) { - assertErrorContainsAllOf := func(contains ...string) assert.ErrorAssertionFunc { - return func(t assert.TestingT, err error, msgAndArgs ...any) bool { - assert.NotEmpty(t, contains) - allOk := true - for _, contain := range contains { - ok := assert.ErrorContains(t, err, contain, msgAndArgs...) - allOk = allOk && ok - } - return allOk - } - } - tests := []struct { - name string - s string - want Version - wantErr assert.ErrorAssertionFunc - }{ - {"valid 1.0.0", "1.0.0", Version{1, 0, 0}, assert.NoError}, - {"valid 1.3.2", "1.3.2", Version{1, 3, 2}, assert.NoError}, - {"not enough parts", "1.1", Version{}, assertErrorContainsAllOf("cannot parse '1.1' as version: expected 3, got 2 fields separated by '.'")}, - {"negative minor", "1.-1.0", Version{}, assertErrorContainsAllOf("cannot parse '1.-1.0' as version: part i=1: negative number '-1' not allowed")}, - {"not a number", "1.1.x", Version{}, assertErrorContainsAllOf(`cannot parse '1.1.x' as version: part i=2: strconv.Atoi: parsing "x": invalid syntax`)}, - {"number too large", "100000000000000000000.1.0", Version{}, assertErrorContainsAllOf(`cannot parse '100000000000000000000.1.0' as version: part i=0: strconv.Atoi: parsing "100000000000000000000": value out of range`)}, - {"multiple errors", "y.x.1", Version{}, assertErrorContainsAllOf(`part i=0: strconv.Atoi: parsing "y": invalid syntax`, `part i=1: strconv.Atoi: parsing "x": invalid syntax`)}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotV, err := Parse(tt.s) - if !tt.wantErr(t, err, fmt.Sprintf("Parse(%v)", tt.s)) { - return - } - assert.Equalf(t, tt.want, gotV, "Parse(%v)", tt.s) - }) - } -} - -func TestMustParse(t *testing.T) { - assert.NotPanics(t, func() { - MustParse("1.0.0") - }) - assert.Panics(t, func() { - MustParse("1.x.0") - }) -} - -func TestVersion_Compare(t *testing.T) { - tests := []struct { - v, other string - want int - }{ - {"0.0.0", "0.0.0", 0}, - {"0.1.0", "0.1.0", 0}, - {"1.1.0", "0.1.0", 1}, - {"1.1.12312331222", "2.1.0", -1}, - {"1.2.0", "1.3.0", -1}, - {"1.2.1", "1.2.0", 1}, - } - for _, tt := range tests { - symbol := "==" - if tt.want < 0 { - symbol = "<" - } else if tt.want > 0 { - symbol = ">" - } - t.Run(fmt.Sprintf("%s %s %s", tt.v, symbol, tt.other), func(t *testing.T) { - v, err := Parse(tt.v) - require.NoError(t, err) - other, err := Parse(tt.other) - require.NoError(t, err) - cmp := v.Compare(other) - assert.Equal(t, tt.want, cmp) - if cmp < 0 { - assert.True(t, v.Less(other)) - } else { - assert.False(t, v.Less(other)) - } - }) - } -} - -func TestVersion_String(t *testing.T) { - assert.Equal(t, "1.2.3", Version{1, 2, 3}.String()) -} diff --git a/client/workspace.go b/client/workspace.go index 1950b504..8f03f703 100644 --- a/client/workspace.go +++ b/client/workspace.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshWorkspace struct { @@ -20,7 +20,7 @@ type MeshWorkspaceMetadata struct { type MeshWorkspaceSpec struct { DisplayName string `json:"displayName" tfsdk:"display_name"` - PlatformBuilderAccessEnabled *bool `json:"platformBuilderAccessEnabled,omitempty" tfsdk:"platform_builder_access_enabled"` + PlatformBuilderAccessEnabled *bool `json:"platformBuilderAccessEnabled,omitzero" tfsdk:"platform_builder_access_enabled"` } type MeshWorkspaceCreate struct { @@ -33,6 +33,9 @@ type MeshWorkspaceCreateMetadata struct { } type MeshWorkspaceClient interface { + // List returns every workspace the credential can see. An unscoped user token reaches this and + // almost nothing else, which is why `meshstack auth login` prompts for a workspace from it. + List(ctx context.Context) ([]MeshWorkspace, error) Read(ctx context.Context, name string) (*MeshWorkspace, error) Create(ctx context.Context, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) Update(ctx context.Context, name string, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) @@ -47,6 +50,10 @@ func newWorkspaceClient(ctx context.Context, httpClient internal.HttpClient) mes return meshWorkspaceClient{internal.NewMeshObjectClient[MeshWorkspace](ctx, httpClient, "v2")} } +func (c meshWorkspaceClient) List(ctx context.Context) ([]MeshWorkspace, error) { + return c.meshObject.List(ctx) +} + func (c meshWorkspaceClient) Read(ctx context.Context, name string) (*MeshWorkspace, error) { return c.meshObject.Get(ctx, name) } diff --git a/client/workspace_binding.go b/client/workspace_binding.go index 30d744ec..e21418d8 100644 --- a/client/workspace_binding.go +++ b/client/workspace_binding.go @@ -5,7 +5,7 @@ type MeshWorkspaceBinding struct { RoleRef MeshWorkspaceRoleRef `json:"roleRef" tfsdk:"role_ref"` TargetRef MeshWorkspaceTargetRef `json:"targetRef" tfsdk:"target_ref"` Subject MeshWorkspaceSubject `json:"subject" tfsdk:"subject"` - ExpiryDate *string `json:"expiryDate,omitempty" tfsdk:"expiry_date"` + ExpiryDate *string `json:"expiryDate,omitzero" tfsdk:"expiry_date"` } type MeshWorkspaceBindingMetadata struct { diff --git a/client/workspace_group_binding.go b/client/workspace_group_binding.go index 1ff9739e..cc56cd34 100644 --- a/client/workspace_group_binding.go +++ b/client/workspace_group_binding.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshWorkspaceGroupBinding struct { diff --git a/client/workspace_user_binding.go b/client/workspace_user_binding.go index 13d3ebb7..1b9cdd24 100644 --- a/client/workspace_user_binding.go +++ b/client/workspace_user_binding.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/terraform-provider-meshstack/client/internal" + "github.com/meshcloud/meshstack-cli/client/internal" ) type MeshWorkspaceUserBinding struct { From c89eb7ad0267a3b5f1eddd2f00b9c4bf26b57f4d Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 18 Sep 2026 21:27:51 +0200 Subject: [PATCH 208/215] feat: set up the Go module that builds the meshStack API client The repository becomes a Go module, with the toolchain pinned in go.mod and in the nix flake, a lint gate that also formats, a release pipeline for the archives and the container image, and the GitHub ruleset under terraform. The HTTP, JSON and version packages the client compiles against come along in this commit rather than a later one, so that the tree builds from here on. They sit outside `client/` because the login flow and the settings layer need them as well, and Go's internal rule would close `client/internal` to both. CU-86cb61rzz Co-Authored-By: Claude Opus 5 (1M context) --- .claude/settings.json | 15 + .dockerignore | 11 + .github/workflows/build-image.yml | 85 ++ .github/workflows/release.yml | 44 ++ .github/workflows/test-acceptance.yml | 87 ++ .github/workflows/test.yml | 101 +++ .gitignore | 34 + .golangci.yml | 366 +++++++++ .goreleaser.yml | 67 ++ AGENTS.md | 215 +++++ CLAUDE.md | 1 + Dockerfile | 30 + LICENSE | 201 +++++ README.md | 24 + Taskfile.yml | 56 ++ flake.lock | 25 + flake.nix | 102 +++ go.mod | 241 ++++++ go.sum | 1003 ++++++++++++++++++++++++ infra/github/main.tf | 83 ++ infra/github/terraform.tf | 19 + internal/http/auth.go | 69 ++ internal/http/http_client.go | 134 ++++ internal/http/http_client_test.go | 557 +++++++++++++ internal/http/http_error.go | 37 + internal/http/logging.go | 155 ++++ internal/http/logging_internal_test.go | 64 ++ internal/http/logging_test.go | 38 + internal/http/method.go | 12 + internal/http/options.go | 158 ++++ internal/http/retry.go | 238 ++++++ internal/http/retry_test.go | 64 ++ internal/json/marshal.go | 73 ++ internal/json/marshal_test.go | 26 + internal/json/unmarshal.go | 54 ++ internal/version/version.go | 97 +++ internal/version/version_test.go | 121 +++ meshstack-satellite.gradle | 14 + 38 files changed, 4721 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .dockerignore create mode 100644 .github/workflows/build-image.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test-acceptance.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yml create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Taskfile.yml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 infra/github/main.tf create mode 100644 infra/github/terraform.tf create mode 100644 internal/http/auth.go create mode 100644 internal/http/http_client.go create mode 100644 internal/http/http_client_test.go create mode 100644 internal/http/http_error.go create mode 100644 internal/http/logging.go create mode 100644 internal/http/logging_internal_test.go create mode 100644 internal/http/logging_test.go create mode 100644 internal/http/method.go create mode 100644 internal/http/options.go create mode 100644 internal/http/retry.go create mode 100644 internal/http/retry_test.go create mode 100644 internal/json/marshal.go create mode 100644 internal/json/marshal_test.go create mode 100644 internal/json/unmarshal.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go create mode 100644 meshstack-satellite.gradle diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..578ccc88 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path // empty' | { read -r f; [[ \"$f\" == *.go ]] && cd \"$CLAUDE_PROJECT_DIR\" && go tool golangci-lint fmt \"$f\"; } 2>/dev/null || true" + } + ] + } + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b1803292 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# The build only needs the Go sources, go.mod and go.sum. Everything below would +# otherwise be copied into the build context and invalidate its cache. +.git/ +.github/ +dist/ +.nix-go/ +meshstack +.env +.vscode/ +.idea/ +*.md diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 00000000..d32d3d51 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,85 @@ +# Modelled on meshcloud/building-block-runner's build-images.yml, minus the Docker Hub push. +name: Build Image + +env: + REGISTRY: ghcr.io + IMAGE_NAMESPACE: ${{ github.repository_owner }} + IMAGE_NAME: meshstack-cli + +on: + # Called by the release workflow, so a tagged release publishes the matching image. + workflow_call: + inputs: + version: + description: "Release version to tag the image with, e.g. v1.2.3" + required: true + type: string + # A push to main refreshes :main, so an image exists before the first release does. + push: + branches: + - main + # Builds the image but does not push it, so a broken Dockerfile fails review rather than main. + pull_request: + paths: + - 'Dockerfile' + - '.github/workflows/build-image.yml' + - 'go.mod' + - 'go.sum' + - '**/*.go' + +jobs: + build: + name: Build and push image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # Computed here rather than with docker/metadata-action, to keep the set of pinned + # actions small. + - name: Determine version and tags + id: meta + env: + # Read through the environment, because a tag name may hold shell metacharacters and + # ${{ }} would paste them into the script this job runs. + VERSION_INPUT: ${{ inputs.version }} + run: | + if [ -n "${VERSION_INPUT}" ]; then + version="${VERSION_INPUT}" + tags="${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:${version}" + tags="${tags},${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:latest" + elif [ "${{ github.ref }}" = "refs/heads/main" ]; then + version="main-$(git rev-parse --short HEAD)" + tags="${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:main" + tags="${tags},${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:${version}" + else + version="pr-${{ github.event.number }}" + tags="${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_NAME}:${version}" + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "tags=${tags}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..45e6918a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +# Releases the meshstack CLI when a tag matching "v*" is pushed. +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + goreleaser: + name: GoReleaser + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Let goreleaser read older tags, which it needs for the changelog. + fetch-depth: 0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: 'go.mod' + cache: true + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Publishes the image for the same tag. Separate job so a failing image build does + # not take the archives down with it. + image: + name: Image + needs: [ goreleaser ] + permissions: + contents: read + packages: write + uses: ./.github/workflows/build-image.yml + with: + version: ${{ github.ref_name }} diff --git a/.github/workflows/test-acceptance.yml b/.github/workflows/test-acceptance.yml new file mode 100644 index 00000000..22ee98ea --- /dev/null +++ b/.github/workflows/test-acceptance.yml @@ -0,0 +1,87 @@ +# The acceptance suite needs a whole meshStack backend, so it cannot run here. This workflow asks +# the private meshStack mono repo to run it and report the result back as a check run. +name: Acceptance Tests + +# The push trigger catches a CLI/backend regression on main, before a release tag. +on: + pull_request_target: + push: + branches: + - main + # TEMPORARY, delete before merge. A pull_request_target run takes this file from the base + # branch, where it does not exist yet, so a push is the only trigger that can try the + # dispatcher out on its own pull request. + - feature/scaffold-cli + +permissions: + contents: read + +jobs: + # No `name:`, unlike test.yml's jobs: nothing gates on this check. The gating check is + # "Acceptance Tests (meshStack backend)", which meshfed-release posts. + request: + runs-on: ubuntu-latest + env: + SATELLITE_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.ref || github.ref_name }} + + # The run has to be in our own repository, and on a pull request the head branch has to live + # here too — which means its author has write access, so the code under test is code we + # already trust. + DISPATCH: ${{ github.repository_owner == 'meshcloud' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) }} + + # This workflow MUST NOT check out the pull request, and has no `actions/checkout` for that + # reason. `pull_request_target` runs in the base repo's context with its secrets, so checking + # out contributor code here would be the classic "pwn request" hole. + steps: + - name: Explain a skipped fork pull request + if: env.DISPATCH != 'true' && github.event_name == 'pull_request_target' + env: + BASE_REPO: ${{ github.repository }} + run: echo "::notice::Acceptance tests are not dispatched for a fork pull request. A maintainer has to adopt the branch into $BASE_REPO before the suite can run against it." + + # Downscoped to `actions: write` at mint time, so a later widening of the app cannot leak + # into this workflow. + - name: Mint a token for the dispatch + id: token + if: env.DISPATCH == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + # The secret holds the numeric app id, not a client id: the action reads `client-id` or + # the deprecated `app-id` into one value, and GitHub takes either as the JWT issuer. The + # name stays because it is an organization secret every satellite reads. + client-id: ${{ secrets.SATELLITE_GH_APP_ID }} + private-key: ${{ secrets.SATELLITE_GH_APP_PRIVATE_KEY }} + owner: meshcloud + repositories: meshfed-release + permission-actions: write + + # meshfed-release pairs a satellite branch with a same-named branch of its own, so the + # dispatch names that branch rather than always `develop`: a `workflow_dispatch` reads the + # workflow file from the ref it is given, and `develop` would run the orchestration that is + # already merged. This token may dispatch workflows there and read nothing, so trying the + # dispatch is the only branch lookup available. + # + # `$SATELLITE_REF` reaches the script through the environment, never as a `${{ }}` expression + # GitHub substitutes into the script text: a branch named `$(id)` would otherwise run as a + # command. + - name: Request the acceptance run + if: env.DISPATCH == 'true' + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + set -euo pipefail + request() { + gh workflow run ci-satellite.yml \ + --repo meshcloud/meshfed-release \ + --ref "$1" \ + -f repo=meshstack-cli \ + -f branch_name="$SATELLITE_REF" + } + if request "$SATELLITE_REF"; then + orchestrated_from="$SATELLITE_REF" + else + echo "::notice::meshfed-release has no branch $SATELLITE_REF to dispatch, so develop orchestrates this run." + request develop + orchestrated_from=develop + fi + echo "::notice::Requested an acceptance run for $SATELLITE_REF, orchestrated from meshfed-release $orchestrated_from. The result arrives as the \"Acceptance Tests (meshStack backend)\" check." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..53cc86d9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,101 @@ +name: Tests + +# No `paths-ignore` on either trigger, deliberately: a workflow skipped that way never reports its +# checks, so a required check on it stays "expected" forever and blocks the merge. A skipped *job* +# reports success; a skipped *workflow* does not. +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Go Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: 'go.mod' + cache: true + - run: go mod tidy + - run: go build -v ./... + - name: git diff + run: | + git diff --compact-summary --exit-code || \ + (echo; echo "Unexpected difference in directories after 'go mod tidy'. Run 'go mod tidy' command and commit."; exit 1) + + golangci: + needs: [ build ] + name: Go Lint and Format Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: 'go.mod' + cache: true + # Built here rather than downloaded by the action below: golangci-lint's formatters use the + # go/format compiled into the binary, so the formatting they enforce comes from the Go release + # that built the linter. The tool directive in go.mod is the single pin. + - name: Build the pinned golangci-lint + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint + - name: golangci-lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + # The step above already installed it; the action is here for the annotations it puts on + # the pull request diff, which a bare `run:` does not produce. + install-mode: none + - name: Suggest fix command on failure + if: failure() + run: | + echo "::error::Linting or formatting issues detected. Run 'task lint -- --fix' locally to automatically fix these issues, then commit the changes." + + test: + name: Go Test + needs: [ build ] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: 'go.mod' + cache: true + # Binary coverage data (GOCOVERDIR format) rather than a text profile, because only the + # binary form merges with the acceptance run's coverage. `-coverpkg=./...` has to match what + # meshfed-release runs the acceptance suite with, or the merged figure is a ratio over two + # different denominators. + - name: Run unit tests with gotestsum + run: | + mkdir -p covdata/unit + go tool gotestsum --junitfile junit.xml --format testdox -- \ + -coverpkg=./... ./... -args -test.gocoverdir="$PWD/covdata/unit" + + # meshfed-release finds this by the pull request head sha, so it has to be uploaded from a + # `pull_request` job. The contract with that side is the artifact name `covdata-unit` and + # covmeta.*/covcounters.* at its root, which naming the directory gives and a glob would nest. + - name: Upload unit coverage data + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: covdata-unit + path: covdata/unit + retention-days: 3 + + - name: Report unit coverage + if: always() + run: | + if ! ls covdata/unit/covmeta.* >/dev/null 2>&1; then + echo "Unit coverage: no data produced." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + go tool covdata textfmt -i=covdata/unit -o=unit.txt + echo "Unit coverage: $(go tool cover -func=unit.txt | tail -1 | awk '{print $NF}')" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..bb139e1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Binary produced by 'task build' +/meshstack + +# Release artifacts produced by goreleaser +/dist/ + +# Go environment created by the Nix dev shell (flake.nix shellHook) +/.nix-go/ + +# Store symlink left behind by 'nix build' +/result +/result-* + +# Local meshStack credentials, read by the Taskfile's dotenv +.env + +# A go.work names other checkouts by path, so it describes one developer's or one CI runner's +# directory layout and never the repository. meshfed-release's go-satellite plugin writes one when +# it runs the acceptance suite, and the meshStack Terraform provider writes one to build against a +# paired branch of this repository. +go.work +go.work.sum + +# What the CI test command leaves behind when you reproduce it locally. +covdata/ +junit.xml +unit.txt + +# Editor and IDE directories +.vscode/ +.idea/ + +# Per-developer Claude Code settings; .claude/settings.json is shared and committed +.claude/settings.local.json diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..083f0efa --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,366 @@ +# Kept close to terraform-provider-meshstack's configuration, so that code moving between +# the two repositories does not trip a different linter set. +version: "2" +issues: + max-same-issues: 0 + +formatters: + enable: + - gci + - gofumpt + settings: + gci: + sections: + - standard # Go standard library + - default # All other external dependencies + - localmodule # This repository's modules + +linters: + # Every linter, minus the list below, so a rejection is a line here with a reason. + default: all + disable: + # Broken or superseded + - exhaustruct_v5 # panics on this codebase with exhaustruct v5.0.3 + - exhaustruct # deprecated, superseded by exhaustruct_v5 + - wsl # deprecated, superseded by wsl_v5 + - gomodguard # deprecated, superseded by gomodguard_v2 + + # Layout, which gofumpt and gci already settle + - wsl_v5 + - nlreturn + - tagalign + - lll + - decorder + - grouper + - goheader + + # Styles this repository chose on purpose + - nakedret # a named result returned bare documents itself at the signature + - nonamedreturns # so a named result stays + - noinlineerr # `if err := f(); err != nil` is the house style + - varnamelen # a short receiver or loop name is idiomatic + - godox # a TODO comment is a deliberate marker + - gochecknoglobals # a setting is declared once, as a package-level var + - gochecknoinits # credential checks its names against the fields of Credentials at init + - ireturn # a constructor returning an interface is the client's shape + - testpackage # an in-package test is a deliberate choice + - dupl # the binding clients are parallel by design + - tagliatelle # OIDC and OAuth field names are snake_case by spec + - paralleltest # t.Setenv forbids t.Parallel + + # Error plumbing we decide case by case + - wrapcheck + - err113 + + # Measurements, not goals + - funlen + - cyclop + - gocyclo + - gocognit + - maintidx + - nestif + - mnd + - goconst + - prealloc + - dogsled + - interfacebloat + + exclusions: + rules: + # A doc comment is required in pkg/, the surface a front end reads, and left to + # judgement elsewhere. + - linters: + - revive + text: "^exported:" + path-except: (^|/)pkg/ + - linters: + - revive + text: "^exported:" + path: _test\.go + # In test code the testing.T comes first, before a context. + - linters: + - revive + text: "^context-as-argument:" + path: (_test\.go|internal/testutil/) + # A test api key is a fixture, not a leaked secret. + - linters: + - gosec + text: G101 + path: _test\.go + - path: internal/http/ + linters: + - forbidigo + - path: internal/testutil/testserver/ + linters: + - forbidigo + # testacc plays the browser: it drives keycloak's HTML login forms with its own cookie + # jar, and it launches the binary it built itself. + - path: cmd/internal/testacc/ + linters: + - forbidigo + - gosec + + settings: + # The process has one HTTP client, built in internal/http. + forbidigo: + analyze-types: true + forbid: + - pattern: \.(Client|DefaultClient|Transport|DefaultTransport|Get|Head|Post|PostForm)$ + pkg: ^net/http$ + msg: the process has one HTTP client, built in internal/http; take it from there rather than making another + + gocritic: + disabled-checks: + # an if-else chain that ends in a return per branch reads as it is written here + - ifElseChain + + # The Context form, because the provider's handler reads terraform's logger out of the + # context and drops a record without one. + sloglint: + context: all + no-mixed-args: true + + importas: + alias: + - pkg: net/http + alias: gohttp + + govet: + enable-all: true + disable: + # field order follows what reads well, not what packs tightest + - fieldalignment + + staticcheck: + checks: + - all + - -ST1000 + # Replaces the default list, so that ClientId, BaseUrl and Uuid stand as the meshObject + # API spells them. ST1003 still enforces the rest. + initialisms: [] + + godoclint: + default: basic + + exhaustive: + default-signifies-exhaustive: true + + nolintlint: + require-explanation: true + require-specific: true + + revive: + # No default rule set: package-comments wants a comment on every package, and var-naming + # wants the API's own field names spelled differently. exported is scoped under exclusions. + rules: + - name: context-as-argument + - name: context-keys-type + - name: datarace + - name: defer + - name: early-return + - name: error-return + - name: error-strings + - name: errorf + - name: exported + - name: identical-branches + - name: modifies-value-receiver + - name: range-val-address + - name: receiver-naming + - name: redundant-import-alias + - name: string-of-int + - name: superfluous-else + - name: time-naming + - name: unconditional-recursion + - name: unused-parameter + - name: unexported-return + - name: unnecessary-stmt + - name: use-any + - name: waitgroup-by-value + + # These rules are the dependency policy, so widening one is a deliberate edit rather than + # a lint fix. + depguard: + rules: + + # cobra is used in cmd/ and nowhere else. + cmd: + files: + - "**/cmd/*.go" + - "**/cmd/**/*.go" + - "!**/cmd/meshstack/*.go" + - "!**/cmd/internal/*.go" + - "!$test" + list-mode: strict + deny: + - pkg: log + desc: Use log/slog instead + - pkg: encoding/json + desc: Use encoding/json/v2 and internal/json instead + allow: + - $gostd + - log/slog + # no direct access to internal/ + - github.com/meshcloud/meshstack-cli/pkg + - github.com/meshcloud/meshstack-cli/cmd/internal + - github.com/meshcloud/meshstack-cli/client + - github.com/spf13/cobra + + # The only package that may install a log handler. + cmd-meshstack: + files: + - "**/cmd/meshstack/*.go" + - "!$test" + list-mode: strict + deny: + - pkg: log + desc: Use log/slog instead + - pkg: encoding/json + desc: Use encoding/json/v2 and internal/json instead + allow: + - $gostd + - log/slog + - github.com/meshcloud/meshstack-cli/cmd + - github.com/meshcloud/meshstack-cli/pkg/io + - github.com/spf13/cobra + - github.com/charmbracelet/log + + cmd-internal: + files: + - "**/cmd/internal/*.go" + - "!$test" + list-mode: strict + deny: + - pkg: log + desc: Use log/slog instead + - pkg: encoding/json + desc: Use encoding/json/v2 and internal/json instead + allow: + - $gostd + - log/slog + - github.com/meshcloud/meshstack-cli/pkg + - github.com/meshcloud/meshstack-cli/client + - github.com/spf13/cobra + - github.com/spf13/pflag + + # The package the Terraform provider consumes most directly, so it reaches nothing + # beyond the standard library and the three packages below. + client: + files: + # Both patterns are needed: '**/dir/**/*.go' only matches files in + # subdirectories of dir, never files directly inside it. + - "**/client/*.go" + - "**/client/**/*.go" + - "!$test" + list-mode: strict + deny: + - pkg: log + desc: Use log/slog instead + - pkg: encoding/json + desc: Use encoding/json/v2 and internal/json instead + allow: + - $gostd + - encoding/json/v2 + - encoding/json/jsontext + - log/slog + - github.com/meshcloud/meshstack-cli/client + - github.com/meshcloud/meshstack-cli/internal/http + - github.com/meshcloud/meshstack-cli/internal/json + - github.com/meshcloud/meshstack-cli/internal/version + + pkg: + files: + - "**/pkg/**/*.go" + - "!$test" + list-mode: strict + deny: + - pkg: log + desc: Use log/slog instead + - pkg: encoding/json + desc: Use encoding/json/v2 and internal/json instead + allow: + - $gostd + - log/slog + - github.com/meshcloud/meshstack-cli/client + - github.com/meshcloud/meshstack-cli/pkg + - github.com/meshcloud/meshstack-cli/internal + + # client reaches these two, so whatever they may import, client gets. + internal-http: + files: + - "**/internal/http/*.go" + - "**/internal/json/*.go" + - "!$test" + list-mode: strict + deny: + - pkg: encoding/json + desc: Use encoding/json/v2 and internal/json instead + allow: + - $gostd + - encoding/json/v2 + - encoding/json/jsontext + - github.com/meshcloud/meshstack-cli/internal/json + + internal-version: + files: + - "**/internal/version/*.go" + - "!$test" + list-mode: strict + deny: + - pkg: encoding/json + desc: Marshal a Version through its MarshalText, and keep JSON out of here + allow: + - $gostd + + internal: + files: + - "**/internal/**/*.go" + - "!$test" + - "!**/internal/testutil/**/*.go" + list-mode: strict + deny: + - pkg: log + desc: Use log/slog instead + - pkg: encoding/json + desc: Use encoding/json/v2 and internal/json instead + allow: + - $gostd + - encoding/json/v2 + - encoding/json/jsontext + - log/slog + - github.com/meshcloud/meshstack-cli/internal + # internal/auth builds a client, and the types a profile stores are client/types ones + - github.com/meshcloud/meshstack-cli/client + # Keep this list of external libraries as small as possible. Never add cobra. + - github.com/gofrs/flock + + internal-test: + files: + - "**/internal/testutil/**/*.go" + list-mode: strict + deny: + - pkg: encoding/json + desc: Use encoding/json/v2 and internal/json instead + allow: + - $gostd + - encoding/json/v2 + - encoding/json/jsontext + - github.com/meshcloud/meshstack-cli + - github.com/stretchr/testify + + # A test that pins a wire format with encoding/json v1 pins a format this module does + # not produce, so the deny holds here too. + tests: + files: + - "$test" + list-mode: strict + deny: + - pkg: encoding/json + desc: Use encoding/json/v2 and internal/json instead + allow: + - $gostd + - encoding/json/v2 + - encoding/json/jsontext + - github.com/meshcloud/meshstack-cli + - github.com/spf13/cobra + - github.com/stretchr/testify + - github.com/gofrs/flock diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 00000000..ce744131 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,67 @@ +version: 2 + +# Everything published carries the repository name, while the binary inside it is +# meshstack, which is why the build below names the binary explicitly. +project_name: meshstack-cli + +before: + hooks: + - go mod tidy + +builds: + - main: ./cmd/meshstack + binary: meshstack + env: + # A statically linked binary runs in the distroless image and on any glibc version. + - CGO_ENABLED=0 + mod_timestamp: '{{ .CommitTimestamp }}' + flags: + - -trimpath + # The Dockerfile and flake.nix set the same ldflag, and all three have to agree. The + # linker ignores an -X whose path does not resolve and warns about nothing, so a stale + # path here is silent. + ldflags: + # .Tag, not .Version: the latter strips the leading v, and every version this binary + # reports carries it. + - '-s -w -X github.com/meshcloud/meshstack-cli/cmd/internal.Version={{ .Tag }}' + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + +archives: + - formats: + - tar.gz + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' + format_overrides: + - goos: windows + formats: + - zip + +checksum: + name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' + algorithm: sha256 + +changelog: + use: github + sort: asc + groups: + - title: Features + regexp: '^feat(\(.+\))?!?:' + order: 0 + - title: Fixes + regexp: '^fix(\(.+\))?!?:' + order: 1 + - title: Others + order: 99 + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..72f118b1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,215 @@ +# AGENTS.md — meshStack CLI + + +You are an expert Go engineer working on the meshStack CLI: the `meshstack` binary, and the Go +client for the meshStack API that the +[meshStack Terraform provider](https://github.com/meshcloud/terraform-provider-meshstack) imports as +a library. This file is the always-on source of truth for both AI agents and humans. + + +> **This repository is public.** Write everything here so an external contributor with no meshcloud +> access can follow it. Tag meshcloud-internal shortcuts clearly as internal, and never let +> understanding a rule *depend* on them. + +A relative path like `../meshfed-release` refers to a **sibling checkout**: meshcloud developers +clone the `meshcloud` org flat, so every repository in it is a sibling of this one. Write cross-repo +paths that way rather than bare, so they resolve as written. + + +**This file is loaded into every session, so keep it short.** A rule earns a place here only if it +has no closer home. Everything else belongs next to what it governs: + +| Belongs in | Rather than here | +|---|---| +| `.golangci.yml` | Which dependency may reach which package | +| `Taskfile.yml` | What a command does | +| A doc comment on the code | Why a package, type or command is built the way it is | +| The file that holds the setting | Why the setting has that value: `flake.nix`, `go.mod`, `.goreleaser.yml`, `Dockerfile` | +| A skill | A procedure long enough to need its own steps, loaded only when the work starts | + +Restating a rule in two places is worse than leaving it in one: the copies drift, and neither one +looks stale. + + +## Naming + +- **`meshstack`** — the binary, so every invocation reads `meshstack auth login`. +- **meshStack CLI** — the product name, used in prose and docs. +- `github.com/meshcloud/meshstack-cli` — the repository and Go module. + +Everything published carries the repository name — the release archives, the checksum file and the +container image are all `meshstack-cli` — while the binary inside them is `meshstack`. + +The binary gets its name from its directory, `cmd/meshstack`, which is what `task build` relies on. +**Do not add a `main.go` at the repository root**; that would name the binary after the module. + +## Package layout + +| Path | Holds | +|---|---| +| `cmd/meshstack/` | `package main`: `main()` and the root command. The only main package. | +| `cmd//` | One package per subcommand of the cobra command tree. `cmd/auth` is the only one so far. | +| `cmd/internal/` | What the command tree shares: flags, the session it resolves, the version. | +| `cmd/internal/testacc/` | The suite that drives the built binary against a live meshStack. | +| `pkg/` | `auth`, `io`, `profile` and `setting`, each wrapping the `internal/` package of the same name. | +| `client/` | The meshStack API client, imported as a git subtree. | +| `internal/` | Everything else. The `depguard` rules in `.golangci.yml` say which package may import which. | + +`pkg/` and `client/` are the two import paths the Terraform provider's own `depguard` rule allows, +so a rename or a signature change in either breaks it. Go's internal rule closes `internal/` to the +provider, which is what makes the indirection through `pkg/` worth its cost. + + +To add a command, put it in `cmd/`, where **the package name is the subcommand and the file name is +the leaf command**: `cmd/auth/login.go` holds `meshstack auth login`. The package exports a `New` +function returning its `*cobra.Command`, and the parent's constructor wires it in with `AddCommand`. + +`cmd/meshstack` is the one exception, and is not a subcommand: it is the binary's `package main`, +holding `main()` and the root command together. + +Four rules hold the tree together: + +- Register a command **explicitly in its parent's constructor, never from `init()`**. +- A command with a **top-level shortcut** — `meshstack login` for `meshstack auth login` — is + registered twice by calling its constructor twice. `Aliases` cannot do this. +- A constructor keeps its own flag targets in **locals captured by the closure**. The four + persistent flags in `cmd/internal` are the exception: `SettingSources` reads their values back, + so they are package-level vars. +- A **parent command sets `RunE` as well as `Args`**. + + +## Dependency policy + +The CLI runs on **four external dependencies**: `cobra` and `pflag`, `charmbracelet/log`, and +`gofrs/flock`. Everything else is the standard library, with `testify` in tests. + +**The `depguard` rules in `.golangci.yml` are the policy**, not only its enforcement: each rule +confines a dependency to a smaller area than the module, so widening a boundary is a deliberate edit +rather than a lint fix. + + +`client/` is a **git subtree** of +[terraform-provider-meshstack](https://github.com/meshcloud/terraform-provider-meshstack). Carry +changes across with `git subtree`, not by copying files. + +**A pull takes a split, not a branch.** The subtree's history carries the files at the *repository +root*, while in the provider the same files sit under `client/`, so pulling the provider's `main` +directly fails with *"refusing to merge unrelated histories"*. Split first, in a checkout of the +provider: + +```shell +cd ../terraform-provider-meshstack +git subtree split --prefix=client -b client-split main + +cd ../meshstack-cli +git subtree pull --prefix=client ../terraform-provider-meshstack client-split +git subtree push --prefix=client ../terraform-provider-meshstack +``` + +Reading the pre-import history takes both paths, since the split history carries the files at the +repository root and the import merge re-roots them under `client/`: + +```shell +git log -- client/client.go client.go # a path-limited log from client/ alone stops at the merge +git blame client/client.go # traverses the merge on its own +``` + +**`client/` does not log in.** `client.Authorization` produces a bearer token and replaces one that +came back 401; resolving a credential, minting a token, caching it and refreshing it is `pkg/auth`. +Both front ends build their client through `auth.Session.Client`, so the endpoint and the +authorization always agree with what was resolved. Do **not** add a login exchange anywhere else: a +second one gets a static token and starts returning 401 once it expires, and for a browser login it +would end the user's session. + +**`client/` does not own HTTP.** The client, the request options and the retry policy are +`internal/http`, one directory above, because `internal/oidc` and `pkg/auth` need them and Go's +internal rule closes `client/internal` to both. Its names carry no `Http` prefix — the package is +what says that — so it reads `http.Client`, `http.Error`, `http.NewClient`. + +**`net/http` is always imported as `gohttp`**, which `importas` in `.golangci.yml` settles. That +leaves the plain name to `internal/http`, the package a meshStack call goes through, and `net/http` +to the status and method constants and to the loopback server. The `forbidigo` rule matches on the +type rather than on the written name, so it catches `gohttp.Client` and leaves `http.Client` alone. + +**Logging goes through `slog`'s default logger**, on which each front end installs its own handler: +`cmd/meshstack` a `charmbracelet/log` one, the Terraform provider a `tflog` bridge. A handler +installed that late imposes two rules on every log call, and `internal/http/logging.go` states them. + + +## Always-on rules + + + +- **Self-explanatory code.** Move the fact into a name, a type, a check or a test: the compiler and + CI keep those true, while a comment goes stale in silence. A comment stays only when you could not + have written it by reading the code, and only when it changes what the reader does — the reason + for a decision, a rejected alternative, an external constraint with its source, a link to code + this must stay in step with. (*meshcloud-internal*: the `self-explanatory-code` skill of + `../meshfed-release`.) +- **Lint and format only via `task lint`**, and **never run `gofmt` or `go vet` separately** — a + differently built gofmt enforces different formatting. A `PostToolUse` hook in + `.claude/settings.json` formats every `.go` file an agent writes, so it rarely reaches the gate. +- **Conventional Commits** for messages (`feat:`, `fix:`, `docs:`, `chore:`, `feat!:` for breaking). +- **Stress-test a plan before writing code.** For any non-trivial change, walk each branch of the + decision tree and settle every open question with a recommended answer first. (*meshcloud-internal*: + the `grill-me` skill of `../meshfed-release`.) + + + +## Commands + +Everything runs through the Taskfile, inside `nix develop`. **`task --list` is the list.** + +The Go version is pinned in **three** places that must agree — `go.mod`, `flake.nix` and the +`Dockerfile`'s base image, each of which says so at the pin — and is held in lock-step with the +Terraform provider's own pin. + +`flake.nix` also builds the binary — `nix build .#meshstack` — and exports it as +`packages..meshstack` and as `overlays.default`, so another flake can put it in a dev shell. + +## Acceptance tests + +This repository is a **meshStack satellite**: `cmd/internal/testacc/` drives the built binary +against a live backend, and a whole meshStack only exists in the *meshcloud-internal* mono repo, so +that repository runs the suite and nothing here does. +`.github/workflows/test-acceptance.yml` asks for the run, and `meshstack-satellite.gradle` is +everything the run reads from here. The other half of the lane belongs to `../meshfed-release` and +changes without us, so read it there, in `satellite-suites.md` of the `acceptance-testing` skill, +rather than trusting a copy here. + +## Authentication + +`MESHSTACK_ENDPOINT`, `MESHSTACK_API_KEY` and `MESHSTACK_API_SECRET`, with `MESHSTACK_API_TOKEN` as +an alternative to the key and secret pair, plus `MESHSTACK_PROFILE`, `MESHSTACK_WORKSPACE`, +`MESHSTACK_CONFIG_DIR` and `MESHSTACK_SKIP_VERSION_CHECK`. + +**Each one is declared once, in the domain package it belongs to**, as a `setting.Setting[T]` whose +`EnvKey` is both the variable name and the setting's identity. `internal/setting` resolves it from +the sources it is given, and each front end contributes exactly one source over its own flags or +block attributes. **No front end assembles a sentence out of an imported name**: every message that +has to mention a variable is produced in the package that owns the declaration. The Taskfile reads a +git-ignored `.env` for local runs. + +## Releasing + +Pushing a `v*` tag runs goreleaser, which publishes the archives and checksums, and then builds the +container image for the same tag. The image goes to GHCR only, as +`ghcr.io/meshcloud/meshstack-cli`, and its entrypoint is the `meshstack` binary, so the image takes +the same arguments a local `meshstack` does. A push to `main` refreshes `:main`, so an image exists +before the first release does. + + +The version reaches the binary through an ldflag on +`github.com/meshcloud/meshstack-cli/cmd/internal.Version`, set in **three places that must agree**: +`.goreleaser.yml`, the `Dockerfile` and `flake.nix`, all of which say so at the ldflag. The linker +ignores an `-X` whose path does not resolve and warns about nothing, so a stale path is silent. + +A build with no ldflag falls back to what the go command stamped itself, which `cmd/internal` reads +from `debug.ReadBuildInfo`: the module version for `go install @`, and since Go 1.24 +a pseudo-version derived from the commit for a build inside a git checkout. Only a source tree with +no VCS information, such as an extracted archive, reports `dev`. Check with `meshstack --version` +after `task release:snapshot`. + + +Pin every GitHub Action by commit SHA with the version in a trailing comment, as the existing +workflows do. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..659a65b0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Runs on the build platform and cross-compiles for TARGETOS/TARGETARCH, so a +# multi-platform build needs no emulation. buildx sets those two args itself. +# +# go 1.27 (pinned, in lock-step with go.mod and flake.nix). +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build + +WORKDIR /src + +# Copied on their own so the module download layer survives any source change. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev +# .goreleaser.yml and flake.nix set the same ldflag, and all three have to agree. The +# linker ignores an -X whose path does not resolve and warns about nothing, so a stale +# path here is silent. +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ + -trimpath \ + -ldflags "-s -w -X github.com/meshcloud/meshstack-cli/cmd/internal.Version=${VERSION}" \ + -o /out/meshstack ./cmd/meshstack + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=build /out/meshstack /usr/local/bin/meshstack + +ENTRYPOINT ["/usr/local/bin/meshstack"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..0f900363 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 meshcloud GmbH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 00000000..4899f8bd --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# meshStack CLI + +`meshstack` is the command line interface for [meshStack](https://www.meshcloud.io/). This +repository also holds the Go client for the meshStack API, which the +[meshStack Terraform provider](https://github.com/meshcloud/terraform-provider-meshstack) imports. + +## Install + +```shell +go install github.com/meshcloud/meshstack-cli/cmd/meshstack@latest +``` + +## Development + +The Nix dev shell provides Go, `goreleaser` and `task`. `task lint` builds `golangci-lint` from +the tool directive in `go.mod`, so the dev shell deliberately does not carry it: + +```shell +nix develop +task build # writes ./meshstack +task test +task lint # add -- --fix to apply the fixes it can make itself +task release:snapshot # the release artifacts, into dist/, without publishing them +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..3141bf19 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,56 @@ +version: '3' + +# Git-ignored, and holds the MESHSTACK_* credentials that AGENTS.md lists. +dotenv: ['.env'] + +tasks: + build: + desc: Build the meshstack binary + cmds: + - go build {{.CLI_ARGS}} ./cmd/meshstack + + install: + desc: Install the meshstack binary into GOBIN + cmds: + - go install {{.CLI_ARGS}} ./cmd/meshstack + + test: + desc: Run unit tests + cmds: + - go test ./... {{.CLI_ARGS}} + + lint: + # Also formats, so there is no separate fmt task: golangci-lint's formatters use the + # go/format compiled into it, and a gofmt built against another Go release enforces + # different formatting. `go tool` builds it from the pin in go.mod, against the same Go. + desc: Run golangci-lint + cmds: + - go tool golangci-lint run {{.CLI_ARGS}} + + tidy: + desc: Tidy go.mod and go.sum + cmds: + - go mod tidy + + release:check: + desc: Validate .goreleaser.yml + cmds: + - goreleaser check + + release:snapshot: + # Check `dist/*/meshstack --version` afterwards: a build that missed the ldflag + # reports `dev` rather than failing. + desc: Build the release artifacts into dist/ without publishing them + cmds: + - goreleaser release --snapshot --clean {{.CLI_ARGS}} + + image: + desc: Build the container image locally + cmds: + - docker build -t meshstack:dev {{.CLI_ARGS}} . + + clean: + desc: Remove build artifacts + cmds: + - rm -f meshstack + - rm -rf dist diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..07ecf399 --- /dev/null +++ b/flake.lock @@ -0,0 +1,25 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1787736819, + "narHash": "sha256-IkjmqLoWzeqBAi1VIkdhDLMGjQDJ4suEDp59Zwxpswg=", + "rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1062397.9fbb54b33e91/nixexprs.tar.xz" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-unstable", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..da4caffb --- /dev/null +++ b/flake.nix @@ -0,0 +1,102 @@ +{ + description = "meshStack CLI"; + + inputs = { + nixpkgs.url = "nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f { + pkgs = import nixpkgs { inherit system; }; + }); + + # A flake input carries no tag, only a revision, so a nix build reports the commit it + # was built from. A nix derivation version carries no leading v, so reportedVersion + # adds one as a Go pseudo-version. + version = self.shortRev or self.dirtyShortRev or "dev"; + reportedVersion = if version == "dev" then version else "v0.0.0-${version}"; + + # Takes pkgs so overlays.default can build it from the *consumer's* nixpkgs, while + # packages. below builds it from this flake's locked one. + # + # go 1.27 (pinned, in lock-step with go.mod and with terraform-provider-meshstack). + # The override is what carries the pin: buildGoModule ignores a `go` attribute in the + # argument set and builds against nixpkgs' default Go instead. + meshstackPackage = pkgs: (pkgs.buildGoModule.override { go = pkgs.go_1_27; }) { + pname = "meshstack"; + inherit version; + src = self; + + # No subPackages, so that doCheck below runs the whole suite rather than one + # directory's tests. + + vendorHash = "sha256-vvO0VufdztbH0PCXGwJ1yEfB4Xo1Ot/b5JkrVe0YTE0="; + + # .goreleaser.yml and the Dockerfile set the same ldflag, and all three have to + # agree. The linker ignores an -X whose path does not resolve and warns about + # nothing, so a stale path here is silent. + ldflags = [ "-s" "-w" "-X github.com/meshcloud/meshstack-cli/cmd/internal.Version=${reportedVersion}" ]; + + # Every test points itself at a temp dir for $HOME and for its config, so the + # suite passes in the nix sandbox. Should a test ever need a real $HOME, turn + # this off rather than teaching the sandbox to provide one. + doCheck = true; + + meta = { + description = "Command line interface for meshStack"; + homepage = "https://github.com/meshcloud/meshstack-cli"; + license = nixpkgs.lib.licenses.asl20; + mainProgram = "meshstack"; + }; + }; + in + { + # Two lines make the binary available to another flake — this is how the meshStack + # Terraform provider's dev shell gets it: + # + # inputs.meshstack-cli.url = "github:meshcloud/meshstack-cli"; + # # then, in a devShell: packages = [ meshstack-cli.packages.${system}.meshstack ]; + packages = forEachSupportedSystem ({ pkgs }: rec { + meshstack = meshstackPackage pkgs; + default = meshstack; + }); + + # The alternative to the lines above: a consumer that adds this overlay to its own + # nixpkgs writes `meshstack` in a `with pkgs; [ … ]` list like any other package. + overlays.default = final: _prev: { + meshstack = meshstackPackage final; + }; + + devShells = forEachSupportedSystem ({ pkgs }: { + default = pkgs.mkShell { + packages = with pkgs; [ + # go 1.27 (pinned, in lock-step with go.mod and with terraform-provider-meshstack) + go_1_27 + + gotools + + # No golangci-lint here: it is a tool directive in go.mod, so `task lint` builds it + # with the pinned Go rather than taking whatever nixpkgs built it with. + + go-task + goreleaser + ]; + + shellHook = '' + export GOROOT="${pkgs.go_1_27}/share/go" + + # Keep the Go caches out of the developer's home directory. + export GOPATH="$PWD/.nix-go" + export GOCACHE="$PWD/.nix-go/cache" + export GOMODCACHE="$PWD/.nix-go/mod" + export GOBIN="$PWD/.nix-go/bin" + export PATH="$GOBIN:$PATH" + + mkdir -p "$GOPATH" "$GOCACHE" "$GOMODCACHE" "$GOBIN" + ''; + }; + }); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..aed174c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,241 @@ +module github.com/meshcloud/meshstack-cli + +// 1.27 is the floor because internal/http declares generic methods, which no earlier release +// compiles. Keep flake.nix's pinned Go (go_1_27 + GOROOT) in lock-step when bumping. +go 1.27 + +// gotestsum is not a convenience: meshfed-release's go-satellite build plugin runs +// `go tool gotestsum` in this directory to drive the acceptance suite. +tool ( + github.com/golangci/golangci-lint/v2/cmd/golangci-lint + gotest.tools/gotestsum +) + +require ( + github.com/charmbracelet/log v1.0.0 + github.com/gofrs/flock v0.13.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.12.1 +) + +require ( + 4d63.com/gocheckcompilerdirectives v1.4.0 // indirect + 4d63.com/gochecknoglobals v0.2.2 // indirect + charm.land/lipgloss/v2 v2.0.6 // indirect + codeberg.org/chavacava/garif v0.2.0 // indirect + codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect + dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect + dev.gaijin.team/go/exhaustruct/v5 v5.0.3 // indirect + dev.gaijin.team/go/golib v0.8.1 // indirect + github.com/4meepo/tagalign v1.4.3 // indirect + github.com/Abirdcfly/dupword v0.1.8 // indirect + github.com/AdminBenni/iota-mixing v1.0.0 // indirect + github.com/AlwxSin/noinlineerr v1.0.6 // indirect + github.com/Antonboom/errname v1.1.2 // indirect + github.com/Antonboom/nilnil v1.1.2 // indirect + github.com/Antonboom/testifylint v1.6.4 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect + github.com/Djarvur/go-err113 v0.1.1 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/MirrexOne/unqueryvet v1.5.4 // indirect + github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect + github.com/alecthomas/go-check-sumtype v0.3.1 // indirect + github.com/alexkohler/nakedret/v2 v2.0.6 // indirect + github.com/alexkohler/prealloc v1.1.0 // indirect + github.com/alfatraining/structtag v1.0.0 // indirect + github.com/alingse/asasalint v0.0.11 // indirect + github.com/alingse/nilnesserr v0.2.0 // indirect + github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect + github.com/ashanbrown/makezero/v2 v2.2.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bitfield/gotestdox v0.2.2 // indirect + github.com/bkielbasa/cyclop v1.2.3 // indirect + github.com/blizzy78/varnamelen v0.8.0 // indirect + github.com/bombsimon/wsl/v4 v4.7.0 // indirect + github.com/bombsimon/wsl/v5 v5.9.0 // indirect + github.com/breml/bidichk v0.3.3 // indirect + github.com/breml/errchkjson v0.4.1 // indirect + github.com/butuzov/ireturn v0.4.1 // indirect + github.com/butuzov/mirror v1.3.3 // indirect + github.com/catenacyber/perfsprint v0.10.1 // indirect + github.com/ccojocar/zxcvbn-go v1.0.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charithe/durationcheck v0.0.11 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/ckaznocha/intrange v0.3.1 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/curioswitch/go-reassign v0.3.0 // indirect + github.com/daixiang0/gci v0.13.7 // indirect + github.com/dave/dst v0.27.3 // indirect + github.com/denis-tingaikin/go-header v0.5.0 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect + github.com/dnephin/pflag v1.0.7 // indirect + github.com/ettle/strcase v0.2.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/firefart/nonamedreturns v1.0.8 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fzipp/gocyclo v0.6.0 // indirect + github.com/ghostiam/protogetter v0.3.21 // indirect + github.com/go-critic/go-critic v0.14.4 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/go-toolsmith/astcast v1.1.0 // indirect + github.com/go-toolsmith/astcopy v1.1.0 // indirect + github.com/go-toolsmith/astequal v1.2.0 // indirect + github.com/go-toolsmith/astfmt v1.1.0 // indirect + github.com/go-toolsmith/astp v1.1.0 // indirect + github.com/go-toolsmith/strparse v1.1.0 // indirect + github.com/go-toolsmith/typep v1.1.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/godoc-lint/godoc-lint v0.11.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/golangci/asciicheck v0.5.0 // indirect + github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect + github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect + github.com/golangci/go-printf-func-name v0.1.1 // indirect + github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect + github.com/golangci/golangci-lint/v2 v2.13.2 // indirect + github.com/golangci/golines v0.15.0 // indirect + github.com/golangci/misspell v0.8.0 // indirect + github.com/golangci/plugin-module-register v0.1.2 // indirect + github.com/golangci/revgrep v0.8.0 // indirect + github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect + github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect + github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/gordonklaus/ineffassign v0.2.0 // indirect + github.com/gostaticanalysis/analysisutil v0.7.1 // indirect + github.com/gostaticanalysis/comment v1.5.0 // indirect + github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect + github.com/gostaticanalysis/nilerr v0.1.2 // indirect + github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jgautheron/goconst v1.11.0 // indirect + github.com/jjti/go-spancheck v0.6.5 // indirect + github.com/julz/importas v0.2.0 // indirect + github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect + github.com/kisielk/errcheck v1.20.0 // indirect + github.com/kkHAIKE/contextcheck v1.1.6 // indirect + github.com/kulti/thelper v0.7.1 // indirect + github.com/kunwardeep/paralleltest v1.0.15 // indirect + github.com/ldez/exptostd v0.4.5 // indirect + github.com/ldez/gomoddirectives v0.9.0 // indirect + github.com/ldez/grignotin v0.10.1 // indirect + github.com/ldez/structtags v0.6.1 // indirect + github.com/ldez/tagliatelle v0.7.2 // indirect + github.com/ldez/usetesting v0.5.0 // indirect + github.com/leonklingele/grouper v1.1.2 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/macabu/inamedparam v0.2.0 // indirect + github.com/magiconair/properties v1.8.6 // indirect + github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect + github.com/manuelarte/funcorder v0.6.0 // indirect + github.com/maratori/testableexamples v1.0.1 // indirect + github.com/maratori/testpackage v1.1.2 // indirect + github.com/matoous/godox v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/mgechev/revive v1.15.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moricho/tparallel v0.3.2 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/nakabonne/nestif v0.3.1 // indirect + github.com/nishanths/exhaustive v0.12.0 // indirect + github.com/nishanths/predeclared v0.2.2 // indirect + github.com/nunnatsa/ginkgolinter v0.24.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/prometheus/client_golang v1.12.1 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.32.1 // indirect + github.com/prometheus/procfs v0.7.3 // indirect + github.com/quasilyte/go-ruleguard v0.4.5 // indirect + github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect + github.com/quasilyte/gogrep v0.5.0 // indirect + github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect + github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect + github.com/raeperd/recvcheck v0.3.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect + github.com/ryancurrah/gomodguard v1.4.1 // indirect + github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect + github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect + github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + github.com/sashamelentyev/interfacebloat v1.1.0 // indirect + github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect + github.com/securego/gosec/v2 v2.28.0 // indirect + github.com/sirupsen/logrus v1.10.1 // indirect + github.com/sivchari/containedctx v1.0.3 // indirect + github.com/sonatard/noctx v0.5.1 // indirect + github.com/sourcegraph/go-diff v0.8.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/viper v1.12.0 // indirect + github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect + github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/subosito/gotenv v1.4.1 // indirect + github.com/tetafro/godot v1.5.6 // indirect + github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect + github.com/timonwong/loggercheck v0.11.0 // indirect + github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect + github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect + github.com/ultraware/funlen v0.2.0 // indirect + github.com/ultraware/whitespace v0.2.0 // indirect + github.com/uudashr/gocognit v1.2.1 // indirect + github.com/uudashr/iface v1.5.1 // indirect + github.com/xen0n/gosmopolitan v1.3.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yagipy/maintidx v1.0.0 // indirect + github.com/yeya24/promlinter v0.3.0 // indirect + github.com/ykadowak/zerologlint v0.1.5 // indirect + gitlab.com/bosi/decorder v0.4.2 // indirect + go-simpler.org/musttag v0.14.0 // indirect + go-simpler.org/sloglint v0.12.0 // indirect + go.augendre.info/arangolint v0.4.0 // indirect + go.augendre.info/fatcontext v0.10.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f // indirect + golang.org/x/mod v0.40.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.49.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/gotestsum v1.13.0 // indirect + honnef.co/go/tools v0.8.1 // indirect + mvdan.cc/gofumpt v0.11.0 // indirect + mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..264aa488 --- /dev/null +++ b/go.sum @@ -0,0 +1,1003 @@ +4d63.com/gocheckcompilerdirectives v1.4.0 h1:ZLq62rbGWVmQhiZ8kuNVIT/M09xCSTdJz9K3xOdT/CY= +4d63.com/gocheckcompilerdirectives v1.4.0/go.mod h1:9ZOAiMOjqC/nRwci2fcUXVHUNLG/cH6r6rhUh+jTFtQ= +4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= +4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= +codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= +codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= +codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= +dev.gaijin.team/go/exhaustruct/v5 v5.0.3 h1:yOeA7DNjlT8y4yfmN6nWWYYggA13N523YAj9/TXbuTM= +dev.gaijin.team/go/exhaustruct/v5 v5.0.3/go.mod h1:KwtBsX8nHHH1YxhxkpiBq6bfsmw5WnazWpNvJPHgY9Y= +dev.gaijin.team/go/golib v0.8.1 h1:JYju4x9BSo+QD/AYeHULVDcvEhiFg8wOi6pT0IaZF5E= +dev.gaijin.team/go/golib v0.8.1/go.mod h1:c5fu7t1RSGMxSQgcUYO1sODbzsYnOCXJLmHeNG1Eb+0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= +github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= +github.com/Abirdcfly/dupword v0.1.8 h1:SrhcUuGsROBuChFxHALRYzyyPODWn9zwghmzPvD9Cd8= +github.com/Abirdcfly/dupword v0.1.8/go.mod h1:XZrhVnI7YGpsTiWZANSQaBJ4QpL/Tq5vIEdKJJAs9WI= +github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo= +github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= +github.com/AlwxSin/noinlineerr v1.0.6 h1:KAvuxunTe9QxvqrFB7nZTdb/7Wzas4AvifslTnG0Ld8= +github.com/AlwxSin/noinlineerr v1.0.6/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= +github.com/Antonboom/errname v1.1.2 h1:dxwONZJua3VB8Xh/VaCjqAcqF645sWWv7xj26zy7tdQ= +github.com/Antonboom/errname v1.1.2/go.mod h1:YeZIpgLMxT+SNkruGgYkLhzq/9vs3fsolTZegKaKDZI= +github.com/Antonboom/nilnil v1.1.2 h1:aNlFuJhaEseXe4fHO3xbjXlSeEiQVYa2lEkWD2s2hAY= +github.com/Antonboom/nilnil v1.1.2/go.mod h1:0ynwvphOLmAuMwTNDyBnDZmSwZoDpcFXmUHmzoHH2WA= +github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= +github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/ClickHouse/clickhouse-go-linter v1.2.1 h1:zGEKIyd5YL08ieWG/LOUmlau2DxbxPVOfAeo+4Jz3ck= +github.com/ClickHouse/clickhouse-go-linter v1.2.1/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4= +github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= +github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ= +github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= +github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= +github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= +github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M= +github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig= +github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= +github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= +github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= +github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= +github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= +github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= +github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI= +github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM= +github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM= +github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= +github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= +github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= +github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= +github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= +github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= +github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= +github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= +github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfsU= +github.com/bombsimon/wsl/v5 v5.9.0/go.mod h1:kjo4HiAV5FDkHC8/uzJq9mBffEEd6WT/nvN7DoMovDM= +github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= +github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= +github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= +github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= +github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I= +github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4= +github.com/butuzov/mirror v1.3.3 h1:v0RsWBhfFc1RQqE/f3sHpSttKDtodFn0gFmtYyD4/hA= +github.com/butuzov/mirror v1.3.3/go.mod h1:h9BzzwYnTiHO0GzgvaTqIg7VSsOUhdIv51cHFFBmX1w= +github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= +github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= +github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= +github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= +github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4= +github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= +github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= +github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= +github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= +github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= +github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= +github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= +github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= +github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= +github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= +github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/firefart/nonamedreturns v1.0.8 h1:iB32Dl17zJl1zlVEj/WlUWgx0HiRyQ85OUw1WHa4/II= +github.com/firefart/nonamedreturns v1.0.8/go.mod h1:vxFNvm5AfP/8rgAKFzYmnqx0yp1HjrYsErZ9pHPTznA= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= +github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= +github.com/ghostiam/protogetter v0.3.21 h1:EeWTGvL/Eyosp653hiWb6Byx4b69iJC4/E+za7vQHoI= +github.com/ghostiam/protogetter v0.3.21/go.mod h1:iAKSpyoHwYzay+OpjoWgwzRtPFthEfuUvmlomTThck0= +github.com/go-critic/go-critic v0.14.4 h1:dSX4C3pWSeuMVxvQh6yG8U0ReSf3YOmKi4nwX5q7n/8= +github.com/go-critic/go-critic v0.14.4/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= +github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= +github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= +github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= +github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= +github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= +github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= +github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= +github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= +github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= +github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= +github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= +github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= +github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= +github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= +github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= +github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= +github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= +github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= +github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= +github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= +github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= +github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= +github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 h1:fVLolA3dG6s0brGetsiDAtcnpMSwVa2LqXJEw/9RJG4= +github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6/go.mod h1:1xo+NFW5S+bEf2DKXhaxuzvcDN5AR6o/KMwvqC5Mkpo= +github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A= +github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= +github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= +github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= +github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg= +github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA= +github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU= +github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w= +github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0= +github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10= +github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg= +github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= +github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= +github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= +github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= +github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba h1:lqtcnSMDuuJdu/LrKWi5RJzpSNLOJXYe/nzQutTI5kg= +github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sCBNcpRmhJCtbFGz49+IM3ETTFf7QdJ30AeYCd43NKk= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= +github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= +github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= +github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= +github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= +github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= +github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= +github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= +github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= +github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU= +github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= +github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= +github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= +github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jgautheron/goconst v1.11.0 h1:KgN90z5qXt5f0Uzf3cWXev3hfMMFUyNeKdpkSBRvLDk= +github.com/jgautheron/goconst v1.11.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= +github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= +github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= +github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= +github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= +github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= +github.com/kisielk/errcheck v1.20.0 h1:9rwHBNKzd4wkDWcROy3DvFGNqEPlkxBg305rvk7HabI= +github.com/kisielk/errcheck v1.20.0/go.mod h1:O+f80MKNwX8Oor2jwgpeQ9An7uJm+hRSgT+h22knRJU= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= +github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= +github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= +github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w= +github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= +github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= +github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= +github.com/ldez/gomoddirectives v0.9.0 h1:2YV/EX7nVlWL4jySusYTzBKHuE3D2fgcRsQuMa3yIoo= +github.com/ldez/gomoddirectives v0.9.0/go.mod h1:DdZzfm9MdXCjn2/UGYXCFfo+tzrp2Ib4iD2Q0kIJkwE= +github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= +github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= +github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk= +github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY= +github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk= +github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI= +github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= +github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= +github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= +github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= +github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= +github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= +github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0= +github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g= +github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8= +github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= +github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= +github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= +github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= +github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q= +github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= +github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= +github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= +github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= +github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= +github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= +github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= +github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8= +github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= +github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= +github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= +github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= +github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= +github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= +github.com/raeperd/recvcheck v0.3.0 h1:PM+XYvyxIj3bo+kobJfFTdTuU3Lmfu96mKDbyHDbRt8= +github.com/raeperd/recvcheck v0.3.0/go.mod h1:PZNwG+HztFYMH2ZPq0Hu3QgkV2yiA6VrtNz9c1fXWJo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= +github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= +github.com/ryancurrah/gomodguard/v2 v2.1.3 h1:E7sz3PJwE9Ba1reVxSpF6XLCPJZ74Kfw/LabTNM4GIA= +github.com/ryancurrah/gomodguard/v2 v2.1.3/go.mod h1:CQicdLGatWMxLX53JzoBjYlsNZhHbmLv2AVa0s2aivU= +github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg= +github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU= +github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= +github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= +github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= +github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= +github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= +github.com/securego/gosec/v2 v2.28.0 h1:ZsSdiDb0AtTpLFVol5z91gbMei9ZiLEPG/pZjZujp7c= +github.com/securego/gosec/v2 v2.28.0/go.mod h1:lb4/9AHe+lJy/kjWmWRWWsEipvbwGKuxf+tY1Pmjdnk= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q= +github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk= +github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= +github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= +github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs= +github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= +github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY= +github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= +github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= +github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= +github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= +github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= +github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= +github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= +github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= +github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA= +github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= +github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0= +github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M= +github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= +github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= +github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is= +github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= +github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= +github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= +github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= +github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= +github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= +github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= +github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4= +github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q= +github.com/uudashr/iface v1.5.1 h1:BS3yrgaT55s3dAtJoxuTnsZNbsejq/mVuzp49YDyfBA= +github.com/uudashr/iface v1.5.1/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw= +github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= +github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= +github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= +github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= +github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= +github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= +github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= +gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= +go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= +go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= +go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= +go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= +go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4= +go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I= +go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50= +go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= +go.augendre.info/fatcontext v0.10.0 h1:HhFopmivh8U1+AU7f0kuwUeg2eiIns7YsGQOMHwSJ90= +go.augendre.info/fatcontext v0.10.0/go.mod h1:pqpGvA9GlrXy+aXkp8L2dKz12Zp4g2FhzcAtwToU+2w= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f h1:+lI8cDJ4uceLipg2f1ODay7fEuLkk0BIHXd6PB8icxo= +golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo= +gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w= +honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= +mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc= +mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo= +mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 h1:Re1NRyLpiAt9kB+ImaaoapwWiQXrKwER4tY8fLOkDew= +mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8/go.mod h1:MrS/+zJ1M2xvGXhKktiHbNQeQPyg3Qel+KkzT+f7/i4= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/infra/github/main.tf b/infra/github/main.tf new file mode 100644 index 00000000..e03f02d2 --- /dev/null +++ b/infra/github/main.tf @@ -0,0 +1,83 @@ +# This module puts the repository's GitHub configuration under version control. It is deployed by +# hand, like the equivalent modules in meshstack-hub, meshfed-release and terraform-provider-meshstack. +locals { + github_repository_name = "meshstack-cli" + + # Every check that gates a merge, mapped to the app allowed to report it: a check that is not + # listed here cannot block a merge, and pinning the app stops anything else reporting under the + # same name. + # + # Apply this only once every check named here can actually report, and merge right after: in + # between, a pull request off the old default branch requires a check that nothing reports. + required_checks = { + "Go Build" = local.github_actions_app_id + "Go Lint and Format Check" = local.github_actions_app_id + "Go Test" = local.github_actions_app_id + "Acceptance Tests (meshStack backend)" = local.satellite_app_id + } + + # The provider cannot resolve an app slug to an id. Read one off a commit that carries the check: + # gh api /repos/meshcloud/meshstack-cli/commits//check-runs \ + # --jq '.check_runs[] | {name, app_id: .app.id, app: .app.slug}' + github_actions_app_id = 15368 # github-actions + satellite_app_id = 781479 # meshcloud-gh-actions, which meshfed-release reports with +} + +resource "github_repository_ruleset" "protect_default_branch" { + repository = local.github_repository_name + name = "Protect default branch" + target = "branch" + enforcement = "active" + + bypass_actors { + actor_id = 0 # org admin (role-based; GitHub stores 0) + actor_type = "OrganizationAdmin" + bypass_mode = "always" + } + + bypass_actors { + actor_id = 2 # maintain + actor_type = "RepositoryRole" + bypass_mode = "always" + } + + conditions { + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + deletion = true + non_fast_forward = true # force push + + # Rebase-only and a linear history, which is what the other meshcloud repositories enforce, are + # both impossible here: client/ arrives as a git subtree, and every `git subtree pull` produces + # a merge commit that only a merge can land on the default branch. + required_linear_history = false + + pull_request { + required_approving_review_count = 1 + required_review_thread_resolution = true + allowed_merge_methods = ["rebase", "merge"] + dismiss_stale_reviews_on_push = false + require_code_owner_review = false + require_last_push_approval = false + } + + required_status_checks { + # A pull request has to be up to date with the default branch before it can merge, so a + # check result always describes the code that actually lands. + strict_required_status_checks_policy = true + + dynamic "required_check" { + for_each = local.required_checks + content { + context = required_check.key + integration_id = required_check.value + } + } + } + } +} diff --git a/infra/github/terraform.tf b/infra/github/terraform.tf new file mode 100644 index 00000000..843b3938 --- /dev/null +++ b/infra/github/terraform.tf @@ -0,0 +1,19 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + github = { + source = "integrations/github" + version = "~> 6.13" + } + } + + backend "gcs" { + bucket = "meshcloud-tf-states" + prefix = "meshstack-cli/infra/github" + } +} + +provider "github" { + owner = "meshcloud" +} diff --git a/internal/http/auth.go b/internal/http/auth.go new file mode 100644 index 00000000..36e5957d --- /dev/null +++ b/internal/http/auth.go @@ -0,0 +1,69 @@ +package http + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/url" +) + +// Authorization produces the bearer token for each request. An implementation renews the token +// it holds once that token is close to expiry, so that [AuthorizedClient.DoRequest] only has to +// handle the 401 a token lost earlier than expected. +type Authorization interface { + GetBearerToken(ctx context.Context) (BearerToken, error) + + // RefreshBearerToken returns the token that replaces the rejected one. Returning the + // rejected token unchanged tells the caller that there is nothing new to try. + RefreshBearerToken(ctx context.Context, rejected BearerToken) (BearerToken, error) +} + +func (c Client) WithAuthorization(auth Authorization) AuthorizedClient { + return AuthorizedClient{c, auth} +} + +type AuthorizedClient struct { + Client + + Authorization Authorization +} + +func (c AuthorizedClient) DoRequest[R any](ctx context.Context, method string, url *url.URL, options ...RequestOption) (result R, err error) { + token, tokenErr := c.Authorization.GetBearerToken(ctx) + if tokenErr != nil { + return result, tokenErr + } + result, err = c.Client.DoRequest[R](ctx, method, url, append(options, token.asRequestOption())...) + + if httpErr, ok := errors.AsType[Error](err); ok && httpErr.IsUnauthorized() { + // Clock skew between this machine and the server can make a token look valid here and + // expired there, so one 401 earns one retry with a freshly minted token. + refreshedToken, refreshErr := c.Authorization.RefreshBearerToken(ctx, token) + switch { + case refreshErr != nil: + return result, errors.Join(err, fmt.Errorf("cannot renew the rejected token: %w", refreshErr)) + case refreshedToken == token: + return result, err + } + slog.DebugContext(ctx, "retrying after 401 with a freshly minted token", "url", url.String(), "method", method) + return c.Client.DoRequest[R](ctx, method, url, append(options, refreshedToken.asRequestOption())...) + } + return result, err +} + +// BearerToken is both the token Authorization produces and an Authorization of its own, for a +// caller that holds one static token. +type BearerToken string + +func (token BearerToken) GetBearerToken(_ context.Context) (BearerToken, error) { + return token, nil +} + +func (token BearerToken) RefreshBearerToken(_ context.Context, _ BearerToken) (BearerToken, error) { + return "", fmt.Errorf("cannot renew %T", token) +} + +func (token BearerToken) asRequestOption() RequestOption { + return withHeader("Authorization", fmt.Sprintf("Bearer %s", token)) +} diff --git a/internal/http/http_client.go b/internal/http/http_client.go new file mode 100644 index 00000000..5ee99c9f --- /dev/null +++ b/internal/http/http_client.go @@ -0,0 +1,134 @@ +package http + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + gohttp "net/http" + "net/url" + "reflect" + "slices" + "time" + + "github.com/meshcloud/meshstack-cli/internal/json" +) + +var sharedClient = func() (client *gohttp.Client) { + client = &gohttp.Client{ + Timeout: 1 * time.Minute, + } + RetryOptions{ + // Sized to ride out a full meshStack backend restart, which can leave the gateway + // returning 503 for two to three minutes. This backoff sequence sums to about four + // minutes: 1+2+4+8+16+30*7 seconds. + MaxRetries: 12, + Backoff: ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 30 * time.Second}, + }.ApplyTo(client) + return +}() + +type Client struct { + *gohttp.Client + + UserAgent string +} + +func NewClient(userAgent string) Client { + return Client{sharedClient, userAgent} +} + +// DoRequest sends one request and parses the answer as JSON. A non-2xx status is an Error that +// carries the response body, because an OIDC endpoint answers a refusal with an error document +// and that document is the only thing saying which refusal it was. +func (c Client) DoRequest[R any](ctx context.Context, method string, url *url.URL, options ...RequestOption) (result R, err error) { + var body []byte + body, err = c.doRequest(ctx, method, url, options) + if err != nil { + return + } + if len(body) == 0 { + // An empty body is expected only for no-content calls, which are typed DoRequest[any] (e.g. + // trigger-run, delete) and ignore the result. For a call that expects an object (a pointer or a + // concrete struct), an empty 2xx body is unexpected — fail loudly instead of returning a nil/zero + // value that the caller would dereference or mistake for a 404/"not found". + if t := reflect.TypeFor[R](); t.Kind() == reflect.Interface && t.NumMethod() == 0 { + return + } + err = fmt.Errorf("unexpected empty response body from %s %s", method, url) + return + } + if err = json.Unmarshal(body, &result); err != nil { + err = fmt.Errorf("parsing response body as JSON failed: %w", err) + } + return +} + +func (c Client) doRequest(ctx context.Context, method string, url *url.URL, options []RequestOption) ([]byte, error) { + if c.UserAgent != "" { + options = slices.Insert(options, 0, + withHeader("User-Agent", c.UserAgent), + ) + } + opts := requestOptions{} + for _, option := range options { + option(&opts) + } + req, err := c.buildRequest(ctx, method, url, opts) + if err != nil { + return nil, err + } + res, err := c.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + return c.readBodyAndCheckSuccess(ctx, res) +} + +func (c Client) readBodyAndCheckSuccess(ctx context.Context, res *gohttp.Response) ([]byte, error) { + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) + } + slog.DebugContext(ctx, "response", "status", res.StatusCode, "body", loggedBody{bytes.NewBuffer(responseBody)}) + + if res.StatusCode >= 200 && res.StatusCode <= 299 { + return responseBody, nil + } + + return responseBody, Error{ + StatusCode: res.StatusCode, + ResponseBody: responseBody, + } +} + +func (c Client) buildRequest(ctx context.Context, method string, url *url.URL, opts requestOptions) (*gohttp.Request, error) { + var requestBody io.ReadWriter + if opts.requestPayload != nil { + requestBodyData, err := opts.requestPayload() + if err != nil { + return nil, fmt.Errorf("cannot build request body data: %w", err) + } + requestBody = bytes.NewBuffer(requestBodyData) + } + + if opts.retryable { + ctx = context.WithValue(ctx, retryableKey{}, true) + } + + req, err := gohttp.NewRequestWithContext(ctx, method, url.String(), requestBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + for _, modifier := range opts.requestModifiers { + if err := modifier(req); err != nil { + return nil, err + } + } + slog.DebugContext(ctx, "request", "url", req.URL.String(), "method", req.Method, "headers", loggedHeaders(req.Header), "body", loggedBody{requestBody}) + return req, nil +} diff --git a/internal/http/http_client_test.go b/internal/http/http_client_test.go new file mode 100644 index 00000000..a70c51dd --- /dev/null +++ b/internal/http/http_client_test.go @@ -0,0 +1,557 @@ +// Package http_test drives the client from the outside, so that it can parse answers into the +// types real callers declare. internal/http may not import any of them. +package http_test + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "log/slog" + gohttp "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" +) + +func TestHttpClient(t *testing.T) { + t.Run("DoRequest success", func(t *testing.T) { + testLogger := installTestLogger(t) + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + resp.WriteHeader(gohttp.StatusOK) + _, _ = resp.Write([]byte(`"some-answer"`)) + assert.Equal(t, "/get", req.URL.Path) + assert.Equal(t, gohttp.MethodGet, req.Method) + assert.Equal(t, "test-agent", req.Header.Get("User-Agent")) + }) + resp, err := client.DoRequest[string](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("get")) + require.NoError(t, err) + assert.Equal(t, "some-answer", resp) + assert.Equal(t, []string{ + fmt.Sprintf("request [url %s/get method GET headers User-Agent=test-agent body ]", client.ServerUrl), + `response [status 200 body "some-answer"]`, + }, testLogger.Debugs) + assert.Empty(t, testLogger.Warns) + }) + + t.Run("DoRequest object call with empty 2xx body errors", func(t *testing.T) { + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + resp.WriteHeader(gohttp.StatusOK) + }) + _, err := client.DoRequest[*string](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("get")) + require.Error(t, err) + assert.ErrorContains(t, err, "unexpected empty response body") + }) + + t.Run("DoRequest no-content call (any) tolerates an empty 2xx body", func(t *testing.T) { + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + resp.WriteHeader(gohttp.StatusAccepted) // empty body by design (trigger-run/delete) + }) + _, err := client.DoRequest[any](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("trigger-run")) + require.NoError(t, err) + }) + + t.Run("DoRequest with successful retry", func(t *testing.T) { + for _, retryableStatusCode := range []int{429, 502, 503, 504} { + t.Run(fmt.Sprintf("after code %d", retryableStatusCode), func(t *testing.T) { + testLogger := installTestLogger(t) + retryTestBackoff := retryTestBackoff{WaitTime: 1 * time.Second} + retried := false + client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + if !retried { + if retryableStatusCode == 429 { + // Delay-seconds form. The HTTP-date form needs a mocked clock, which + // only a test inside the package can install, so TestRetryAfterBackoff + // covers it. + resp.Header().Set("Retry-After", "1") + } + resp.WriteHeader(retryableStatusCode) + retried = true + return + } + resp.WriteHeader(gohttp.StatusOK) + _, _ = resp.Write([]byte(`{}`)) + }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff}) + + _, err := client.DoRequest[any](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("get")) + require.NoError(t, err) + if retryableStatusCode == 429 { + assert.Equal(t, 0, retryTestBackoff.Called) + } else { + assert.Equal(t, 1, retryTestBackoff.Called) + } + assert.Equal(t, []string{ + fmt.Sprintf("retrying request [status %d method GET path /get attempt 1/3 waitTime 1s]", retryableStatusCode), + }, testLogger.Warns) + }) + } + }) + + t.Run("DoRequest with 2 retries exhausted", func(t *testing.T) { + testLogger := installTestLogger(t) + backoff := retryTestBackoff{} + client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + resp.WriteHeader(gohttp.StatusBadGateway) + }), http.RetryOptions{MaxRetries: 2, Backoff: &backoff}) + _, err := client.DoRequest[any](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("get")) + var httpErr http.Error + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, 502, httpErr.StatusCode) + assert.Equal(t, 2, backoff.Called) + assert.Equal(t, []string{ + "retrying request [status 502 method GET path /get attempt 1/2 waitTime 0s]", + "retrying request [status 502 method GET path /get attempt 2/2 waitTime 0s]", + }, testLogger.Warns) + assert.Equal(t, []string{ + fmt.Sprintf("request [url %s/get method GET headers User-Agent=test-agent body ]", client.ServerUrl), + "response [status 502 body ]", + }, testLogger.Debugs) + }) + + t.Run("DoRequest with context cancelled during backoff", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + resp.WriteHeader(gohttp.StatusBadGateway) + cancel() // cancel context so the backoff wait is interrupted + }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) + _, err := client.DoRequest[any](ctx, gohttp.MethodGet, client.ServerUrl.JoinPath("get")) + require.ErrorIs(t, err, context.Canceled) + }) + + t.Run("DoRequest with PATCH (not retried)", func(t *testing.T) { + attempts := 0 + client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + attempts++ + resp.WriteHeader(gohttp.StatusBadGateway) + }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{WaitTime: 10 * time.Second}}) + _, err := client.DoRequest[any](t.Context(), gohttp.MethodPatch, client.ServerUrl) + require.Error(t, err) + assert.Equal(t, 1, attempts, "PATCH must not be retried") + }) + + // An OIDC grant and /api/login are both a POST, and only one of them may be replayed. + // Retryable is what tells them apart. + t.Run("DoRequest with POST", func(t *testing.T) { + t.Run("is not retried by default", func(t *testing.T) { + attempts := 0 + client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + attempts++ + resp.WriteHeader(gohttp.StatusServiceUnavailable) + }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}}) + _, err := client.DoRequest[any](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("grant")) + require.Error(t, err) + assert.Equal(t, 1, attempts, "a POST may create something, so replaying it needs the caller's word") + }) + + t.Run("is retried when the caller marked it Retryable", func(t *testing.T) { + attempts := 0 + client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + attempts++ + if attempts == 1 { + resp.WriteHeader(gohttp.StatusServiceUnavailable) + return + } + resp.WriteHeader(gohttp.StatusOK) + _, _ = resp.Write([]byte(`{}`)) + }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}}) + _, err := client.DoRequest[any](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("login"), http.Retryable()) + require.NoError(t, err) + assert.Equal(t, 2, attempts) + }) + }) + + // GET is the only method the client replays unasked, so MeshObjectClient marks its + // idempotent PUT and DELETE with Retryable. + t.Run("DoRequest with DELETE marked Retryable", func(t *testing.T) { + attempts := 0 + client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + attempts++ + if attempts == 1 { + resp.WriteHeader(gohttp.StatusServiceUnavailable) + return + } + resp.WriteHeader(gohttp.StatusNoContent) + }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}}) + _, err := client.DoRequest[any](t.Context(), gohttp.MethodDelete, client.ServerUrl.JoinPath("delete"), http.Retryable()) + require.NoError(t, err) + assert.Equal(t, 2, attempts, "DELETE must be retried after a 503") + }) + + t.Run("DoRequest with PUT replays body on retry", func(t *testing.T) { + attempt := 0 + client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + body, _ := io.ReadAll(req.Body) + assert.JSONEq(t, `{"key":"value"}`, string(body)) + attempt++ + if attempt == 1 { + resp.WriteHeader(gohttp.StatusBadGateway) + return + } + resp.WriteHeader(gohttp.StatusOK) + }), http.RetryOptions{MaxRetries: 2, Backoff: &retryTestBackoff{}}) + _, err := client.DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl, + http.WithJsonPayload(map[string]string{"key": "value"}, "application/json"), http.Retryable()) + require.NoError(t, err) + assert.Equal(t, 2, attempt) + }) + + t.Run("DoRequest with BearerToken as Authorization", func(t *testing.T) { + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + assert.Equal(t, "Bearer my-static-token", req.Header.Get("Authorization")) + resp.WriteHeader(gohttp.StatusAccepted) + }) + _, err := client.WithAuthorization(http.BearerToken("my-static-token")).DoRequest[any](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("create"), + http.WithJsonPayload("content", "text/plain")) + require.NoError(t, err) + }) + + t.Run("DoRequest re-mints once on 401", func(t *testing.T) { + t.Run("retries with the freshly minted token", func(t *testing.T) { + auth := &refreshableAuthorization{token: "stale"} + var seen []string + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + seen = append(seen, req.Header.Get("Authorization")) + if req.Header.Get("Authorization") == "Bearer stale" { + resp.WriteHeader(gohttp.StatusUnauthorized) + return + } + resp.WriteHeader(gohttp.StatusAccepted) + }) + _, err := client.WithAuthorization(auth).DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit")) + require.NoError(t, err) + assert.Equal(t, []string{"Bearer stale", "Bearer fresh"}, seen) + assert.Equal(t, []http.BearerToken{"stale"}, auth.rejected, "the token that was refused is what the refresh is told about") + }) + + t.Run("reports the 401 when the re-mint changes nothing", func(t *testing.T) { + auth := &refreshableAuthorization{token: "stale", keepToken: true} + attempts := 0 + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + attempts++ + resp.WriteHeader(gohttp.StatusUnauthorized) + }) + _, err := client.WithAuthorization(auth).DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit")) + var httpErr http.Error + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, gohttp.StatusUnauthorized, httpErr.StatusCode) + assert.Equal(t, 1, attempts, "a re-mint that produced the same token has nothing new to try") + }) + + t.Run("reports both errors when the re-mint fails", func(t *testing.T) { + auth := &refreshableAuthorization{token: "stale", refreshErr: errors.New("the login expired")} + attempts := 0 + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + attempts++ + resp.WriteHeader(gohttp.StatusUnauthorized) + }) + _, err := client.WithAuthorization(auth).DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit")) + var httpErr http.Error + require.ErrorAs(t, err, &httpErr, "the 401 the request ran into must stay reachable") + assert.Equal(t, gohttp.StatusUnauthorized, httpErr.StatusCode) + require.ErrorIs(t, err, auth.refreshErr, "and so must the reason nothing better could be tried") + assert.Equal(t, 1, attempts) + }) + + t.Run("leaves an authorization that cannot re-mint alone", func(t *testing.T) { + attempts := 0 + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + attempts++ + resp.WriteHeader(gohttp.StatusUnauthorized) + }) + _, err := client.WithAuthorization(http.BearerToken("static")).DoRequest[any](t.Context(), gohttp.MethodPut, client.ServerUrl.JoinPath("edit")) + var httpErr http.Error + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, gohttp.StatusUnauthorized, httpErr.StatusCode) + assert.Equal(t, 1, attempts) + }) + }) +} + +// refreshableAuthorization mints "fresh" once it has been told its token was refused. +type refreshableAuthorization struct { + token http.BearerToken + keepToken bool + refreshErr error + rejected []http.BearerToken +} + +func (a *refreshableAuthorization) GetBearerToken(context.Context) (http.BearerToken, error) { + return a.token, nil +} + +func (a *refreshableAuthorization) RefreshBearerToken(_ context.Context, rejected http.BearerToken) (http.BearerToken, error) { + a.rejected = append(a.rejected, rejected) + if a.refreshErr != nil { + return "", a.refreshErr + } + if !a.keepToken { + a.token = "fresh" + } + return a.token, nil +} + +func TestUrlQueryOptions(t *testing.T) { + queryFrom := func(t *testing.T, query any) url.Values { + t.Helper() + var gotQuery url.Values + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + gotQuery = req.URL.Query() + resp.WriteHeader(gohttp.StatusOK) + _, _ = resp.Write([]byte(`"ok"`)) + }) + _, err := client.DoRequest[string](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("list"), + http.WithUrlQuery(query), + ) + require.NoError(t, err) + return gotQuery + } + + t.Run("a map is sent verbatim", func(t *testing.T) { + got := queryFrom(t, map[string]string{"definitionUuid": "abc", "status": "SUCCEEDED"}) + assert.Equal(t, "abc", got.Get("definitionUuid")) + assert.Equal(t, "SUCCEEDED", got.Get("status")) + }) + + // MeshObjectClient.List puts two of these on one request: the caller's filter, then the page + // it is fetching. Replacing rather than merging drops the filter, and the backend answers 400. + t.Run("a second query adds to the first", func(t *testing.T) { + var gotQuery url.Values + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + gotQuery = req.URL.Query() + resp.WriteHeader(gohttp.StatusOK) + _, _ = resp.Write([]byte(`"ok"`)) + }) + _, err := client.DoRequest[string](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("list"), + http.WithUrlQuery(map[string]string{"buildingBlockDefinitionUuid": "abc"}), + http.WithUrlQuery(map[string]any{"page": 2}), + ) + require.NoError(t, err) + assert.Equal(t, "abc", gotQuery.Get("buildingBlockDefinitionUuid")) + assert.Equal(t, "2", gotQuery.Get("page")) + }) + + t.Run("map values are kept even when zero", func(t *testing.T) { + got := queryFrom(t, map[string]any{"page": 0}) + assert.Equal(t, "0", got.Get("page")) + }) + + t.Run("struct fields are named by json tag and zero fields are dropped", func(t *testing.T) { + type filter struct { + Identifier *string `json:"identifier"` + Name string `json:"name"` + Restricted *bool `json:"restricted"` + } + got := queryFrom(t, filter{Identifier: new("abc")}) + assert.Equal(t, "abc", got.Get("identifier")) + assert.False(t, got.Has("name"), "zero string field must be dropped") + assert.False(t, got.Has("restricted"), "nil pointer field must be dropped") + }) + + t.Run("a zero-value struct adds no params", func(t *testing.T) { + type filter struct { + Identifier *string `json:"identifier"` + } + got := queryFrom(t, &filter{}) + assert.Empty(t, got) + }) +} + +// TestFormPayloadOption covers what the OIDC grants send and receive. A grant is a form and the +// token endpoint answers JSON, so the two content types disagree here where WithJsonPayload has +// them agree. +func TestFormPayloadOption(t *testing.T) { + postForm := func(t *testing.T, payload any) (gohttp.Header, url.Values) { + t.Helper() + var gotHeader gohttp.Header + var gotForm url.Values + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + assert.NoError(t, req.ParseForm()) + gotHeader, gotForm = req.Header, req.PostForm + resp.WriteHeader(gohttp.StatusOK) + _, _ = resp.Write([]byte(`"ok"`)) + }) + _, err := client.DoRequest[string](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("token"), + http.WithFormPayload(payload), + ) + require.NoError(t, err) + return gotHeader, gotForm + } + + t.Run("a struct becomes a form named by its json tags", func(t *testing.T) { + type refreshGrant struct { + GrantType string `json:"grant_type"` + RefreshToken string `json:"refresh_token"` + ClientId string `json:"client_id"` + CodeVerifier string `json:"code_verifier"` + } + header, got := postForm(t, refreshGrant{ + GrantType: "refresh_token", + RefreshToken: "the-rotating-one", + ClientId: "meshstack-cli", + }) + assert.Equal(t, "application/x-www-form-urlencoded", header.Get("Content-Type")) + assert.Equal(t, "application/json", header.Get("Accept")) + assert.Equal(t, "refresh_token", got.Get("grant_type")) + assert.Equal(t, "the-rotating-one", got.Get("refresh_token")) + assert.Equal(t, "meshstack-cli", got.Get("client_id")) + assert.False(t, got.Has("code_verifier"), "a field the grant does not use must not be sent empty") + }) + + t.Run("no payload sends no body", func(t *testing.T) { + header, got := postForm(t, nil) + assert.Empty(t, got) + assert.Empty(t, header.Get("Content-Type")) + }) + + t.Run("a form sends those types as the text they came from", func(t *testing.T) { + type logoutRequest struct { + RedirectUri xurl.URL `json:"post_logout_redirect_uri"` + IdToken jwt.JWT `json:"id_token_hint"` + ClientId string `json:"client_id"` + Unset *xurl.URL `json:"unset_uri"` + } + redirectUri, err := url.Parse("http://127.0.0.1:31234/callback") + require.NoError(t, err) + var idToken jwt.JWT + claims := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"someone"}`)) + require.NoError(t, idToken.UnmarshalText([]byte("e30."+claims+".not-a-signature"))) + + _, got := postForm(t, logoutRequest{ + RedirectUri: xurl.URL{URL: redirectUri}, + IdToken: idToken, + ClientId: "meshstack-cli", + }) + assert.Equal(t, "http://127.0.0.1:31234/callback", got.Get("post_logout_redirect_uri")) + assert.Equal(t, idToken.String(), got.Get("id_token_hint")) + assert.False(t, got.Has("unset_uri"), "a URL nobody set is dropped like any other zero value") + }) + + // xurl.URL and jwt.JWT both parse from a JSON string through UnmarshalText, so a caller + // declares the field it wants and reads a parsed URL or the token's claims. + t.Run("the answer parses into the types the caller declared", func(t *testing.T) { + type tokenResponse struct { + Issuer xurl.URL `json:"issuer"` + AccessToken jwt.JWT `json:"access_token"` + } + // Only the middle part is ever read, so the header is {} and the signature is not one. + claims := base64.RawURLEncoding.EncodeToString([]byte(`{"MC_CUSTOMER":"my-workspace"}`)) + accessToken := "e30." + claims + ".not-a-signature" + + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { + assert.NoError(t, req.ParseForm()) + assert.Equal(t, "refresh_token", req.PostForm.Get("grant_type")) + resp.WriteHeader(gohttp.StatusOK) + _, _ = resp.Write(fmt.Appendf(nil, + `{"issuer":"https://sso.example.com/realms/meshfed","access_token":%q}`, accessToken)) + }) + got, err := client.DoRequest[tokenResponse](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("token"), + http.WithFormPayload(map[string]string{"grant_type": "refresh_token"}), + ) + require.NoError(t, err) + + assert.Equal(t, "https://sso.example.com/realms/meshfed", got.Issuer.String()) + assert.Equal(t, "sso.example.com", got.Issuer.Host, "the field is a parsed URL, not the text it came from") + assert.Equal(t, accessToken, got.AccessToken.String()) + }) + + // Which texts jwt.JWT refuses is pinned in the jwt package, against its own testdata. + t.Run("an answer that is not what those types accept fails the call", func(t *testing.T) { + type tokenResponse struct { + AccessToken jwt.JWT `json:"access_token"` + } + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + resp.WriteHeader(gohttp.StatusOK) + _, _ = resp.Write([]byte(`{"access_token":"an-opaque-token"}`)) + }) + _, err := client.DoRequest[tokenResponse](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("token"), + http.WithFormPayload(map[string]string{"grant_type": "refresh_token"}), + ) + assert.ErrorContains(t, err, "not a JWT") + }) +} + +type TestClient struct { + http.Client + + ServerUrl *url.URL +} + +func newTestClientWithServer(t *testing.T, handlerFunc gohttp.HandlerFunc) TestClient { + t.Helper() + server := httptest.NewServer(handlerFunc) + t.Cleanup(server.Close) + serverUrl, err := url.Parse(server.URL) + require.NoError(t, err) + client := server.Client() + return TestClient{http.Client{Client: client, UserAgent: "test-agent"}, serverUrl} +} + +// withTestRetry gives one test client its own retry policy. The shipped client is the one shared +// one and takes no configuration, so a test that needs a backoff it can count builds its own. +func withTestRetry(c TestClient, options http.RetryOptions) TestClient { + options.ApplyTo(c.Client.Client) + return c +} + +func installTestLogger(t *testing.T) *testLogger { + t.Helper() + testLogger := &testLogger{} + previous := slog.Default() + slog.SetDefault(slog.New(testLogger)) + t.Cleanup(func() { + slog.SetDefault(previous) + }) + return testLogger +} + +// testLogger is the slog handler these tests read records back from. It formats a record as the +// message followed by its attributes, so that a test asserts the line a person reads in the log. +type testLogger struct { + Debugs []string + Infos []string + Warns []string +} + +var _ slog.Handler = (*testLogger)(nil) + +func (c *testLogger) Enabled(context.Context, slog.Level) bool { return true } + +func (c *testLogger) Handle(_ context.Context, record slog.Record) error { + var args []any + record.Attrs(func(attr slog.Attr) bool { + args = append(args, attr.Key, attr.Value.Any()) + return true + }) + line := fmt.Sprintf("%s %v", record.Message, args) + switch { + case record.Level >= slog.LevelWarn: + c.Warns = append(c.Warns, line) + case record.Level >= slog.LevelInfo: + c.Infos = append(c.Infos, line) + default: + c.Debugs = append(c.Debugs, line) + } + return nil +} + +func (c *testLogger) WithAttrs([]slog.Attr) slog.Handler { return c } +func (c *testLogger) WithGroup(string) slog.Handler { return c } + +type retryTestBackoff struct { + WaitTime time.Duration + Called int +} + +func (b *retryTestBackoff) Calculate(int) time.Duration { + b.Called++ + return b.WaitTime +} diff --git a/internal/http/http_error.go b/internal/http/http_error.go new file mode 100644 index 00000000..a2efba41 --- /dev/null +++ b/internal/http/http_error.go @@ -0,0 +1,37 @@ +package http + +import ( + "fmt" + gohttp "net/http" +) + +// Error represents an HTTP error response with status code. +// This error is returned when an HTTP request fails with a non-2XX status code. +type Error struct { + StatusCode int + ResponseBody []byte +} + +func (e Error) Error() string { + return fmt.Sprintf("http error %d, response '%s'", e.StatusCode, string(e.ResponseBody)) +} + +// IsUnauthorized returns true if the error is a 401 Unauthorized response. +func (e Error) IsUnauthorized() bool { + return e.StatusCode == gohttp.StatusUnauthorized +} + +// IsForbidden returns true if the error is a 403 Forbidden response. +func (e Error) IsForbidden() bool { + return e.StatusCode == gohttp.StatusForbidden +} + +// IsNotFound returns true if the error is a 404 Not Found response. +func (e Error) IsNotFound() bool { + return e.StatusCode == gohttp.StatusNotFound +} + +// IsConflict returns true if the error is a 409 Conflict response. +func (e Error) IsConflict() bool { + return e.StatusCode == gohttp.StatusConflict +} diff --git a/internal/http/logging.go b/internal/http/logging.go new file mode 100644 index 00000000..40c11a84 --- /dev/null +++ b/internal/http/logging.go @@ -0,0 +1,155 @@ +package http + +import ( + "bytes" + "encoding" + "encoding/json/jsontext" + "errors" + "fmt" + "io" + "maps" + gohttp "net/http" + "regexp" + "slices" + "strings" +) + +// This package logs through slog's default logger, and each front end installs its handler on it +// late: the Terraform provider does so in Configure. Two rules follow. +// +// - Reach the logger through the slog package functions at the point of use, and pass the +// request's context — DebugContext, not Debug. The provider's handler reads terraform's +// logger out of the context and drops a record that arrives without one. +// - Render an expensive attribute with fmt.Stringer and encoding.TextMarshaler, never with +// slog.LogValuer. The provider's handler resolves a LogValuer for every record, because its +// Enabled says yes to all of them and terraform owns the level, so a LogValuer would +// pretty-print every request body of every run including the ones TF_LOG then drops. + +// loggedHeaders is the request's headers with the bearer token taken out. Both methods produce +// that redacted form, because both are reached: the CLI's sink formats with %v and calls String, +// while terraform's sink encodes the fields as JSON and would otherwise walk this map itself and +// write the Authorization header out in full. +type loggedHeaders gohttp.Header + +var ( + _ fmt.Stringer = loggedHeaders(nil) + _ encoding.TextMarshaler = loggedHeaders(nil) +) + +func (l loggedHeaders) MarshalText() ([]byte, error) { + return []byte(l.String()), nil +} + +func (l loggedHeaders) String() string { + var lines []string + for _, k := range slices.Sorted(maps.Keys(l)) { + for _, v := range l[k] { + // Avoid printing that longish JWT Bearer token (which is also a secret) + if k == "Authorization" { + v = "[REDACTED]" + } + lines = append(lines, fmt.Sprintf("%s=%s", k, v)) + } + } + return strings.Join(lines, "\n") +} + +// loggedBody is a request or response body, pretty-printed when a sink writes it. Without +// MarshalText terraform's JSON log would show it as {"Reader":{}}. +type loggedBody struct { + io.Reader +} + +var ( + _ fmt.Stringer = loggedBody{} + _ encoding.TextMarshaler = loggedBody{} +) + +func (l loggedBody) MarshalText() ([]byte, error) { + return []byte(l.String()), nil +} + +func (l loggedBody) String() string { + switch body := l.Reader.(type) { + case nil: + return "" + case *bytes.Buffer: + return bytesToPrettyJson(body.Bytes()) + default: + return fmt.Sprintf(" %v", body) + } +} + +// secretNamePattern matches the name of a JSON member or of a url-encoded field whose value is a +// credential: /api/login sends clientSecret, an OIDC grant sends and returns refresh_token and +// access_token, and a meshObject carries an API key secret or a secret input. Redacting a name +// such as token_type along with them costs a debug log nothing, while missing one writes a +// reusable credential into it. +const secretNamePattern = `(?i:secret|token|password|plaintext)` + +const redactedValue = "[REDACTED]" + +var ( + secretName = regexp.MustCompile(secretNamePattern) + secretField = regexp.MustCompile(`([^&=\s]*` + secretNamePattern + `[^&=\s]*)=[^&\s]*`) +) + +func bytesToPrettyJson(data []byte) string { + if len(data) == 0 { + return "" + } + if redacted, err := redactSecrets(data); err == nil { + return redacted.String() + } + // Not JSON: an OIDC grant sends a url-encoded form, and a gateway answers with plain text. + return fmt.Sprintf(" %s", len(data), secretField.ReplaceAllString(string(data), "${1}="+redactedValue)) +} + +// redactSecrets indents the body and replaces the value of every member secretName matches. It +// rewrites the token stream rather than a decoded value, so every number keeps the text it +// arrived with: decoded into float64, a large integer came back out with lost precision. +func redactSecrets(data []byte) (jsontext.Value, error) { + decoder := jsontext.NewDecoder(bytes.NewReader(data)) + var indented bytes.Buffer + encoder := jsontext.NewEncoder(&indented, jsontext.WithIndent(" ")) + secret := false + for { + if secret { + secret = false + if err := decoder.SkipValue(); err != nil { + return nil, err + } + if err := encoder.WriteToken(jsontext.String(redactedValue)); err != nil { + return nil, err + } + continue + } + switch decoder.PeekKind() { + case 0: + if _, err := decoder.ReadToken(); !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("cannot read the body as JSON: %w", err) + } + // An indenting encoder terminates the top-level value with a newline, which would + // end the log line early. + return bytes.TrimSuffix(indented.Bytes(), []byte("\n")), nil + case '{', '}', '[', ']': + token, err := decoder.ReadToken() + if err != nil { + return nil, err + } + if err := encoder.WriteToken(token); err != nil { + return nil, err + } + default: + container, read := decoder.StackIndex(decoder.StackDepth()) + value, err := decoder.ReadValue() + if err != nil { + return nil, err + } + secret = container == '{' && read%2 == 0 && secretName.Match(value) + if err := encoder.WriteValue(value); err != nil { + return nil, err + } + } + } +} diff --git a/internal/http/logging_internal_test.go b/internal/http/logging_internal_test.go new file mode 100644 index 00000000..8a56925a --- /dev/null +++ b/internal/http/logging_internal_test.go @@ -0,0 +1,64 @@ +package http + +import ( + "bytes" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestLogRenderingWaitsForTheSink stays in package http because it builds a loggedBody directly: +// the client only ever wraps a bytes.Buffer, so counting the renders needs a reader of its own. +func TestLogRenderingWaitsForTheSink(t *testing.T) { + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&bytes.Buffer{}, &slog.HandlerOptions{Level: slog.LevelInfo}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + rendered := 0 + body := loggedBody{&countingReader{counted: &rendered}} + slog.DebugContext(t.Context(), "request", "body", body) + assert.Zero(t, rendered, "the dropped record still rendered its body") + + slog.InfoContext(t.Context(), "request", "body", body) + assert.Equal(t, 1, rendered, "the written record did not render its body") +} + +func TestLoggedBodyRedactsCredentials(t *testing.T) { + const secret = "s3cr3t" + tests := map[string]string{ + "api key login": `{"clientId":"an-id","clientSecret":"` + secret + `"}`, + "login answer": `{"access_token":"` + secret + `"}`, + "nested secret": `{"spec":{"config":{"clientSecret":{"plaintext":"` + secret + `"}}}}`, + "oidc grant form": `grant_type=refresh_token&refresh_token=` + secret + `&client_id=an-id`, + "oidc answer": `{"access_token":"` + secret + `","refresh_token":"` + secret + `","scope":"openid"}`, + } + for name, body := range tests { + t.Run(name, func(t *testing.T) { + rendered := loggedBody{bytes.NewBufferString(body)}.String() + assert.NotContains(t, rendered, secret) + assert.Contains(t, rendered, redactedValue) + }) + } +} + +func TestLoggedBodyKeepsWhatIsNoCredential(t *testing.T) { + rendered := loggedBody{bytes.NewBufferString(`{"clientId":"an-id","clientSecret":"s3cr3t"}`)}.String() + assert.Contains(t, rendered, "an-id") +} + +func TestLoggedBodyKeepsALargeIntegerExact(t *testing.T) { + assert.Contains(t, loggedBody{bytes.NewBufferString(`{"at":1234567890123456789}`)}.String(), "1234567890123456789") +} + +// countingReader counts how often loggedBody rendered it. +type countingReader struct { + counted *int +} + +func (c *countingReader) Read([]byte) (int, error) { return 0, nil } + +func (c *countingReader) String() string { + *c.counted++ + return "counted" +} diff --git a/internal/http/logging_test.go b/internal/http/logging_test.go new file mode 100644 index 00000000..8bcbf189 --- /dev/null +++ b/internal/http/logging_test.go @@ -0,0 +1,38 @@ +package http_test + +import ( + "bytes" + "log/slog" + gohttp "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/http" +) + +// TestJsonLogRedactsTheToken uses a handler that encodes attributes as JSON rather than +// formatting them with %v, which is what the Terraform provider's sink does. Without MarshalText +// that sink walks loggedHeaders as the map it is and writes the bearer token out in full. +func TestJsonLogRedactsTheToken(t *testing.T) { + var written bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&written, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { + resp.WriteHeader(gohttp.StatusOK) + _, _ = resp.Write([]byte(`{"answer":"served"}`)) + }) + const secret = "supersecret" + _, err := client.WithAuthorization(http.BearerToken(secret)).DoRequest[map[string]string](t.Context(), http.MethodPost, client.ServerUrl, + http.WithJsonPayload(map[string]string{"asked": "for"}, "application/json")) + require.NoError(t, err) + + logged := written.String() + assert.NotContains(t, logged, secret) + assert.Contains(t, logged, "[REDACTED]") + assert.Contains(t, logged, `asked`, "the request body") + assert.Contains(t, logged, `served`, "the response body") +} diff --git a/internal/http/method.go b/internal/http/method.go new file mode 100644 index 00000000..e98d85f2 --- /dev/null +++ b/internal/http/method.go @@ -0,0 +1,12 @@ +package http + +import gohttp "net/http" + +// The request methods this repository sends, re-exported so that a caller of DoRequest needs no +// second import for them. Add one here when a caller starts sending it. +const ( + MethodGet = gohttp.MethodGet + MethodPost = gohttp.MethodPost + MethodPut = gohttp.MethodPut + MethodDelete = gohttp.MethodDelete +) diff --git a/internal/http/options.go b/internal/http/options.go new file mode 100644 index 00000000..8f297bf9 --- /dev/null +++ b/internal/http/options.go @@ -0,0 +1,158 @@ +package http + +import ( + "context" + "encoding/json/jsontext" + "fmt" + "maps" + gohttp "net/http" + "net/url" + "reflect" + + "github.com/meshcloud/meshstack-cli/internal/json" +) + +type ( + // RequestOption is a functional option for configuring HTTP requests. + RequestOption func(opts *requestOptions) + + requestOptions struct { + retryable bool + requestPayload func() ([]byte, error) + requestModifiers []requestModifier + } + requestModifier func(req *gohttp.Request) error +) + +// Retryable declares that replaying this request cannot do harm, which is the only way a method +// other than GET is ever retried. meshStack's /api/login is the case it exists for: it mints a +// token and invalidates nothing, so a replay after a gateway 503 costs one token. +// +// Never put it on an OIDC refresh grant: that grant rotates the refresh token, and keycloak ends +// the whole session when a rotated token is used twice. +func Retryable() RequestOption { + return func(opts *requestOptions) { + opts.retryable = true + } +} + +// retryableKey marks a request its caller declared safe to replay. It travels in the request +// context rather than in a list on the client, because one client serves every caller and +// because gohttp.Client passes the context on to the request it issues for a redirect. +type retryableKey struct{} + +func isRetryable(ctx context.Context) bool { + retryable, _ := ctx.Value(retryableKey{}).(bool) + return retryable +} + +// WithUrlQuery adds URL query parameters from a query value. +// +// The given value is JSON-marshalled and decoded into a flat map, so each field becomes a query param +// named by its `json` tag. A struct passed by value is the common case: its zero-value fields are +// dropped (an implicit `omitempty`), so an unset filter needs neither a pointer nor an `omitempty` +// tag and a zero-value struct adds no params at all. A map[string]string / map[string]any is taken +// verbatim — every entry is sent, including deliberate zero values such as page=0. +// +// A value goes in as the JSON literal it marshalled to, with a string unquoted; nested objects or +// arrays are not supported. +func WithUrlQuery(query any) RequestOption { + return appendRequestModifier(func(req *gohttp.Request) error { + urlValues, err := convertStructOrMapToUrlValues(query) + if err != nil { + return fmt.Errorf("cannot convert url query: %w", err) + } + // Merged rather than assigned: MeshObjectClient.List adds its own page parameter to + // whatever filter the caller passed, and overwriting would drop a required parameter + // such as buildingBlockDefinitionUuid, which the backend then rejects. + merged := req.URL.Query() + maps.Copy(merged, urlValues) + req.URL.RawQuery = merged.Encode() + return nil + }) +} + +func convertStructOrMapToUrlValues(structOrMap any) (url.Values, error) { + data, err := json.Marshal(structOrMap) + if err != nil { + return nil, fmt.Errorf("cannot marshal type %T: %w", structOrMap, err) + } + var converted map[string]jsontext.Value + if err := json.Unmarshal(data, &converted); err != nil { + return nil, fmt.Errorf("cannot decode type %T into a flat map: %w", structOrMap, err) + } + // Drop zero-value fields only for a struct (passed by value, not by pointer); a map is + // passed through as given. + skipZero := reflect.ValueOf(structOrMap).Kind() == reflect.Struct + result := url.Values{} + for key, value := range converted { + if value.Kind() == 'n' { + continue + } + // A number goes in as the literal it marshalled to, never through a Go value: decoded into + // float64 and printed again, a millisecond timestamp would arrive as 1.2345678901234568e+18. + param := value.String() + if value.Kind() == '"' { + if err := json.Unmarshal(value, ¶m); err != nil { + return nil, fmt.Errorf("cannot read %s of type %T as a string: %w", key, structOrMap, err) + } + } + if skipZero && (param == "" || value.Kind() == 'f') { + continue + } + result[key] = append(result[key], param) + } + return result, nil +} + +func appendRequestModifier(modifier requestModifier) RequestOption { + return func(opts *requestOptions) { + opts.requestModifiers = append(opts.requestModifiers, modifier) + } +} + +func WithAccept(accept string) RequestOption { + return withHeader("Accept", accept) +} + +func withHeader(key, value string) RequestOption { + return appendRequestModifier(func(req *gohttp.Request) error { + req.Header.Set(key, value) + return nil + }) +} + +// WithJsonPayload sends a value as a JSON body, and both sends and asks for the given content +// type. The type is a parameter because meshStack names a meshObject's kind and version in it. +func WithJsonPayload(payload any, contentType string) RequestOption { + return func(opts *requestOptions) { + if payload == nil { + return + } + WithAccept(contentType)(opts) + withHeader("Content-Type", contentType)(opts) + opts.requestPayload = func() ([]byte, error) { + return json.Marshal(payload) + } + } +} + +// WithFormPayload sends the values as an url-encoded form body — converted from a struct or a +// map as [WithUrlQuery] describes — and asks for JSON in return, which is what an OIDC grant +// expects. +func WithFormPayload(payload any) RequestOption { + return func(opts *requestOptions) { + if payload == nil { + return + } + WithAccept("application/json")(opts) + withHeader("Content-Type", "application/x-www-form-urlencoded")(opts) + opts.requestPayload = func() ([]byte, error) { + values, err := convertStructOrMapToUrlValues(payload) + if err != nil { + return nil, fmt.Errorf("cannot convert form payload: %w", err) + } + return []byte(values.Encode()), nil + } + } +} diff --git a/internal/http/retry.go b/internal/http/retry.go new file mode 100644 index 00000000..08980e2e --- /dev/null +++ b/internal/http/retry.go @@ -0,0 +1,238 @@ +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "math" + gohttp "net/http" + "strconv" + "time" +) + +type RetryOptions struct { + // MaxRetries limits the attempts to retries. If zero, retries will never be attempted. + MaxRetries int + // Backoff to use when retrying. If nil, retries will never be attempted. + Backoff RetryBackoff +} + +// ApplyTo makes the given client retry a GET on its own, and any other method only where the +// caller marked the request with [Retryable]. +func (options RetryOptions) ApplyTo(c *gohttp.Client) { + next := gohttp.DefaultTransport + if c.Transport != nil { + next = c.Transport + } + c.Transport = &retryRoundTripper{ + Next: next, + MaxRetries: options.MaxRetries, + ShouldRetryRequest: func(req *gohttp.Request) bool { + if options.Backoff == nil { + return false + } + return req.Method == MethodGet || isRetryable(req.Context()) + }, + // ShouldRetryResponse returns the backoff policy if the response/error indicates a retryable condition, + // otherwise nil is returned to indicate no retry. + ShouldRetryResponse: func(resp *gohttp.Response, err error) RetryBackoff { + if err != nil { + return options.Backoff + } + switch resp.StatusCode { + case gohttp.StatusTooManyRequests, gohttp.StatusServiceUnavailable: + return retryAfterBackoff{Response: resp, Fallback: options.Backoff} + case gohttp.StatusBadGateway, gohttp.StatusGatewayTimeout: + return options.Backoff + default: + return nil + } + }, + } +} + +// RetryBackoff calculates the duration to wait before the next retry attempt. +type RetryBackoff interface { + Calculate(attempt int) time.Duration +} + +// ExponentialBackoff increases the backoff exponentially: minWait * 2^(attempt-1). +type ExponentialBackoff struct { + MinWait, MaxWait time.Duration +} + +func (b ExponentialBackoff) Calculate(attempt int) time.Duration { + nextWait := time.Duration(math.Pow(2, float64(attempt-1))) * b.MinWait + if b.MaxWait > 0 && nextWait > b.MaxWait { + return b.MaxWait + } + return nextWait +} + +var timeNow = time.Now + +type retryAfterBackoff struct { + Response *gohttp.Response + Fallback RetryBackoff +} + +func (b retryAfterBackoff) Calculate(attempt int) (waitTime time.Duration) { + defer func() { + const maxRetryAfterWaitTime = 5 * time.Minute + if waitTime < 0 { + waitTime = b.Fallback.Calculate(attempt) + } else if waitTime > maxRetryAfterWaitTime { + waitTime = maxRetryAfterWaitTime + } + }() + + // Parse the Retry-After header from a response. + // It supports both delay-seconds and HTTP-date formats (RFC 7231 §7.1.3). + + header := b.Response.Header.Get("Retry-After") + if header == "" { + return -1 + } + + // Try as delay-seconds first. + if seconds, err := strconv.ParseInt(header, 10, 64); err == nil { + return time.Duration(seconds) * time.Second + } + + // Try as HTTP-date (RFC 7231). + if date, err := gohttp.ParseTime(header); err == nil { + return date.Sub(timeNow()) + } + return -1 +} + +// retryRoundTripper wraps a gohttp.RoundTripper to retry failed requests. +// See [RetryOptions.ApplyTo] for which methods are retried. +type retryRoundTripper struct { + Next gohttp.RoundTripper + MaxRetries int + ShouldRetryRequest func(req *gohttp.Request) bool + ShouldRetryResponse func(resp *gohttp.Response, err error) RetryBackoff +} + +func (r *retryRoundTripper) RoundTrip(req *gohttp.Request) (*gohttp.Response, error) { + if !r.ShouldRetryRequest(req) { + return r.Next.RoundTrip(req) + } + req = makeRequestBodyRetryable(req) + for attempt := 1; ; attempt++ { + resp, err := r.Next.RoundTrip(req) + if errors.Is(err, errRetryableBodyClose) { + return resp, err + } + backoff := r.ShouldRetryResponse(resp, err) + // No retry needed or no more retries left — return as-is. + if backoff == nil || attempt > r.MaxRetries { + return resp, err + } + drainAndCloseResponseBody(req.Context(), resp) + if req.GetBody != nil { + body, bodyErr := req.GetBody() + if bodyErr != nil { + return nil, errors.Join(err, bodyErr) + } + req.Body = body + } + waitTime := backoff.Calculate(attempt) + slog.WarnContext(req.Context(), "retrying request", append( + func() []any { + if err != nil { + return []any{"error", err.Error()} + } + return []any{"status", resp.StatusCode} + }(), + "method", req.Method, + "path", req.URL.Path, + "attempt", fmt.Sprintf("%d/%d", attempt, r.MaxRetries), + "waitTime", waitTime, + )...) + timer := time.NewTimer(waitTime) + select { + case <-req.Context().Done(): + timer.Stop() + return nil, req.Context().Err() + case <-timer.C: + } + } +} + +func makeRequestBodyRetryable(req *gohttp.Request) *gohttp.Request { + if req.Body == nil { + return req + } + // If GetBody already returns independent readers (e.g. set by gohttp.NewRequestWithContext + // for *bytes.Buffer, *bytes.Reader, *strings.Reader), use it as-is for retries. + if req.GetBody != nil { + return req + } + body := retryableBody{Closer: req.Body} + body.Reader = io.TeeReader(req.Body, &body.Buffer) + result := req.Clone(req.Context()) + result.Body = &body + result.GetBody = nil + return result +} + +// retryableBody lazily captures request body bytes on the first read and replays them on retries. +// Buffer is filled via TeeReader as the transport reads during the first request. On Close, the +// source is released and subsequent reads replay from Buffer via bytes.NewReader. +type retryableBody struct { + io.Reader + io.Closer + + Buffer appendWriter +} + +var errRetryableBodyClose = errors.New("retryableBody failed to close") + +func (b *retryableBody) Close() error { + // Drain remaining bytes through the TeeReader to ensure Buffer captures the full body, + // even if the transport only partially read it (e.g. connection reset mid-write). + if _, err := io.Copy(io.Discard, b.Reader); err != nil { + return errors.Join(err, errRetryableBodyClose) + } + // On first close, close the Body and use the b.Buffer from now on + if b.Closer != nil { + if err := b.Closer.Close(); err != nil { + return errors.Join(err, errRetryableBodyClose) + } + } + b.Closer = nil + b.Reader = bytes.NewReader(b.Buffer) + return nil +} + +// appendWriter is an io.Writer that appends to a []byte slice. +// Helper for retryableBody.Buffer. +type appendWriter []byte + +func (w *appendWriter) Write(p []byte) (int, error) { + *w = append(*w, p...) + return len(p), nil +} + +// drainAndCloseResponseBody reads up to maxBytes from the response body before closing it. +// Draining enables Go's gohttp.Transport to reuse the underlying TCP connection for +// subsequent requests. The maxBytes limit prevents getting stuck on large or slow +// responses — if the body exceeds this limit, the connection won't be reused, but +// we won't block indefinitely either. +func drainAndCloseResponseBody(ctx context.Context, resp *gohttp.Response) { + const maxBytes = 16 * 1024 + if resp != nil && resp.Body != nil { + drainedBytes, err := io.CopyN(io.Discard, resp.Body, maxBytes) + if err != nil && !errors.Is(err, io.EOF) { + slog.DebugContext(ctx, "failed to drain response body: "+err.Error()) + } + if err := resp.Body.Close(); err != nil { + slog.DebugContext(ctx, fmt.Sprintf("failed to close response body after draining %d bytes: %s", drainedBytes, err.Error())) + } + } +} diff --git a/internal/http/retry_test.go b/internal/http/retry_test.go new file mode 100644 index 00000000..7230247c --- /dev/null +++ b/internal/http/retry_test.go @@ -0,0 +1,64 @@ +package http + +import ( + "fmt" + gohttp "net/http" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestExponentialBackoff_Calculate(t *testing.T) { + tests := []struct { + attempt int + want time.Duration + }{ + {1, 1 * time.Second}, + {2, 2 * time.Second}, + {3, 4 * time.Second}, + {4, 5 * time.Second}, + {5, 5 * time.Second}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("attempt %d", tt.attempt), func(t *testing.T) { + b := ExponentialBackoff{ + MinWait: 1 * time.Second, + MaxWait: 5 * time.Second, + } + assert.Equalf(t, tt.want, b.Calculate(tt.attempt), "Calculate(%v)", tt.attempt) + }) + } +} + +func TestRetryAfterBackoff(t *testing.T) { + // synctest bubble starts at 2000-01-01T00:00:00Z + bubbleStart := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + fallback := ExponentialBackoff{MinWait: 1 * time.Second, MaxWait: 10 * time.Second} + + tests := []struct { + name string + header string + want time.Duration + }{ + {"delay-seconds", "30", 30 * time.Second}, + {"zero seconds", "0", 0}, // RFC: retry immediately + {"capped at 5 minutes", "600", 5 * time.Minute}, // capped + {"empty header", "", 1 * time.Second}, // falls back + {"unparseable header", "not-a-number-or-date", 1 * time.Second}, // falls back + {"HTTP-date in the past", bubbleStart.Add(-10 * time.Second).Format(gohttp.TimeFormat), 1 * time.Second}, // falls back + {"HTTP-date in the future", bubbleStart.Add(45 * time.Second).Format(gohttp.TimeFormat), 45 * time.Second}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + b := retryAfterBackoff{ + Response: &gohttp.Response{Header: gohttp.Header{"Retry-After": {tt.header}}}, + Fallback: fallback, + } + assert.Equal(t, tt.want, b.Calculate(1)) + }) + }) + } +} diff --git a/internal/json/marshal.go b/internal/json/marshal.go new file mode 100644 index 00000000..5ed8191f --- /dev/null +++ b/internal/json/marshal.go @@ -0,0 +1,73 @@ +package json + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" +) + +// wireCompatibility holds the output to the shape encoding/json v1 produced: a nil slice and a +// nil map go out as null rather than as [] and {}, and object members are sorted. json/v2 would +// write [] and {} and leave map order to chance, and neither change is ours to make — the +// meshObject API may read null and an empty collection differently on a PUT, and the Terraform +// provider hashes this JSON to decide whether a resource changed. +var wireCompatibility = json.JoinOptions( + json.Deterministic(true), + json.FormatNilSliceAsNull(true), + json.FormatNilMapAsNull(true), +) + +func Marshal(payload any) ([]byte, error) { + return json.Marshal(payload, wireCompatibility) +} + +type MarshalOption func(opts *marshalOptions) + +type marshalOptions struct { + perm os.FileMode +} + +// UserOnlyFilePerms keeps the file readable and writable by its owner alone. Use it for a +// file that holds a credential, or a token minted from one. +func UserOnlyFilePerms() MarshalOption { + return func(opts *marshalOptions) { + opts.perm = 0o600 + } +} + +func MarshalTo(ctx context.Context, file string, payload any, options ...MarshalOption) (err error) { + opts := marshalOptions{perm: 0o644} + for _, option := range options { + option(&opts) + } + if err = os.MkdirAll(filepath.Dir(file), 0o700); err != nil { + return err + } + // Every reader of these files reads them without taking the lock, so none of them may + // ever see a half-written one: the encoder fills a temporary file in the same directory, + // and the rename below publishes it in one step. + var out *os.File + out, err = os.CreateTemp(filepath.Dir(file), filepath.Base(file)+".tmp") + if err != nil { + return err + } + defer func() { + err = errors.Join(err, out.Chmod(opts.perm), out.Close()) + if err == nil { + err = os.Rename(out.Name(), file) + } + if err != nil { + _ = os.Remove(out.Name()) + err = fmt.Errorf("cannot marshal json to %s: %w", file, err) + } else { + slog.DebugContext(ctx, "Marshaled json to "+file) + } + }() + encoder := jsontext.NewEncoder(out, jsontext.WithIndent(" ")) + return json.MarshalEncode(encoder, payload, wireCompatibility) +} diff --git a/internal/json/marshal_test.go b/internal/json/marshal_test.go new file mode 100644 index 00000000..dd3deb68 --- /dev/null +++ b/internal/json/marshal_test.go @@ -0,0 +1,26 @@ +package json_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/json" +) + +func TestMarshalToKeepsTheDirectoryAndTheCredentialFileToItsOwner(t *testing.T) { + file := filepath.Join(t.TempDir(), "created", "credentials.json") + + require.NoError(t, json.MarshalTo(t.Context(), file, map[string]string{"secret": "s3cret"}, json.UserOnlyFilePerms())) + + dir, err := os.Stat(filepath.Dir(file)) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), dir.Mode().Perm(), "the configuration directory") + + written, err := os.Stat(file) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), written.Mode().Perm(), "a file holding a credential") +} diff --git a/internal/json/unmarshal.go b/internal/json/unmarshal.go new file mode 100644 index 00000000..34f241b8 --- /dev/null +++ b/internal/json/unmarshal.go @@ -0,0 +1,54 @@ +package json + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "log/slog" + "os" +) + +func Unmarshal(in []byte, out any) error { + return json.Unmarshal(in, out) +} + +func UnmarshalFrom(ctx context.Context, file string, out any, unmarshalers ...*json.Unmarshalers) (err error) { + var f *os.File + //nolint:gosec // G304: internal/config builds every path here from the config dir and validated names + f, err = os.Open(file) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, f.Close()) + if err != nil { + err = fmt.Errorf("cannot unmarshal json from %s: %w", file, err) + } else { + slog.DebugContext(ctx, "Unmarshaled json from "+file) + } + }() + err = json.UnmarshalDecode(jsontext.NewDecoder(f), &out, + json.WithUnmarshalers(json.JoinUnmarshalers(unmarshalers...)), + ) + return +} + +func ModifyAfterUnmarshal[T any](modifier func(target *T)) *json.Unmarshalers { + var decoding bool + return json.UnmarshalFromFunc(func(decoder *jsontext.Decoder, t *T) error { + if decoding { + // The nested decode below dispatches here again, and json.JoinUnmarshalers reads + // errors.ErrUnsupported as "leave this value to the default behavior". + return errors.ErrUnsupported + } + decoding = true + defer func() { decoding = false }() + if err := json.UnmarshalDecode(decoder, t); err != nil { + return err + } + modifier(t) + return nil + }) +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 00000000..a5915eb7 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,97 @@ +package version + +import ( + "cmp" + "encoding" + "errors" + "fmt" + "strconv" + "strings" +) + +var ( + _ encoding.TextUnmarshaler = &Version{} + _ encoding.TextMarshaler = Version{} +) + +type Version struct { + Major, Minor, Patch int + // Extra is everything past the first '-', which a CLI version carries and a meshStack + // version does not: a prerelease, or the commit in a version the go command stamped. + Extra string +} + +const separatorExtra = "-" + +func Parse(s string) (Version, error) { + parts := strings.Split(strings.TrimPrefix(s, "v"), ".") + if len(parts) != 3 { + return Version{}, fmt.Errorf("cannot parse '%s' as version: expected 3, got %d fields separated by '.'", s, len(parts)) + } + patch, extra, _ := strings.Cut(parts[2], separatorExtra) + parts[2] = patch + var errs []error + partTo := func(i int, target *int) { + parsed, err := strconv.Atoi(parts[i]) + if err == nil && parsed < 0 { + err = fmt.Errorf("negative number '%d' not allowed", parsed) + } + if err != nil { + errs = append(errs, fmt.Errorf("part i=%d: %w", i, err)) + } else { + *target = parsed + } + } + var result Version + partTo(0, &result.Major) + partTo(1, &result.Minor) + partTo(2, &result.Patch) + if len(errs) > 0 { + return Version{}, fmt.Errorf("cannot parse '%s' as version: %w", s, errors.Join(errs...)) + } + result.Extra = extra + return result, nil +} + +func MustParse(s string) Version { + version, err := Parse(s) + if err != nil { + panic(err) + } + return version +} + +func (v Version) Compare(other Version) int { + if major := cmp.Compare(v.Major, other.Major); major != 0 { + return major + } else if minor := cmp.Compare(v.Minor, other.Minor); minor != 0 { + return minor + } else if patch := cmp.Compare(v.Patch, other.Patch); patch != 0 { + return patch + } + // Deliberately not semver, which ranks a prerelease below its release: here the version + // carrying an Extra is the greater one, so a build from a tagged commit outranks the tag. + return strings.Compare(v.Extra, other.Extra) +} + +func (v Version) Less(other Version) bool { + return v.Compare(other) < 0 +} + +func (v Version) String() (s string) { + s = fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) + if v.Extra != "" { + s += separatorExtra + v.Extra + } + return +} + +func (v Version) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +//goland:noinspection GoMixedReceiverTypes +func (v *Version) UnmarshalText(text []byte) (err error) { + *v, err = Parse(string(text)) + return +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 00000000..03b08d52 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,121 @@ +package version + +import ( + "encoding/json/v2" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + assertErrorContainsAllOf := func(contains ...string) assert.ErrorAssertionFunc { + return func(t assert.TestingT, err error, msgAndArgs ...any) bool { + assert.NotEmpty(t, contains) + allOk := true + for _, contain := range contains { + ok := assert.ErrorContains(t, err, contain, msgAndArgs...) + allOk = allOk && ok + } + return allOk + } + } + tests := []struct { + name string + s string + want Version + wantErr assert.ErrorAssertionFunc + }{ + {"valid 1.0.0", "1.0.0", Version{Major: 1}, assert.NoError}, + {"valid 1.3.2", "1.3.2", Version{Major: 1, Minor: 3, Patch: 2}, assert.NoError}, + {"leading v", "v1.3.2", Version{Major: 1, Minor: 3, Patch: 2}, assert.NoError}, + {"prerelease", "v1.3.2-rc1", Version{Major: 1, Minor: 3, Patch: 2, Extra: "rc1"}, assert.NoError}, + {"a dot in the extra reads as a fourth field", "v1.3.2-rc.1", Version{}, assertErrorContainsAllOf("cannot parse 'v1.3.2-rc.1' as version: expected 3, got 4 fields separated by '.'")}, + { + "everything past the first dash", "v0.0.0-20260917130613-28674dcbceff+dirty", + Version{Extra: "20260917130613-28674dcbceff+dirty"}, + assert.NoError, + }, + {"not enough parts", "1.1", Version{}, assertErrorContainsAllOf("cannot parse '1.1' as version: expected 3, got 2 fields separated by '.'")}, + {"negative minor", "1.-1.0", Version{}, assertErrorContainsAllOf("cannot parse '1.-1.0' as version: part i=1: negative number '-1' not allowed")}, + {"negative patch empties the patch field", "1.0.-1", Version{}, assertErrorContainsAllOf(`cannot parse '1.0.-1' as version: part i=2: strconv.Atoi: parsing "": invalid syntax`)}, + {"not a number", "1.1.x", Version{}, assertErrorContainsAllOf(`cannot parse '1.1.x' as version: part i=2: strconv.Atoi: parsing "x": invalid syntax`)}, + {"number too large", "100000000000000000000.1.0", Version{}, assertErrorContainsAllOf(`cannot parse '100000000000000000000.1.0' as version: part i=0: strconv.Atoi: parsing "100000000000000000000": value out of range`)}, + {"multiple errors", "y.x.1", Version{}, assertErrorContainsAllOf(`part i=0: strconv.Atoi: parsing "y": invalid syntax`, `part i=1: strconv.Atoi: parsing "x": invalid syntax`)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotV, err := Parse(tt.s) + if !tt.wantErr(t, err, fmt.Sprintf("Parse(%v)", tt.s)) { + return + } + assert.Equalf(t, tt.want, gotV, "Parse(%v)", tt.s) + }) + } +} + +func TestMustParse(t *testing.T) { + assert.NotPanics(t, func() { + MustParse("1.0.0") + }) + assert.Panics(t, func() { + MustParse("1.x.0") + }) +} + +func TestVersion_Compare(t *testing.T) { + tests := []struct { + v, other string + want int + }{ + {"0.0.0", "0.0.0", 0}, + {"0.1.0", "0.1.0", 0}, + {"1.1.0", "0.1.0", 1}, + {"1.1.12312331222", "2.1.0", -1}, + {"1.2.0", "1.3.0", -1}, + {"1.2.1", "1.2.0", 1}, + {"1.2.3", "v1.2.3", 0}, + {"1.2.3", "1.2.3-extra", -1}, + {"1.2.3-b", "1.2.3-a", 1}, + {"1.2.3-extra", "1.2.4", -1}, + } + for _, tt := range tests { + symbol := "==" + if tt.want < 0 { + symbol = "<" + } else if tt.want > 0 { + symbol = ">" + } + t.Run(fmt.Sprintf("%s %s %s", tt.v, symbol, tt.other), func(t *testing.T) { + v, err := Parse(tt.v) + require.NoError(t, err) + other, err := Parse(tt.other) + require.NoError(t, err) + cmp := v.Compare(other) + assert.Equal(t, tt.want, cmp) + if cmp < 0 { + assert.True(t, v.Less(other)) + } else { + assert.False(t, v.Less(other)) + } + }) + } +} + +func TestVersion_JsonIsTheQuotedVersionString(t *testing.T) { + version := MustParse("1.2.3-rc1") + + encoded, err := json.Marshal(version) + require.NoError(t, err) + assert.Equal(t, `"1.2.3-rc1"`, string(encoded)) + + var decoded Version + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, version, decoded) +} + +func TestVersion_String(t *testing.T) { + assert.Equal(t, "1.2.3", Version{Major: 1, Minor: 2, Patch: 3}.String()) + assert.Equal(t, "1.2.3-rc.1", Version{Major: 1, Minor: 2, Patch: 3, Extra: "rc.1"}.String()) +} diff --git a/meshstack-satellite.gradle b/meshstack-satellite.gradle new file mode 100644 index 00000000..6cf568a1 --- /dev/null +++ b/meshstack-satellite.gradle @@ -0,0 +1,14 @@ +plugins { + id 'io.meshcloud.meshstack.go-satellite' +} + +satellite { + acceptance { + run = 'TestAcc' + environment MESHSTACK_CLI_TEST_ACC: '1', + MESHSTACK_CLI_NO_BROWSER: '1', + MESHSTACK_API_KEY: fromBackendConfig('auth.openid.apiKeys.terraform-provider-acceptance.clientId'), + MESHSTACK_API_SECRET: fromBackendConfig('auth.openid.apiKeys.terraform-provider-acceptance.clientSecret'), + MESHSTACK_CLI_TEST_USERS: fromBackendConfig('auth.openid.users') + } +} From ddb57eec8df350b5ff21f8151cd2c391d3a8aaee Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 18 Sep 2026 21:28:22 +0200 Subject: [PATCH 209/215] feat: resolve a meshStack session from settings, profiles and credentials Both front ends need the same answer to one question: which endpoint, which workspace and which token does this invocation use. A session resolves that once, from the environment, a stored profile and whatever the front end itself offers, and hands out a client that already carries the result. Each setting is declared once, in the package that owns it, so a message naming an environment variable is written where the variable is defined. A credential is resolved by name. Two sources that carry different values for the same setting raise an error that names both, rather than one quietly winning. A minted token is cached in the config directory under a file lock, so concurrent runs share one token instead of each asking for its own, and the browser login refreshes before expiry rather than sending the person back to keycloak. CU-86cb61rzz Co-Authored-By: Claude Opus 5 (1M context) --- internal/auth/auth.go | 48 +++ internal/auth/credential/apikey.go | 62 ++++ internal/auth/credential/cache.go | 58 ++++ internal/auth/credential/credential.go | 65 ++++ internal/auth/credential/identity.go | 27 ++ internal/auth/credential/manual.go | 29 ++ internal/auth/credential/name.go | 51 +++ internal/auth/credential/oidclogin.go | 112 +++++++ internal/auth/credential/oidclogin_test.go | 36 +++ internal/auth/credential_apikey.go | 58 ++++ internal/auth/credential_manual.go | 38 +++ internal/auth/credential_oidclogin.go | 37 +++ internal/auth/credentials.go | 85 +++++ internal/auth/session.go | 193 +++++++++++ internal/auth/session_stress_test.go | 255 +++++++++++++++ internal/auth/session_test.go | 157 +++++++++ internal/config/directory.go | 65 ++++ internal/io/stderr.go | 21 ++ internal/lock/lock.go | 140 ++++++++ internal/lock/lock_test.go | 302 ++++++++++++++++++ internal/lock/readonly_other.go | 12 + internal/lock/readonly_windows.go | 17 + internal/meshstack/settings.go | 32 ++ internal/meshstack/workspace.go | 29 ++ internal/meshstack/workspaces.go | 83 +++++ internal/oidc/authcode.go | 63 ++++ internal/oidc/browser/browser.go | 142 ++++++++ internal/oidc/browser/callback.go | 22 ++ internal/oidc/browser/callback.html | 8 + internal/oidc/browser/open_darwin.go | 11 + internal/oidc/browser/open_linux.go | 13 + internal/oidc/browser/open_windows.go | 13 + internal/oidc/client.go | 121 +++++++ internal/oidc/jwt/claim.go | 61 ++++ internal/oidc/jwt/jwt.go | 46 +++ internal/oidc/jwt/jwt_test.go | 82 +++++ .../oidc/jwt/testdata/jwt_not_base64.json | 1 + internal/oidc/jwt/testdata/jwt_not_json.json | 1 + internal/oidc/jwt/testdata/jwt_opaque.json | 1 + internal/oidc/jwt/testdata/jwt_scoped.json | 1 + internal/oidc/jwt/testdata/jwt_unscoped.json | 1 + .../jwt/testdata/jwt_unscoped_no_exp.json | 1 + internal/oidc/scope/scope.go | 26 ++ internal/profile/credentials.go | 193 +++++++++++ internal/profile/name.go | 59 ++++ internal/profile/profile.go | 58 ++++ internal/profile/profiles.go | 86 +++++ internal/profile/resolve.go | 88 +++++ internal/profile/resolve_test.go | 195 +++++++++++ .../profile/testdata/configdir/.gitignore | 2 + .../credentials-cache/dev-local/apiKey.json | 7 + .../configdir/credentials/dev-local.json | 8 + .../profile/testdata/configdir/profiles.json | 14 + internal/profile/testdata/jwt.json | 1 + internal/setting/env.go | 24 ++ internal/setting/resolve.go | 75 +++++ internal/setting/resolve_test.go | 120 +++++++ internal/setting/setting.go | 64 ++++ internal/setting/setting_test.go | 30 ++ internal/setting/setting_test/setting.go | 20 ++ internal/setting/source.go | 66 ++++ internal/testutil/jsontest/jsontest.go | 16 + internal/testutil/testserver/testserver.go | 214 +++++++++++++ pkg/auth/method.go | 17 + pkg/auth/session.go | 63 ++++ pkg/io/stderr.go | 14 + pkg/profile/profile.go | 25 ++ pkg/setting/setting.go | 26 ++ pkg/setting/source.go | 53 +++ 69 files changed, 4164 insertions(+) create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/credential/apikey.go create mode 100644 internal/auth/credential/cache.go create mode 100644 internal/auth/credential/credential.go create mode 100644 internal/auth/credential/identity.go create mode 100644 internal/auth/credential/manual.go create mode 100644 internal/auth/credential/name.go create mode 100644 internal/auth/credential/oidclogin.go create mode 100644 internal/auth/credential/oidclogin_test.go create mode 100644 internal/auth/credential_apikey.go create mode 100644 internal/auth/credential_manual.go create mode 100644 internal/auth/credential_oidclogin.go create mode 100644 internal/auth/credentials.go create mode 100644 internal/auth/session.go create mode 100644 internal/auth/session_stress_test.go create mode 100644 internal/auth/session_test.go create mode 100644 internal/config/directory.go create mode 100644 internal/io/stderr.go create mode 100644 internal/lock/lock.go create mode 100644 internal/lock/lock_test.go create mode 100644 internal/lock/readonly_other.go create mode 100644 internal/lock/readonly_windows.go create mode 100644 internal/meshstack/settings.go create mode 100644 internal/meshstack/workspace.go create mode 100644 internal/meshstack/workspaces.go create mode 100644 internal/oidc/authcode.go create mode 100644 internal/oidc/browser/browser.go create mode 100644 internal/oidc/browser/callback.go create mode 100644 internal/oidc/browser/callback.html create mode 100644 internal/oidc/browser/open_darwin.go create mode 100644 internal/oidc/browser/open_linux.go create mode 100644 internal/oidc/browser/open_windows.go create mode 100644 internal/oidc/client.go create mode 100644 internal/oidc/jwt/claim.go create mode 100644 internal/oidc/jwt/jwt.go create mode 100644 internal/oidc/jwt/jwt_test.go create mode 100644 internal/oidc/jwt/testdata/jwt_not_base64.json create mode 100644 internal/oidc/jwt/testdata/jwt_not_json.json create mode 100644 internal/oidc/jwt/testdata/jwt_opaque.json create mode 100644 internal/oidc/jwt/testdata/jwt_scoped.json create mode 100644 internal/oidc/jwt/testdata/jwt_unscoped.json create mode 100644 internal/oidc/jwt/testdata/jwt_unscoped_no_exp.json create mode 100644 internal/oidc/scope/scope.go create mode 100644 internal/profile/credentials.go create mode 100644 internal/profile/name.go create mode 100644 internal/profile/profile.go create mode 100644 internal/profile/profiles.go create mode 100644 internal/profile/resolve.go create mode 100644 internal/profile/resolve_test.go create mode 100644 internal/profile/testdata/configdir/.gitignore create mode 100644 internal/profile/testdata/configdir/credentials-cache/dev-local/apiKey.json create mode 100644 internal/profile/testdata/configdir/credentials/dev-local.json create mode 100644 internal/profile/testdata/configdir/profiles.json create mode 100644 internal/profile/testdata/jwt.json create mode 100644 internal/setting/env.go create mode 100644 internal/setting/resolve.go create mode 100644 internal/setting/resolve_test.go create mode 100644 internal/setting/setting.go create mode 100644 internal/setting/setting_test.go create mode 100644 internal/setting/setting_test/setting.go create mode 100644 internal/setting/source.go create mode 100644 internal/testutil/jsontest/jsontest.go create mode 100644 internal/testutil/testserver/testserver.go create mode 100644 pkg/auth/method.go create mode 100644 pkg/auth/session.go create mode 100644 pkg/io/stderr.go create mode 100644 pkg/profile/profile.go create mode 100644 pkg/setting/setting.go create mode 100644 pkg/setting/source.go diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 00000000..f337c26e --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,48 @@ +package auth + +import ( + "context" + "time" + + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" +) + +var _ http.Authorization = Session{} + +func (s Session) GetBearerToken(ctx context.Context) (out http.BearerToken, err error) { + return s.RefreshBearerToken(ctx, "") +} + +func (s Session) RefreshBearerToken(ctx context.Context, rejected http.BearerToken) (out http.BearerToken, err error) { + usable := func() bool { + token, found := s.Credential.CachedToken(ctx, s.getWorkspace) + if !found || token.GetClaim(jwt.ExpiryClaim).Expired(30*time.Second) || token.String() == string(rejected) { + return false + } + out = http.BearerToken(token.String()) + return true + } + + var foundCached bool + err = s.Credentials.ReadCache(ctx, s.Credential, func() error { + foundCached = usable() + return nil + }) + if err != nil || foundCached { + return + } + err = s.Credentials.ModifyCache(ctx, s.Credential, func() error { + if usable() { + return nil + } + if err := s.Credential.RefreshCachedToken(ctx, s.httpClient, s.getWorkspace); err != nil { + return err + } + // RefreshCachedToken guarantees a cached token, so found is always true here. + token, _ := s.Credential.CachedToken(ctx, s.getWorkspace) + out = http.BearerToken(token.String()) + return nil + }) + return +} diff --git a/internal/auth/credential/apikey.go b/internal/auth/credential/apikey.go new file mode 100644 index 00000000..bafc6f98 --- /dev/null +++ b/internal/auth/credential/apikey.go @@ -0,0 +1,62 @@ +package credential + +import ( + "context" + "errors" + "fmt" + "uuid" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" +) + +var _ Credential = &ApiKey{} + +type ApiKey struct { + Endpoint xurl.URL + ClientId uuid.UUID `json:"client_id"` + ClientSecret string `json:"client_secret"` + + Cache *struct { + Token jwt.JWT `json:"token,omitzero"` + } `json:"-"` +} + +func (apiKey *ApiKey) Identity() Identity { + return identityOf(apiKey) +} + +func (apiKey *ApiKey) CachedToken(_ context.Context, _ getWorkspaceFunc) (token jwt.JWT, found bool) { + if apiKey.Cache == nil { + return + } + return apiKey.Cache.Token, true +} + +func (apiKey *ApiKey) RefreshCachedToken(ctx context.Context, client http.Client, _ getWorkspaceFunc) error { + loginEndpoint := apiKey.Endpoint.JoinPath("api", "login") + + payload := struct { + ClientId uuid.UUID `json:"clientId"` + ClientSecret string `json:"clientSecret"` + }{apiKey.ClientId, apiKey.ClientSecret} + + answer, err := client.DoRequest[struct { + AccessToken jwt.JWT `json:"access_token"` + }](ctx, http.MethodPost, loginEndpoint, + http.Retryable(), + http.WithJsonPayload(payload, "application/json"), + ) + + if httpError, ok := errors.AsType[http.Error](err); ok && httpError.IsUnauthorized() { + return fmt.Errorf("api key %s at %s refused: %w; check the secret, or issue a new key in meshPanel", apiKey.ClientId, loginEndpoint, err) + } else if err != nil { + return fmt.Errorf("cannot login with api key %s at %s: %w", apiKey.ClientId, loginEndpoint, err) + } + if apiKey.Cache == nil { + newCache(apiKey) + } + apiKey.Cache.Token = answer.AccessToken + return nil +} diff --git a/internal/auth/credential/cache.go b/internal/auth/credential/cache.go new file mode 100644 index 00000000..8975e18c --- /dev/null +++ b/internal/auth/credential/cache.go @@ -0,0 +1,58 @@ +package credential + +import ( + "fmt" + "reflect" +) + +// WithCacheOf reaches the optional Cache field of a credential struct, such as ApiKey, and holds +// the struct to what identityOf needs to copy it: Cache is a json-ignored pointer, and no other +// field is a pointer at all. +func WithCacheOf(credential Credential, action func(cache reflect.Value)) (ok bool) { + for field, v := range reflect.ValueOf(credential).Elem().Fields() { + if field.Name == "Cache" { + if v.Kind() != reflect.Pointer { + panic(fmt.Sprintf("credential Cache field must be a pointer in %T", credential)) + } + if getJsonKey(field) != "-" { + panic(fmt.Sprintf("credential Cache field must be json-ignored with '-' in %T", credential)) + } + action(v) + ok = true + } else if v.Kind() == reflect.Pointer { + // A second pointer would be shared rather than copied by identityOf. + panic(fmt.Sprintf("credential field %s must NOT be a pointer in %T", field.Name, credential)) + } + } + return +} + +// newCache allocates the Cache field of a credential struct, whose type is anonymous and so +// cannot be written at a call site. Call it only once there is something to cache: a nil Cache +// is what tells CachedToken that nothing is cached. +func newCache(credential Credential) { + WithCacheOf(credential, func(cache reflect.Value) { + cache.Set(reflect.New(cache.Type().Elem())) + }) +} + +func adoptCacheIfIdentityMatches(fromValue reflect.Value, to Credential) { + if fromValue.IsNil() { + return + } + from := fromValue.Interface().(Credential) //nolint:forcetypeassert // the field comes from Credentials, whose pointer fields are all Credential + if identityOf(from).Hash != identityOf(to).Hash { + return + } + WithCacheOf(from, func(fromCache reflect.Value) { + WithCacheOf(to, func(toCache reflect.Value) { + toCache.Set(fromCache) + }) + }) +} + +func clearCache(credential Credential) { + WithCacheOf(credential, func(cache reflect.Value) { + cache.Set(reflect.Zero(cache.Type())) + }) +} diff --git a/internal/auth/credential/credential.go b/internal/auth/credential/credential.go new file mode 100644 index 00000000..1b9a2576 --- /dev/null +++ b/internal/auth/credential/credential.go @@ -0,0 +1,65 @@ +package credential + +import ( + "context" + "fmt" + "iter" + "reflect" + "strings" + + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" +) + +type Credential interface { + // Identity identifies the credential, see Credentials.SetIdentity. + Identity() Identity + CachedToken(ctx context.Context, getWorkspace getWorkspaceFunc) (token jwt.JWT, found bool) + // RefreshCachedToken re-mints the token, so a CachedToken call after it finds one. + RefreshCachedToken(ctx context.Context, client http.Client, getWorkspace getWorkspaceFunc) error +} + +type getWorkspaceFunc func() (meshstack.Workspace, error) + +type Credentials struct { + ApiKey *ApiKey `json:"apiKey,omitempty"` + Manual *Manual `json:"manual,omitempty"` + OidcLogin *OidcLogin `json:"oidcLogin,omitempty"` +} + +func (cs *Credentials) SetIdentity(cred Credential) { + cs.withFieldFor(cred, func(_ Name, storedValue reflect.Value) { + adoptCacheIfIdentityMatches(storedValue, cred) + storedValue.Set(reflect.ValueOf(cred)) + }) +} + +func (cs *Credentials) withFieldFor(credential Credential, action func(name Name, credValue reflect.Value)) { + target := reflect.TypeOf(credential) + for name, field := range cs.fields() { + if field.Type() == target { + action(name, field) + return + } + } + panic(fmt.Sprintf("credential %T is not a field of %T", credential, *cs)) +} + +func (cs *Credentials) fields() iter.Seq2[Name, reflect.Value] { + return func(yield func(Name, reflect.Value) bool) { + for field, value := range reflect.ValueOf(cs).Elem().Fields() { + if value.Kind() != reflect.Pointer { + continue + } + if !yield(Name(getJsonKey(field)), value) { + return + } + } + } +} + +func getJsonKey(field reflect.StructField) (jsonKey string) { + jsonKey, _, _ = strings.Cut(field.Tag.Get("json"), ",") + return +} diff --git a/internal/auth/credential/identity.go b/internal/auth/credential/identity.go new file mode 100644 index 00000000..b3eddb29 --- /dev/null +++ b/internal/auth/credential/identity.go @@ -0,0 +1,27 @@ +package credential + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json/v2" + "fmt" + "reflect" +) + +type Identity struct { + Hash string +} + +// identityOf hashes the credential with its cache cleared, over a copy of the struct. Only Cache +// may be a pointer, which WithCacheOf asserts, so the copy shares nothing with the original. +func identityOf(credential Credential) Identity { + identity := reflect.New(reflect.ValueOf(credential).Elem().Type()) + identity.Elem().Set(reflect.ValueOf(credential).Elem()) + clearCache(identity.Interface().(Credential)) //nolint:forcetypeassert // identity is a fresh copy of a Credential, so it is one + marshaled, err := json.Marshal(identity.Interface()) + if err != nil { + panic(fmt.Sprintf("cannot hash the identity of %T: %s", credential, err.Error())) + } + sum := sha256.Sum256(marshaled) + return Identity{Hash: hex.EncodeToString(sum[:])} +} diff --git a/internal/auth/credential/manual.go b/internal/auth/credential/manual.go new file mode 100644 index 00000000..89828d3c --- /dev/null +++ b/internal/auth/credential/manual.go @@ -0,0 +1,29 @@ +package credential + +import ( + "context" + "fmt" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" +) + +var _ Credential = &Manual{} + +type Manual struct { + Endpoint xurl.URL `json:"endpoint"` + Token jwt.JWT `json:"token"` +} + +func (manual *Manual) Identity() Identity { + return identityOf(manual) +} + +func (manual *Manual) CachedToken(_ context.Context, _ getWorkspaceFunc) (jwt.JWT, bool) { + return manual.Token, manual.Token.String() != "" +} + +func (manual *Manual) RefreshCachedToken(_ context.Context, _ http.Client, _ getWorkspaceFunc) error { + return fmt.Errorf("manual method cannot be refreshed; provide new with 'meshstack login --endpoint %s --api-token [--stdin]'", manual.Endpoint) +} diff --git a/internal/auth/credential/name.go b/internal/auth/credential/name.go new file mode 100644 index 00000000..0e11919b --- /dev/null +++ b/internal/auth/credential/name.go @@ -0,0 +1,51 @@ +package credential + +import ( + "fmt" + "maps" + "reflect" + "slices" +) + +type Name string + +func (n Name) String() string { + return string(n) +} + +const ( + // ApiKeyName mints a token from an API key id and secret. + ApiKeyName Name = "apiKey" + // ManualName sends an access token as it is. + ManualName Name = "manual" + // OidcLoginName logs a person in through a browser, so it resolves only when asked for by name. + OidcLoginName Name = "oidcLogin" +) + +// Names lists every credential, in the order a resolution tries and reports them. +var Names = []Name{ApiKeyName, ManualName, OidcLoginName} + +// A name that no field of Credentials carries resolves to nothing and stores to nowhere, without +// saying so, which is why the two lists are checked against each other at startup. +func init() { + fields := slices.Sorted(maps.Keys(maps.Collect((&Credentials{}).fields()))) + if !slices.Equal(fields, slices.Sorted(slices.Values(Names))) { + panic(fmt.Sprintf("credential names %v do not match the fields of Credentials %v", Names, fields)) + } +} + +func (cs *Credentials) NameOf(credential Credential) (out Name) { + cs.withFieldFor(credential, func(name Name, _ reflect.Value) { + out = name + }) + return +} + +func (cs *Credentials) ByName(name Name) Credential { + for candidate, field := range cs.fields() { + if candidate == name && !field.IsNil() { + return field.Interface().(Credential) //nolint:forcetypeassert // a pointer field that is not a Credential is a mistake in the struct above + } + } + return nil +} diff --git a/internal/auth/credential/oidclogin.go b/internal/auth/credential/oidclogin.go new file mode 100644 index 00000000..1603a7e6 --- /dev/null +++ b/internal/auth/credential/oidclogin.go @@ -0,0 +1,112 @@ +package credential + +import ( + "context" + "fmt" + "log/slog" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/oidc" + "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" + "github.com/meshcloud/meshstack-cli/internal/oidc/scope" +) + +var _ Credential = &OidcLogin{} + +type OidcLogin struct { + Endpoint xurl.URL `json:"endpoint"` + Issuer xurl.URL `json:"issuer"` + ClientId string `json:"clientId"` + Cache *struct { + RefreshToken string `json:"refreshToken"` + ScopedTokens map[scope.Scope]jwt.JWT `json:"tokens,omitzero"` + } `json:"-"` +} + +func (oidcLogin *OidcLogin) Identity() Identity { + return identityOf(oidcLogin) +} + +func (oidcLogin *OidcLogin) StoreLogin(refreshToken string, token jwt.JWT) { + if oidcLogin.Cache == nil { + newCache(oidcLogin) + } + oidcLogin.Cache.RefreshToken = refreshToken + if oidcLogin.Cache.ScopedTokens == nil { + oidcLogin.Cache.ScopedTokens = map[scope.Scope]jwt.JWT{} + } + // An initial login carries no workspace claim, and neither does a token keycloak minted for a + // workspace it refused, so both are stored as the unscoped token they are. + oidcLogin.Cache.ScopedTokens[tokenCacheKey(token.GetClaim(jwt.WorkspaceClaim))] = token +} + +func (oidcLogin *OidcLogin) CachedToken(ctx context.Context, getWorkspace getWorkspaceFunc) (token jwt.JWT, found bool) { + if oidcLogin.Cache == nil { + return + } + if workspace, err := getWorkspace(); err != nil { + // Debug, not Warn: RefreshCachedToken hits the same error and returns it to the caller. + slog.DebugContext(ctx, fmt.Sprintf("Cannot obtain cached token for %s at %s without workspace: %s", OidcLoginName, oidcLogin.Endpoint, err.Error())) + return + } else { + token, found = oidcLogin.Cache.ScopedTokens[tokenCacheKey(workspace)] + } + return +} + +func (oidcLogin *OidcLogin) RefreshCachedToken(ctx context.Context, client http.Client, getWorkspace getWorkspaceFunc) error { + if oidcLogin.Cache == nil || oidcLogin.Cache.RefreshToken == "" { + return fmt.Errorf("no refresh token available for %T; run 'meshstack login --endpoint %s'", oidcLogin, oidcLogin.Endpoint) + } + workspace, err := getWorkspace() + if err != nil { + // TODO profile default workspace can't set otherwise as long as 'meshstack profile edit' is missing (there's no profile CRUD in CLI at all right now) + return fmt.Errorf("a workspace is required for %T; configure one or run 'meshstack login --endpoint %s' and pick one as profile default: %w", oidcLogin, oidcLogin.Endpoint, err) + } + oidcClient, err := oidc.NewClient(ctx, client, oidcLogin.Issuer, oidcLogin.ClientId) + if err != nil { + return err + } + oidcToken, err := oidcClient.Refresh(ctx, oidcLogin.Cache.RefreshToken, scopesFor(workspace)) + if err != nil { + return err + } + // Stored before the check below, so that the rotated refresh token is kept even when the + // workspace turns out to be wrong. Keycloak ends the whole session when a session replays + // a refresh token it has already rotated away. + oidcLogin.StoreLogin(oidcToken.RefreshToken, oidcToken.AccessToken) + if workspaceFromToken := oidcToken.AccessToken.GetClaim(jwt.WorkspaceClaim); workspace != meshstack.NoWorkspace && workspaceFromToken != workspace { + return fmt.Errorf("no access to workspace '%s': %s minted a token for workspace '%s' instead; check the workspace identifier, or ask for access to it in meshPanel", + workspace, oidcLogin.Issuer, workspaceFromToken) + } + return nil +} + +// workspaceScope binds a token to one workspace. Keycloak's mapper strips this prefix again before +// it writes the identifier into the claim, see jwt.WorkspaceClaim. +func workspaceScope(workspace meshstack.Workspace) scope.Scope { + return "c:" + scope.Scope(workspace) +} + +// tokenCacheKey is what a token is stored under in OidcLogin.Cache.ScopedTokens, so these values +// are on disk and cannot change freely. +func tokenCacheKey(workspace meshstack.Workspace) scope.Scope { + if workspace == meshstack.NoWorkspace { + return "unscoped" + } + return workspaceScope(workspace) +} + +// scopesFor asks for a token bound to one workspace, or for one bound to none. Verified against a +// live keycloak: the script mapper in ../meshfed-release/keycloak/container/MC_CUSTOMER.js ignores +// the default scopes openid, profile and email and falls back to the workspace in a keycloak +// session note, while any other non-c: scope clears that note. Asking for no workspace therefore +// names offline_access, where openid alone would inherit the previous workspace. +func scopesFor(workspace meshstack.Workspace) scope.Scopes { + if workspace == meshstack.NoWorkspace { + return scope.Scopes{scope.OpenId, scope.OfflineAccess} + } + return scope.Scopes{scope.OpenId, workspaceScope(workspace)} +} diff --git a/internal/auth/credential/oidclogin_test.go b/internal/auth/credential/oidclogin_test.go new file mode 100644 index 00000000..c03c2049 --- /dev/null +++ b/internal/auth/credential/oidclogin_test.go @@ -0,0 +1,36 @@ +package credential + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/meshcloud/meshstack-cli/internal/meshstack" +) + +func TestTheWorkspaceDecidesTheCacheKeyAndTheScopesAsked(t *testing.T) { + for _, test := range []struct { + name string + workspace meshstack.Workspace + cacheKey string + scopes string + }{ + { + name: "a workspace", + workspace: "demo-partner", + cacheKey: "c:demo-partner", + scopes: "openid c:demo-partner", + }, + { + name: "no workspace", + workspace: meshstack.NoWorkspace, + cacheKey: "unscoped", + scopes: "openid offline_access", + }, + } { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.cacheKey, string(tokenCacheKey(test.workspace))) + assert.Equal(t, test.scopes, scopesFor(test.workspace).String()) + }) + } +} diff --git a/internal/auth/credential_apikey.go b/internal/auth/credential_apikey.go new file mode 100644 index 00000000..e37ad42d --- /dev/null +++ b/internal/auth/credential_apikey.go @@ -0,0 +1,58 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "uuid" + + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +var ApiKeyClientIdSetting = setting.Setting[uuid.UUID]{ + Env: "MESHSTACK_API_KEY", + Short: func(envKey string) string { + return fmt.Sprintf("The client id of a meshStack API key, which mints tokens together with its secret. Also read from %s.", envKey) + }, + Long: func(envKey string) string { + return fmt.Sprintf("The client id of a meshStack API key, which mints tokens together with its secret, also read from `%s`.", envKey) + }, + Parse: setting.ParseTextUnmarshaler[uuid.UUID], +} + +var ApiKeyClientSecretSetting = setting.Setting[string]{ + Env: "MESHSTACK_API_SECRET", + Short: func(envKey string) string { + return fmt.Sprintf("The client secret belonging to the API key. Also read from %s.", envKey) + }, + Long: func(envKey string) string { + return fmt.Sprintf("The client secret belonging to the API key, also read from `%s`.", envKey) + }, + Parse: setting.ParseText[string], +} + +func (s Session) resolveApiKeyCredential(ctx context.Context, opts ResolveSessionOptions) (credential.Credential, error) { + apiKeyClientId, idErr := opts.ResolveSetting(ctx, ApiKeyClientIdSetting) + apiKeyClientSecret, secretErr := opts.ResolveSetting(ctx, ApiKeyClientSecretSetting) + idMissing := errors.Is(idErr, setting.ErrNoSourceProvidedValue) + secretMissing := errors.Is(secretErr, setting.ErrNoSourceProvidedValue) + switch { + case idMissing && secretMissing: + return nil, errors.Join(idErr, secretErr) + case idMissing || secretMissing: + // Error(), not %w: the sentinel in the chain reads as "no API key was mentioned" and is skipped. + return nil, errors.New("an API key needs " + ApiKeyClientIdSetting.EnvKey() + " and " + + ApiKeyClientSecretSetting.EnvKey() + " together: " + errors.Join(idErr, secretErr).Error()) + } + if err := errors.Join(idErr, secretErr); err != nil { + return nil, err + } + slog.DebugContext(ctx, fmt.Sprintf("Using api key credentials (client id %s with %d bytes long secret)", apiKeyClientId, len(apiKeyClientSecret))) + return &credential.ApiKey{ + Endpoint: s.Endpoint, + ClientId: apiKeyClientId, + ClientSecret: apiKeyClientSecret, + }, nil +} diff --git a/internal/auth/credential_manual.go b/internal/auth/credential_manual.go new file mode 100644 index 00000000..21b4cb8e --- /dev/null +++ b/internal/auth/credential_manual.go @@ -0,0 +1,38 @@ +package auth + +import ( + "context" + "fmt" + "log/slog" + + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +var ApiTokenSetting = setting.Setting[jwt.JWT]{ + Env: "MESHSTACK_API_TOKEN", + Short: func(envKey string) string { + return fmt.Sprintf("A meshStack access token to send as it is. Also read from %s.", envKey) + }, + Long: func(envKey string) string { + return fmt.Sprintf("A meshStack access token to send as it is, also read from `%s`.\n\n"+ + "The token is minted elsewhere, so nothing renews it and nothing asks for a workspace: it "+ + "already carries the one it was minted for. A building block runner's token is the usual "+ + "one to bring here, to see what a run sees, and such a token may not be allowed to list "+ + "workspaces at all.", envKey) + }, + Parse: setting.ParseTextUnmarshaler[jwt.JWT], +} + +func (s Session) resolveManualCredential(ctx context.Context, opts ResolveSessionOptions) (credential.Credential, error) { + apiToken, apiTokenErr := opts.ResolveSetting(ctx, ApiTokenSetting) + if apiTokenErr != nil { + return nil, apiTokenErr + } + slog.DebugContext(ctx, fmt.Sprintf("Using setting %s as manual credential", ApiTokenSetting.EnvKey())) + return &credential.Manual{ + Endpoint: s.Endpoint, + Token: apiToken, + }, nil +} diff --git a/internal/auth/credential_oidclogin.go b/internal/auth/credential_oidclogin.go new file mode 100644 index 00000000..5e9c9fe2 --- /dev/null +++ b/internal/auth/credential_oidclogin.go @@ -0,0 +1,37 @@ +package auth + +import ( + "context" + "fmt" + "log/slog" + + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/oidc" + "github.com/meshcloud/meshstack-cli/internal/oidc/browser" +) + +// resolveOidcLoginCredential logs a person in through a browser, which is why +// Session.credentialResolvers offers it only to a caller that named it. +func (s Session) resolveOidcLoginCredential(ctx context.Context, _ ResolveSessionOptions) (credential.Credential, error) { + meshInfo, err := s.checkedMeshInfo() + if err != nil { + return nil, err + } + oidcClient, err := oidc.NewClient(ctx, s.httpClient, meshInfo.Issuer, meshInfo.CliClientId) + if err != nil { + return nil, err + } + token, err := browser.Login(ctx, oidcClient) + if err != nil { + return nil, err + } + slog.DebugContext(ctx, fmt.Sprintf("Logged in at %s through a browser", oidcClient.Issuer)) + oidcLogin := &credential.OidcLogin{ + Endpoint: s.Endpoint, + Issuer: oidcClient.Issuer, + ClientId: oidcClient.Id, + } + oidcLogin.StoreLogin(token.RefreshToken, token.AccessToken) + + return oidcLogin, nil +} diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go new file mode 100644 index 00000000..be229989 --- /dev/null +++ b/internal/auth/credentials.go @@ -0,0 +1,85 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/profile" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +type credentialResolver func(context.Context, ResolveSessionOptions) (credential.Credential, error) + +func (s Session) resolveCredentials(ctx context.Context, currentProfile *profile.Profile, opts ResolveSessionOptions) (profile.Credentials, credential.Credential, error) { + creds, err := currentProfile.Credentials(ctx) + if err != nil { + return profile.Credentials{}, nil, err + } + + resolvers := map[credential.Name]credentialResolver{ + credential.ApiKeyName: s.resolveApiKeyCredential, + credential.ManualName: s.resolveManualCredential, + credential.OidcLoginName: s.resolveOidcLoginCredential, + } + + if forced := opts.ForceAuthWith; forced != "" { + resolve, found := resolvers[forced] + if !found { + return profile.Credentials{}, nil, fmt.Errorf("cannot authenticate with credential '%s'; pick one of %v", forced, credential.Names) + } + resolved, err := resolve(ctx, opts) + if err != nil { + return profile.Credentials{}, nil, err + } + creds.SetIdentity(resolved) + currentProfile.Credential = forced + slog.DebugContext(ctx, fmt.Sprintf("Using credential %s, which was asked for by name", forced)) + return creds, resolved, nil + } + + var errs, noSourceErrs []error + var resolvedNames []credential.Name + for _, name := range credential.Names { + // A resolver that needs a person — the browser login — is reached only by the forced path + // above: the Terraform provider resolves a session on every plan and must never open a browser. + if name == credential.OidcLoginName { + continue + } + switch resolved, err := resolvers[name](ctx, opts); { + case errors.Is(err, setting.ErrNoSourceProvidedValue): + noSourceErrs = append(noSourceErrs, err) + case err != nil: + errs = append(errs, err) + default: + creds.SetIdentity(resolved) + resolvedNames = append(resolvedNames, name) + } + } + if err := errors.Join(errs...); err != nil { + return profile.Credentials{}, nil, err + } + + switch len(resolvedNames) { + case 0: + if currentProfile.Credential == "" { + return creds, nil, errors.Join(append([]error{ + fmt.Errorf("no credential resolved, and profile '%s' selects none", currentProfile), + }, noSourceErrs...)...) + } + current := creds.ByName(currentProfile.Credential) + if current == nil { + return creds, nil, fmt.Errorf("profile '%s' selects credential '%s', but %s holds none; run 'meshstack login'", currentProfile, currentProfile.Credential, creds.FilePath) + } + slog.DebugContext(ctx, fmt.Sprintf("Using credential %s of profile %s", currentProfile.Credential, currentProfile)) + return creds, current, nil + case 1: + currentProfile.Credential = resolvedNames[0] + slog.DebugContext(ctx, fmt.Sprintf("Using uniquely resolved credential %s from environment MESHSTACK_* and/or explicit config", currentProfile.Credential)) + return creds, creds.ByName(resolvedNames[0]), nil + default: + return creds, nil, fmt.Errorf("resolved more than one credential %v; please check environment MESHSTACK_* and/or explicit config", resolvedNames) + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 00000000..7f7c8693 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,193 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/config" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/profile" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +type Session struct { + ConfigDir config.Directory + Credentials profile.Credentials + Credential credential.Credential + Endpoint xurl.URL + Client func() (client.Client, error) + Store func(ctx context.Context) error + + getWorkspace func() (meshstack.Workspace, error) + httpClient http.Client + checkedMeshInfo func() (client.MeshInfo, error) +} +type ( + SettingSources = setting.Sources + ResolveSessionOptions struct { + SettingSources + + // Version of the calling front end and GitHubRepo, as "/", where its releases live. + // Both are required: together they are the User-Agent, and they name the release to check against. + Version string + GitHubRepo string + + ForceAuthWith credential.Name + } +) + +func ResolveSession(ctx context.Context, opts ResolveSessionOptions) (Session, error) { + currentProfile, profiles, err := profile.ResolveProfile(ctx, profile.ResolveProfileOptions{ + SettingSources: opts.SettingSources, + }) + if err != nil { + return Session{}, err + } + + endpoint, err := opts.ResolveSetting(ctx, meshstack.EndpointSetting, currentProfile.EndpointSource()) + if err != nil { + return Session{}, err + } else if currentProfile.Endpoint != nil && !endpoint.Equal(*currentProfile.Endpoint) { + // this prevents accidentally sending credentials to the wrong endpoint + return Session{}, fmt.Errorf("endpoint from profile '%s' does not match endpoint '%s' configured for session", currentProfile.Endpoint, endpoint) + } + + userAgent, err := opts.userAgent() + if err != nil { + return Session{}, err + } + httpClient := http.NewClient(userAgent) + + session := Session{ + ConfigDir: currentProfile.ConfigDir, + Endpoint: endpoint, + httpClient: httpClient, + // Lazy, because OidcLogin needs /mesh/info before the authenticated client exists. + checkedMeshInfo: sync.OnceValues(func() (client.MeshInfo, error) { + return getAndCheckMeshInfo(ctx, httpClient, endpoint, opts.SettingSources) + }), + } + + session.Credentials, session.Credential, err = session.resolveCredentials(ctx, currentProfile, opts) + if err != nil { + return Session{}, err + } + session.Client = sync.OnceValues(func() (client.Client, error) { + return session.buildClient(ctx, opts) + }) + session.getWorkspace = sync.OnceValues(func() (meshstack.Workspace, error) { + return session.resolveWorkspace(ctx, *currentProfile, opts) + }) + session.Store = func(ctx context.Context) error { + // The credentials go first, and every step runs even after an earlier one failed. + errs := []error{session.Credentials.Store(ctx)} + + switch defaultWorkspace, err := session.getWorkspace(); { + case err == nil: + currentProfile.DefaultWorkspace = defaultWorkspace + case !errors.Is(err, setting.ErrNoSourceProvidedValue): + errs = append(errs, err) + } + + // currentProfile points into the map profiles holds, so the assignment above is what + // profiles.Store writes out. + return errors.Join(append(errs, profiles.Store(ctx))...) + } + return session, nil +} + +func (o ResolveSessionOptions) userAgent() (string, error) { + org, repo, ok := strings.Cut(o.GitHubRepo, "/") + if !ok || org == "" || repo == "" { + return "", fmt.Errorf("GitHub repo '%s' is not of / format", o.GitHubRepo) + } + if o.Version == "" { + return "", fmt.Errorf("no version given for GitHub repo '%s'", o.GitHubRepo) + } + return repo + "/" + o.Version, nil +} + +func (s Session) MeshInfo() (client.MeshInfo, error) { + return s.checkedMeshInfo() +} + +func getAndCheckMeshInfo(ctx context.Context, httpClient http.Client, endpoint xurl.URL, settingSources SettingSources) (client.MeshInfo, error) { + meshInfo, err := client.NewMeshInfoClient(httpClient, endpoint).Read(ctx) + if err != nil { + return client.MeshInfo{}, err + } + if skipVersionCheck, err := settingSources.ResolveSetting(ctx, meshstack.SkipVersionCheckSetting); err != nil { + return client.MeshInfo{}, err + } else if skipVersionCheck { + return meshInfo, nil + } + return meshInfo, meshInfo.CheckVersion() +} + +func (s Session) buildClient(ctx context.Context, opts ResolveSessionOptions) (client.Client, error) { + // Resolved here rather than while minting a token, which happens under the cache lock. + // A session that names no workspace still builds a client: an api key or a manual token + // needs none, and a credential that does need one says so when it mints. + workspace, workspaceErr := s.getWorkspace() + if workspaceErr != nil && !errors.Is(workspaceErr, setting.ErrNoSourceProvidedValue) { + return client.Client{}, workspaceErr + } + slog.DebugContext(ctx, fmt.Sprintf("Building client for endpoint %s with user agent %s authenticated by %T, workspace %s", + s.Endpoint, s.httpClient.UserAgent, s.Credential, workspace)) + c := client.New(ctx, s.Endpoint, s.httpClient.UserAgent, s) + if skipVersionCheck, err := opts.ResolveSetting(ctx, meshstack.SkipVersionCheckSetting); err != nil { + return client.Client{}, err + } else if skipVersionCheck { + // Skipping the check leaves resolution and this method without a single backend call, + // so the Terraform provider does not block on an unreachable meshStack. + return c, nil + } + if _, err := s.checkedMeshInfo(); err != nil { + return client.Client{}, err + } + return c, nil +} + +func (s Session) resolveWorkspace(ctx context.Context, currentProfile profile.Profile, opts ResolveSessionOptions) (meshstack.Workspace, error) { + // A source that resolves the workspace usually wants the list to pick from, so the context + // carries a lazy fetch of it. That fetch lists through a session naming no workspace, which + // is what keeps it from asking back for the workspace being resolved; see withNoWorkspace. + ctxWithWorkspaces := meshstack.SetWorkspacesInContext(ctx, sync.OnceValues(func() (r meshstack.Workspaces, err error) { + var c client.Client + c, err = s.withNoWorkspace(ctx, opts).Client() + if err != nil { + return r, err + } + r.ProfileDefaultWorkspace = currentProfile.DefaultWorkspace + r.Items, err = c.Workspace.List(ctx) + if httpError, ok := errors.AsType[http.Error](err); ok && httpError.IsForbidden() { + err = fmt.Errorf("cannot list workspaces; try logging into meshPanel UI first, got: %w", httpError) + } else if err == nil && len(r.Items) == 0 { + err = errors.New("no workspaces found; try logging into meshPanel UI first and/or become member of a workspace") + } + return + })) + return opts.ResolveSetting(ctxWithWorkspaces, meshstack.WorkspaceSetting, currentProfile.WorkspaceSource()) +} + +// withNoWorkspace is the session the workspace resolution itself can use: it names no workspace, so +// nothing done through it can ask back for the workspace being resolved. It needs a Client of its +// own, because Session.Client is memoized and would hand back one authorized by that very workspace. +func (s Session) withNoWorkspace(ctx context.Context, opts ResolveSessionOptions) Session { + unscoped := s + unscoped.getWorkspace = func() (meshstack.Workspace, error) { + return meshstack.NoWorkspace, nil + } + unscoped.Client = sync.OnceValues(func() (client.Client, error) { + return unscoped.buildClient(ctx, opts) + }) + return unscoped +} diff --git a/internal/auth/session_stress_test.go b/internal/auth/session_stress_test.go new file mode 100644 index 00000000..9ab1073a --- /dev/null +++ b/internal/auth/session_stress_test.go @@ -0,0 +1,255 @@ +package auth_test + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/auth" + "github.com/meshcloud/meshstack-cli/internal/setting" + "github.com/meshcloud/meshstack-cli/internal/testutil/testserver" +) + +const ( + workersPerSession = 5 + greetingsPerRound = 3 + revokeEvery = 50 * time.Millisecond +) + +// TestConcurrentSessionsShareOneMintedToken resolves sessions from three goroutines while five +// more per session send authorized requests at once, and revokes tokens underneath all of them. +// No request may fail, no request may carry a token the backend never minted, and a session of +// five concurrent callers may mint only once. +func TestConcurrentSessionsShareOneMintedToken(t *testing.T) { + quietLogging(t) + server := newTestServer(t) + + // The warm-up writes profiles.json, the credentials file and the token cache before any + // goroutine starts. A lock file whose directory does not exist yet cannot be taken, and + // internal/lock reports that as acquired, so the first writer would otherwise be unguarded. + warmUp := requireSession(t, testApiKey1) + server.RequireGreeting(t, greetingClient(warmUp)) + require.NoError(t, warmUp.Store(t.Context())) + + resolvers := []*stressResolver{ + {name: "first-key-1", apiKey: testApiKey1, every: 100 * time.Millisecond}, + {name: "second-key-1", apiKey: testApiKey1, every: 100 * time.Millisecond}, + // The second api key writes its own identity into the one cache file both keys share, + // so the resolvers above find a cache minted for someone else and have to mint again. + {name: "only-key-2", apiKey: testApiKey2, every: 300 * time.Millisecond}, + } + + // A round workCtx cuts off is not a failure, which is what the ctx.Err() checks below allow for. + workCtx, endWork := context.WithTimeout(t.Context(), stressDuration(t)) + defer endWork() + + var ( + failures stressFailures + storing sync.Mutex + roundsInFlight atomic.Int64 + running sync.WaitGroup + ) + for _, resolver := range resolvers { + running.Go(func() { + resolver.run(t, workCtx, server, &storing, &roundsInFlight, &failures) + }) + } + running.Go(func() { + revokeTokens(t, workCtx, server, &roundsInFlight) + }) + running.Wait() + + failures.requireNone(t) + + var rounds int64 + for _, resolver := range resolvers { + assert.Positive(t, resolver.rounds.Load(), "%s never completed a round", resolver.name) + rounds += resolver.rounds.Load() + } + + counts := server.Counts(t) + t.Logf("%d rounds, %+v", rounds, counts) + + assert.Zero(t, counts.UnknownTokens, "every request carried a token this server had minted") + assert.Positive(t, counts.RevokedTokens, "no revocation reached a session still using the token") + assert.GreaterOrEqual(t, counts.Greetings, rounds*workersPerSession*greetingsPerRound, + "every worker of every completed round got its greetings") + // One mint for the warm-up, one per session at most, and one per rejected request at most. + assert.LessOrEqual(t, counts.Logins, 1+rounds+counts.RevokedTokens, + "a session minted more than once without having been rejected") + // The bound above still allows one mint per round, so this is the tighter check: five + // concurrent callers share one token, and so do the sessions that follow them. + assert.Less(t, counts.Logins, counts.Greetings/10, + "the token cache saved far fewer logins than it should have") +} + +// stressResolver runs one session per tick, which bounds the live goroutines while still +// overlapping every other resolver's rounds. +type stressResolver struct { + name string + apiKey testserver.ApiKey + every time.Duration + + rounds atomic.Int64 +} + +func (r *stressResolver) run(t *testing.T, ctx context.Context, server *testserver.Server, storing *sync.Mutex, inFlight *atomic.Int64, failures *stressFailures) { + t.Helper() + ticker := time.NewTicker(r.every) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + + // The count covers resolution and store as well, because revoking anywhere in that + // span would defeat the one retry. See revokeTokens. + inFlight.Add(1) + session, err := auth.ResolveSession(ctx, sessionOptsFor(r.apiKey)) + if err == nil { + r.greetConcurrently(t, ctx, server, session, failures) + + // Storing is serialized across resolvers because concurrent writers of one profile + // are not something the CLI has to support, while concurrent authorization is. + storing.Lock() + err = session.Store(ctx) + storing.Unlock() + } + inFlight.Add(-1) + + if err != nil { + if ctx.Err() == nil { + failures.add(fmt.Errorf("%s round %d: %w", r.name, r.rounds.Load()+1, err)) + } + return + } + r.rounds.Add(1) + } +} + +func (r *stressResolver) greetConcurrently(t *testing.T, ctx context.Context, server *testserver.Server, session auth.Session, failures *stressFailures) { + t.Helper() + greet := greetingClient(session) + // Closing the channel releases every worker in the same instant, so they all reach the + // freshly resolved session's empty cache together. + release := make(chan struct{}) + var workers sync.WaitGroup + for worker := range workersPerSession { + workers.Go(func() { + <-release + for range greetingsPerRound { + if err := server.Greeting(t, ctx, greet); err != nil { + if ctx.Err() == nil { + failures.add(fmt.Errorf("%s worker %d: %w", r.name, worker, err)) + } + return + } + } + }) + } + close(release) + workers.Wait() +} + +// revokeTokens stops honoring the token the next session will find in the cache file, so that +// its five callers all meet a 401 at once and go through auth.Session.RefreshBearerToken +// together. Exactly one of them may then mint, which is what the login count checks. +// +// It revokes only between rounds. A revocation during a request can leave a 401 that nothing +// recovers from, because the client retries once and may retry with a token it adopted from the +// cache file just before that token was revoked. +func revokeTokens(t *testing.T, ctx context.Context, server *testserver.Server, inFlight *atomic.Int64) { + t.Helper() + ticker := time.NewTicker(revokeEvery) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if inFlight.Load() == 0 { + server.RevokeNewestToken(t) + } + } + } +} + +// stressFailures collects what went wrong on the worker goroutines, where require must not be +// called. +type stressFailures struct { + mu sync.Mutex + count int + first []error +} + +func (f *stressFailures) add(err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.count++ + if len(f.first) < 10 { + f.first = append(f.first, err) + } +} + +func (f *stressFailures) requireNone(t *testing.T) { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + require.Zerof(t, f.count, "%d calls failed, the first of them: %v", f.count, errors.Join(f.first...)) +} + +// sessionOptsFor supplies the api key as a front end setting source rather than through the +// environment. The resolvers run at the same time, and one process cannot hold two values of +// MESHSTACK_API_KEY at once. +func sessionOptsFor(key testserver.ApiKey) auth.ResolveSessionOptions { + opts := testSessionOpts + opts.SettingSources = setting.Sources{ + setting.FrontendSource{Source: staticSetting(auth.ApiKeyClientIdSetting.EnvKey(), key.ClientId)}, + setting.FrontendSource{Source: staticSetting(auth.ApiKeyClientSecretSetting.EnvKey(), key.ClientSecret)}, + } + return opts +} + +func staticSetting(envKey, value string) setting.Source { + return setting.LookupSource{ + MatchingKey: envKey, + Description: "the stress test", + Func: func(_ context.Context) (string, error) { + return value, nil + }, + } +} + +func requireSession(t *testing.T, key testserver.ApiKey) auth.Session { + t.Helper() + session, err := auth.ResolveSession(t.Context(), sessionOptsFor(key)) + require.NoError(t, err) + return session +} + +func stressDuration(t *testing.T) time.Duration { + t.Helper() + if testing.Short() { + return 1 * time.Second + } + return 10 * time.Second +} + +func quietLogging(t *testing.T) { + t.Helper() + previous := slog.Default() + slog.SetDefault(slog.New(slog.DiscardHandler)) + t.Cleanup(func() { + slog.SetDefault(previous) + }) +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 00000000..9e586b1d --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,157 @@ +package auth_test + +import ( + "context" + gohttp "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/auth" + "github.com/meshcloud/meshstack-cli/internal/config" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/profile" + "github.com/meshcloud/meshstack-cli/internal/testutil/testserver" +) + +func TestSessionAuthorizesWithAnApiKey(t *testing.T) { + server := newTestServer(t) + + testApiKey1.SetEnv(t) + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + greet := greetingClient(session) + + server.RequireGreeting(t, greet) + assert.EqualValues(t, 1, server.Counts(t).Logins, "one login mints the token every request then reuses") + + server.RequireGreeting(t, greet) + assert.EqualValues(t, 1, server.Counts(t).Logins, "the cached token is still valid, so nothing is re-minted") +} + +func TestSessionRefreshesARejectedToken(t *testing.T) { + server := newTestServer(t) + + testApiKey1.SetEnv(t) + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + greet := greetingClient(session) + + server.RequireGreeting(t, greet) + require.EqualValues(t, 1, server.Counts(t).Logins) + + require.True(t, server.RevokeNewestToken(t), "the token the session just minted") + server.RequireGreeting(t, greet) + + assert.EqualValues(t, 2, server.Counts(t).Logins, "a 401 mints once more, on demand") +} + +func TestSessionWithoutAnyCredentialNamesTheSettingsItLookedFor(t *testing.T) { + newTestServer(t) + + _, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.ErrorContains(t, err, "selects none") + require.ErrorContains(t, err, auth.ApiTokenSetting.EnvKey()) + require.ErrorContains(t, err, auth.ApiKeyClientIdSetting.EnvKey()) + require.ErrorContains(t, err, auth.ApiKeyClientSecretSetting.EnvKey()) +} + +func TestSessionWithHalfAnApiKeySaysWhichHalfIsMissing(t *testing.T) { + newTestServer(t) + + t.Setenv(auth.ApiKeyClientIdSetting.EnvKey(), testApiKey1.ClientId) + + _, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.ErrorContains(t, err, "together") + require.ErrorContains(t, err, auth.ApiKeyClientSecretSetting.EnvKey()) +} + +func TestSessionRefusesTwoCredentialsAtOnce(t *testing.T) { + server := newTestServer(t) + + testApiKey1.SetEnv(t) + t.Setenv(auth.ApiTokenSetting.EnvKey(), server.MintToken(t, time.Hour)) + + _, err := auth.ResolveSession(t.Context(), testSessionOpts) + assert.ErrorContains(t, err, "more than one credential") +} + +func TestSessionReusesAStoredTokenUntilTheApiKeyChanges(t *testing.T) { + // The config directory newTestServer sets is the parent's, so every subtest below shares + // it while each one decides its own credential environment. + server := newTestServer(t) + + t.Run("first run", func(t *testing.T) { + testApiKey1.SetEnv(t) + + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + server.RequireGreeting(t, greetingClient(session)) + require.NoError(t, session.Store(t.Context())) + }) + + t.Run("reloaded without env", func(t *testing.T) { + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + server.RequireGreeting(t, greetingClient(session)) + assert.EqualValues(t, 1, server.Counts(t).Logins) + }) + + t.Run("reloaded with same env", func(t *testing.T) { + testApiKey1.SetEnv(t) + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + server.RequireGreeting(t, greetingClient(session)) + assert.EqualValues(t, 1, server.Counts(t).Logins) + }) + + t.Run("reloaded with different env, busting the cache", func(t *testing.T) { + testApiKey2.SetEnv(t) + session, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + server.RequireGreeting(t, greetingClient(session)) + assert.EqualValues(t, 2, server.Counts(t).Logins) + }) +} + +var ( + testSessionOpts = auth.ResolveSessionOptions{Version: "dev", GitHubRepo: "meshcloud/test-client"} + testApiKey1 = testserver.ApiKey{ClientId: "11111111-45bf-42ba-a965-2097b9d0d181", ClientSecret: "super-test-secret-1"} + testApiKey2 = testserver.ApiKey{ClientId: "22222222-45bf-42ba-a965-2097b9d0d181", ClientSecret: "super-test-secret-2"} +) + +// newTestServer starts a backend both test api keys can log in to, and points a fresh config +// directory and the endpoint setting at it. +func newTestServer(t *testing.T) *testserver.Server { + t.Helper() + // A shell that exports any of these would otherwise reach the resolutions under test, and a + // MESHSTACK_API_TOKEN of its own resolves a credential no test here asked for. An empty value + // is skipped as no value at all, see Setting.Resolve. + for _, envKey := range []string{ + profile.NameSetting.EnvKey(), + meshstack.WorkspaceSetting.EnvKey(), + meshstack.SkipVersionCheckSetting.EnvKey(), + auth.ApiKeyClientIdSetting.EnvKey(), + auth.ApiKeyClientSecretSetting.EnvKey(), + auth.ApiTokenSetting.EnvKey(), + } { + t.Setenv(envKey, "") + } + server := testserver.New(t, testApiKey1, testApiKey2) + t.Setenv(config.DirectorySetting.EnvKey(), t.TempDir()) + t.Setenv(meshstack.EndpointSetting.EnvKey(), server.Url(t).String()) + return server +} + +// greetingClient brings its own http.Client, because the session keeps its own to itself. What +// these tests drive is the authorization, which the session supplies either way. +func greetingClient(session auth.Session) testserver.GreetingClient { + return func(ctx context.Context, url *url.URL) (string, error) { + return http.NewClient("session-test").WithAuthorization(session). + DoRequest[string](ctx, gohttp.MethodGet, url) + } +} diff --git a/internal/config/directory.go b/internal/config/directory.go new file mode 100644 index 00000000..8d6bfcf3 --- /dev/null +++ b/internal/config/directory.go @@ -0,0 +1,65 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +type Directory string + +var DirectorySetting = setting.Setting[Directory]{ + Env: "MESHSTACK_CONFIG_DIR", + Short: func(envKey string) string { + return fmt.Sprintf("The directory holding profiles.json and one credentials file per profile. Also read from %s.", envKey) + }, + Long: func(envKey string) string { + return fmt.Sprintf("The directory holding the meshStack CLI's configuration, also read from `%s`.\n\n"+ + "`profiles.json` describes every profile, and `credentials/.json` holds that profile's "+ + "credentials and its cached tokens.", envKey) + }, + Default: setting.DefaultSource(func() (dir string, err error) { + // os.UserConfigDir already honors XDG_CONFIG_HOME on Linux. Reading it here is what makes + // it win on macOS and Windows too, where the platform directory differs. + if dir = os.Getenv("XDG_CONFIG_HOME"); dir == "" { + if dir, err = os.UserConfigDir(); err != nil { + return "", fmt.Errorf("cannot locate a configuration directory: %w", err) + } + } + return filepath.Join(dir, "meshstack"), nil + }), + Parse: setting.ParseText[Directory], +} + +func (d Directory) Join(elems ...any) string { + all := []string{string(d)} + for _, elem := range elems { + all = append(all, fmt.Sprintf("%v", elem)) + } + return filepath.Join(all...) +} + +func (d Directory) ProfilesJson() string { + return d.Join("profiles.json") +} + +func (d Directory) VersionCheckJson() string { + return d.Join("versionCheck.json") +} + +func (d Directory) CredentialsJsonFor(profileName fmt.Stringer) string { + return d.Join("credentials", fmt.Sprintf("%s.json", profileName)) +} + +func (d Directory) CredentialsCacheJsonFor(profileName, credentialName fmt.Stringer) string { + return d.Join("credentials-cache", profileName, fmt.Sprintf("%s.json", credentialName)) +} + +func (d Directory) Exists() bool { + if fileInfo, err := os.Stat(string(d)); err == nil && fileInfo.IsDir() { + return true + } + return false +} diff --git a/internal/io/stderr.go b/internal/io/stderr.go new file mode 100644 index 00000000..4ba967c7 --- /dev/null +++ b/internal/io/stderr.go @@ -0,0 +1,21 @@ +package io + +import ( + "context" + "io" + "os" +) + +type stderrKey struct{} + +func WithStderr(ctx context.Context, stderr io.Writer) context.Context { + return context.WithValue(ctx, stderrKey{}, stderr) +} + +// Stderr is what WithStderr put in the context, and os.Stderr for a context that carries none. +func Stderr(ctx context.Context) io.Writer { + if stderr, ok := ctx.Value(stderrKey{}).(io.Writer); ok { + return stderr + } + return os.Stderr +} diff --git a/internal/lock/lock.go b/internal/lock/lock.go new file mode 100644 index 00000000..a0eba04b --- /dev/null +++ b/internal/lock/lock.go @@ -0,0 +1,140 @@ +package lock + +import ( + "context" + "errors" + "os" + "sync" + "time" + + "github.com/gofrs/flock" +) + +const retryDelay = 25 * time.Millisecond + +func New(path string) Locker { + return Locker{m: &sync.RWMutex{}, pathLock: path + ".lock"} +} + +// Locker guards one file against other goroutines with an RWMutex, and against other +// processes with a lock file. Both spin on a try-lock, so neither is fair: a writer cannot +// preempt a steady stream of readers, and every holder has to release quickly. That is the +// bargain the token cache is built on — a read is one field, a write is one token refresh. +type Locker struct { + m *sync.RWMutex + pathLock string +} + +func (l Locker) WithLock(ctx context.Context, fn func() error) error { + return l.with(ctx, false, fn) +} + +func (l Locker) WithRLock(ctx context.Context, fn func() error) error { + return l.with(ctx, true, fn) +} + +func (l Locker) with(ctx context.Context, read bool, fn func() error) error { + return l.inMemoryLocker(read).With(ctx, func() error { + return l.fileLocker(read).With(ctx, fn) + }) +} + +func (l Locker) inMemoryLocker(read bool) delegatingLocker { + tryLock := l.m.TryLock + unlock := l.m.Unlock + if read { + tryLock = l.m.TryRLock + unlock = l.m.RUnlock + } + return delegatingLocker{ + DelegateTryLock: func() (bool, error) { + return tryLock(), nil + }, + DelegateUnlock: func() error { + unlock() + return nil + }, + } +} + +func (l Locker) fileLocker(read bool) delegatingLocker { + //goland:noinspection GoResourceLeak + f := flock.New(l.pathLock) + + tryLock := f.TryLock + unlock := f.Unlock + if read { + tryLock = f.TryRLock + } + + ignoreErr := func(err error) bool { + return errors.Is(err, os.ErrNotExist) || unwritableFilesystem(err) + } + + return delegatingLocker{ + DelegateTryLock: func() (ok bool, err error) { + defer func() { + if ignoreErr(err) { + ok, err = true, nil + } + }() + return tryLock() + }, + DelegateUnlock: func() (err error) { + defer func() { + if ignoreErr(err) { + err = nil + } + }() + return unlock() + }, + } +} + +type ( + tryLockFunc func() (bool, error) + unlockFunc func() error +) + +type delegatingLocker struct { + DelegateTryLock tryLockFunc + DelegateUnlock unlockFunc +} + +func (l delegatingLocker) With(ctx context.Context, fn func() error) (err error) { + if lockErr := l.SpinLock(ctx); lockErr != nil { + return lockErr + } + defer func() { + err = errors.Join(err, l.Unlock()) + }() + return fn() +} + +func (l delegatingLocker) SpinLock(ctx context.Context) error { + for { + if err := ctx.Err(); err != nil { + return err + } + + if acquired, err := l.TryLock(); err != nil { + return err + } else if acquired { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryDelay): + } + } +} + +func (l delegatingLocker) TryLock() (bool, error) { + return l.DelegateTryLock() +} + +func (l delegatingLocker) Unlock() error { + return l.DelegateUnlock() +} diff --git a/internal/lock/lock_test.go b/internal/lock/lock_test.go new file mode 100644 index 00000000..ea08c4e5 --- /dev/null +++ b/internal/lock/lock_test.go @@ -0,0 +1,302 @@ +package lock_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gofrs/flock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/lock" +) + +const settle = 250 * time.Millisecond + +func noop() error { return nil } + +func fileLockIsFree(t *testing.T, path string) bool { + t.Helper() + + probe := flock.New(path + ".lock") + + taken, err := probe.TryLock() + require.NoError(t, err) + + if taken { + require.NoError(t, probe.Close()) + } + + return taken +} + +func lockBlocked(t *testing.T, l lock.Locker) bool { + t.Helper() + + ctx, cancel := context.WithTimeout(t.Context(), settle) + defer cancel() + + var ran bool + + err := l.WithLock(ctx, func() error { + ran = true + + return nil + }) + if err != nil { + require.ErrorIs(t, err, context.DeadlineExceeded) + require.False(t, ran, "the callback must not run when the lock was not taken") + + return true + } + + require.True(t, ran) + + return false +} + +func TestLocksTheFileWhileTheCallbackRuns(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + + l := lock.New(path) + require.NoFileExists(t, path+".lock", "New touches nothing, the file lock is taken on demand") + + require.NoError(t, l.WithLock(t.Context(), func() error { + require.False(t, fileLockIsFree(t, path)) + + return nil + })) + + require.True(t, fileLockIsFree(t, path)) +} + +func TestFallsBackToTheMutexWhenTheDirectoryIsMissing(t *testing.T) { + dir := t.TempDir() + + l := lock.New(filepath.Join(dir, "absent", "credentials")) + + require.NoError(t, l.WithLock(t.Context(), noop)) + require.NoDirExists(t, filepath.Join(dir, "absent"), "the fallback creates nothing on disk") +} + +func TestAPermissionErrorBubblesInsteadOfFallingBack(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows cannot tell a read-only volume from an ACL denial, so both fall back there") + } + + if os.Geteuid() == 0 { + t.Skip("root ignores the directory permissions this test rests on") + } + + dir := filepath.Join(t.TempDir(), "config") + require.NoError(t, os.Mkdir(dir, 0o500)) + + l := lock.New(filepath.Join(dir, "credentials")) + require.ErrorIs(t, l.WithLock(t.Context(), noop), os.ErrPermission) +} + +func TestFallbackLockerOnlyProtectsItsOwnValue(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent", "credentials") + + a := lock.New(path) + b := lock.New(path) + + require.NoError(t, a.WithLock(t.Context(), func() error { + require.False(t, lockBlocked(t, b), + "with no file to lock the guarantee is process-local, so callers have to share one Locker") + + return nil + })) +} + +func TestTheCallbackErrorReachesTheCaller(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + + l := lock.New(path) + + failed := errors.New("writing the credentials failed") + require.ErrorIs(t, l.WithLock(t.Context(), func() error { return failed }), failed) + require.True(t, fileLockIsFree(t, path), "a failing callback still releases the lock") +} + +func TestAPanickingCallbackReleasesTheLock(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + + l := lock.New(path) + + require.Panics(t, func() { + _ = l.WithLock(t.Context(), func() error { panic("boom") }) + }) + + require.True(t, fileLockIsFree(t, path)) + require.NoError(t, l.WithLock(t.Context(), noop), "the mutex must be free too") +} + +func TestTheLastReaderReleasesTheFileLock(t *testing.T) { + const readers = 4 + + path := filepath.Join(t.TempDir(), "credentials") + + l := lock.New(path) + + holding := make(chan struct{}, readers) + release := make(chan struct{}) + released := make(chan struct{}, readers) + + var wg sync.WaitGroup + + for range readers { + wg.Go(func() { + assert.NoError(t, l.WithRLock(t.Context(), func() error { + holding <- struct{}{} + <-release + + return nil + })) + + released <- struct{}{} + }) + } + + for range readers { + <-holding + } + + for i := range readers { + require.False(t, fileLockIsFree(t, path), "reader %d of %d still holds the shared lock", i+1, readers) + + release <- struct{}{} + <-released + } + + wg.Wait() + require.True(t, fileLockIsFree(t, path)) +} + +func TestOnlyOneWriterRunsAtATime(t *testing.T) { + l := lock.New(filepath.Join(t.TempDir(), "credentials")) + + var live, peak atomic.Int64 + + var wg sync.WaitGroup + + for range 8 { + wg.Go(func() { + assert.NoError(t, l.WithLock(t.Context(), func() error { + if n := live.Add(1); n > peak.Load() { + peak.Store(n) + } + + time.Sleep(time.Millisecond) + live.Add(-1) + + return nil + })) + }) + } + + wg.Wait() + require.Equal(t, int64(1), peak.Load()) +} + +func TestSeparateLockersExcludeEachOther(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + + a := lock.New(path) + b := lock.New(path) + + require.NoError(t, a.WithLock(t.Context(), func() error { + require.True(t, lockBlocked(t, b)) + + return nil + })) + + require.False(t, lockBlocked(t, b)) +} + +func TestAFailedFileLockReleasesTheMutex(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + + a := lock.New(path) + b := lock.New(path) + + require.NoError(t, a.WithLock(t.Context(), func() error { + require.True(t, lockBlocked(t, b)) + + return nil + })) + + require.NoError(t, b.WithLock(t.Context(), noop), + "b's own mutex must not still be held by the attempt that timed out") +} + +func TestWaitingForTheMutexIsCancellable(t *testing.T) { + l := lock.New(filepath.Join(t.TempDir(), "absent", "credentials")) + + require.NoError(t, l.WithLock(t.Context(), func() error { + ctx, cancel := context.WithCancel(t.Context()) + go func() { + time.Sleep(settle) + cancel() + }() + + err := l.WithLock(ctx, func() error { + require.Fail(t, "the callback must not run") + + return nil + }) + require.ErrorIs(t, err, context.Canceled) + + return nil + })) +} + +func TestWaitingForTheFileIsCancellable(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + + a := lock.New(path) + b := lock.New(path) + + require.NoError(t, a.WithLock(t.Context(), func() error { + ctx, cancel := context.WithCancel(t.Context()) + go func() { + time.Sleep(settle) + cancel() + }() + + err := b.WithLock(ctx, func() error { + require.Fail(t, "the callback must not run") + + return nil + }) + require.ErrorIs(t, err, context.Canceled) + + return nil + })) +} + +func TestAnExpiredContextTakesNoLock(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + + l := lock.New(path) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := l.WithLock(ctx, func() error { + require.Fail(t, "the callback must not run") + + return nil + }) + require.ErrorIs(t, err, context.Canceled) + + require.True(t, fileLockIsFree(t, path)) + require.NoError(t, l.WithLock(t.Context(), noop), "the mutex must not be left held") +} diff --git a/internal/lock/readonly_other.go b/internal/lock/readonly_other.go new file mode 100644 index 00000000..8c8cb4c2 --- /dev/null +++ b/internal/lock/readonly_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package lock + +import ( + "errors" + "syscall" +) + +func unwritableFilesystem(err error) bool { + return errors.Is(err, syscall.EROFS) +} diff --git a/internal/lock/readonly_windows.go b/internal/lock/readonly_windows.go new file mode 100644 index 00000000..2132983c --- /dev/null +++ b/internal/lock/readonly_windows.go @@ -0,0 +1,17 @@ +package lock + +import ( + "errors" + "os" + "syscall" +) + +// ERROR_WRITE_PROTECT, which syscall does not name. Its own EROFS is an invented value above +// APPLICATION_ERROR that no Windows call ever returns. +const errorWriteProtect = syscall.Errno(19) + +// Unlike the other platforms, a denied permission falls back here too: Windows reports a read-only +// volume as ERROR_ACCESS_DENIED, so it cannot be told apart from an ACL denial. +func unwritableFilesystem(err error) bool { + return errors.Is(err, errorWriteProtect) || errors.Is(err, os.ErrPermission) +} diff --git a/internal/meshstack/settings.go b/internal/meshstack/settings.go new file mode 100644 index 00000000..919a4f11 --- /dev/null +++ b/internal/meshstack/settings.go @@ -0,0 +1,32 @@ +package meshstack + +import ( + "fmt" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +var EndpointSetting = setting.Setting[xurl.URL]{ + Env: "MESHSTACK_ENDPOINT", + Short: func(envKey string) string { + return fmt.Sprintf("The meshStack API to act against, such as https://api.example.meshcloud.io. Also read from %s.", envKey) + }, + Long: func(envKey string) string { + return fmt.Sprintf("The meshStack API to act against, such as `https://api.example.meshcloud.io`, "+ + "also read from `%s` and inferred from current profile if possible.", envKey) + }, + Parse: setting.ParseTextUnmarshaler[xurl.URL], +} + +// SkipVersionCheckSetting skips both the minimum backend version check and the check for a newer +// release of the front end itself. +var SkipVersionCheckSetting = setting.Setting[bool]{ + Env: "MESHSTACK_SKIP_VERSION_CHECK", + Short: func(envKey string) string { + return fmt.Sprintf("Skip version check against meshStack backend. Also read from %s.", envKey) + }, + Parse: setting.ParseBool, + // An explicit "0", because ParseBool reads every string but a spelling of "no" as true. + Default: setting.StaticDefault("0"), +} diff --git a/internal/meshstack/workspace.go b/internal/meshstack/workspace.go new file mode 100644 index 00000000..91abc1c5 --- /dev/null +++ b/internal/meshstack/workspace.go @@ -0,0 +1,29 @@ +package meshstack + +import ( + "fmt" + + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +// Workspace identifies a workspace as meshPanel shows it. +type Workspace string + +// NoWorkspace means that no workspace is known, or that the authentication is unscoped. It is the +// zero value, so `omitzero` leaves it out of a stored profile. +const NoWorkspace Workspace = "" + +func (w Workspace) String() string { + if w == NoWorkspace { + return "" + } + return string(w) +} + +var WorkspaceSetting = setting.Setting[Workspace]{ + Env: "MESHSTACK_WORKSPACE", + Short: func(envKey string) string { + return fmt.Sprintf("The workspace to act in, identified as in meshPanel, such as my-workspace-ab12c. Also read from %s.", envKey) + }, + Parse: setting.ParseText[Workspace], +} diff --git a/internal/meshstack/workspaces.go b/internal/meshstack/workspaces.go new file mode 100644 index 00000000..17d46796 --- /dev/null +++ b/internal/meshstack/workspaces.go @@ -0,0 +1,83 @@ +package meshstack + +import ( + "context" + "fmt" + "iter" + "log/slog" + + "github.com/meshcloud/meshstack-cli/client" +) + +type ( + workspacesContextKey int + WorkspacesFunc func() (Workspaces, error) +) + +func WorkspacesFromContext(ctx context.Context) (Workspaces, error) { + workspaces, found := ctx.Value(workspacesContextKey(0)).(WorkspacesFunc) + if !found { + // An error rather than a panic, because a front end reaches this through pkg/setting and a + // panic there takes its process down. + return Workspaces{}, fmt.Errorf("no workspaces available in this context; they are only provided while resolving %s", WorkspaceSetting.EnvKey()) + } + return workspaces() +} + +func SetWorkspacesInContext(ctx context.Context, workspacesFunc WorkspacesFunc) context.Context { + return context.WithValue(ctx, workspacesContextKey(0), workspacesFunc) +} + +type ( + Workspaces struct { + Items []client.MeshWorkspace + ProfileDefaultWorkspace Workspace + } + MeshWorkspace struct { + client.MeshWorkspace + } +) + +func (ws Workspaces) All() iter.Seq2[int, MeshWorkspace] { + return func(yield func(int, MeshWorkspace) bool) { + for i, item := range ws.Items { + if !yield(i, MeshWorkspace{item}) { + return + } + } + } +} + +func (ws Workspaces) ProfileDefault(ctx context.Context) (r *MeshWorkspace) { + if ws.ProfileDefaultWorkspace == NoWorkspace { + return + } + for _, item := range ws.All() { + if item.Name() == ws.ProfileDefaultWorkspace { + slog.DebugContext(ctx, fmt.Sprintf("Found profile's default workspace: %s", item)) + return &item + } + } + slog.WarnContext(ctx, fmt.Sprintf("Your profile carries the default workspace '%s', which you have no access to currently", ws.ProfileDefaultWorkspace)) + return nil +} + +func (ws Workspaces) Single(ctx context.Context) (single *MeshWorkspace) { + if len(ws.Items) == 1 { + single = &MeshWorkspace{ws.Items[0]} + slog.InfoContext(ctx, fmt.Sprintf("Auto-selecting the only workspace available: %s", single)) + } + return +} + +func (w MeshWorkspace) Name() Workspace { + return Workspace(w.Metadata.Name) +} + +func (w MeshWorkspace) Matches(other MeshWorkspace) bool { + return w.Name() == other.Name() +} + +func (w MeshWorkspace) String() string { + return fmt.Sprintf("%s (%s)", w.Spec.DisplayName, w.Metadata.Name) +} diff --git a/internal/oidc/authcode.go b/internal/oidc/authcode.go new file mode 100644 index 00000000..ba650b19 --- /dev/null +++ b/internal/oidc/authcode.go @@ -0,0 +1,63 @@ +package oidc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "net/url" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/oidc/scope" +) + +// AuthorizationCodeFlow is one run of the authorization code flow with PKCE. +type AuthorizationCodeFlow struct { + Client + + RedirectURI xurl.URL + verifier string + state string +} + +func (c Client) NewAuthorizationCode(redirectURI xurl.URL) AuthorizationCodeFlow { + return AuthorizationCodeFlow{Client: c, RedirectURI: redirectURI, verifier: randomString(), state: randomString()} +} + +func (a AuthorizationCodeFlow) Exchange(ctx context.Context, code string) (Token, error) { + return a.ExchangeAuthCode(ctx, code, a.RedirectURI, a.verifier) +} + +func (a AuthorizationCodeFlow) BrowserUrl() xurl.URL { + challenge := sha256.Sum256([]byte(a.verifier)) + // scopes never include c:: a login is unscoped and the workspace arrives later + scopes := scope.Scopes{scope.OpenId, scope.Profile, scope.Email, scope.OfflineAccess} + authURL := a.AuthorizationEndpoint.Clone() + authURL.RawQuery = url.Values{ + "response_type": {"code"}, + "client_id": {a.Id}, + "redirect_uri": {a.RedirectURI.String()}, + "scope": {scopes.String()}, + "state": {a.state}, + "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])}, + "code_challenge_method": {"S256"}, + }.Encode() + return authURL +} + +func (a AuthorizationCodeFlow) CheckState(state string) error { + if subtle.ConstantTimeCompare([]byte(state), []byte(a.state)) != 1 { + return errors.New("the login redirect carried the wrong state parameter, so it did not belong to this login") + } + return nil +} + +// randomString is the source of both the PKCE verifier and the state parameter: 32 bytes, which +// is the top of the range RFC 7636 allows for a verifier. +func randomString() string { + buf := make([]byte, 32) + _, _ = rand.Read(buf) + return base64.RawURLEncoding.EncodeToString(buf) +} diff --git a/internal/oidc/browser/browser.go b/internal/oidc/browser/browser.go new file mode 100644 index 00000000..ef111671 --- /dev/null +++ b/internal/oidc/browser/browser.go @@ -0,0 +1,142 @@ +package browser + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + gohttp "net/http" + "os" + "strings" + "time" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/io" + "github.com/meshcloud/meshstack-cli/internal/oidc" +) + +const callbackPath = "/callback" + +// Login supplies the two things oidc.AuthorizationCodeFlow needs and internal/oidc cannot have: +// a loopback address to receive the redirect on, and a person in front of a browser. +func Login(ctx context.Context, client oidc.Client) (oidc.Token, error) { + // Bound first, because the port it gets is part of the redirect URI, which the flow puts + // in the authorization request and echoes back in the token request. + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0") + if err != nil { + return oidc.Token{}, fmt.Errorf("cannot listen on a loopback port for the login redirect: %w", err) + } + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + // await is what otherwise closes the listener, through the server it hands it to. + _ = listener.Close() + return oidc.Token{}, fmt.Errorf("cannot determine the port of the loopback listener, got %s", listener.Addr()) + } + + // callbackPath carries its own leading slash, so the format string must not add one: a + // redirect URI of //callback is not the one keycloak has registered. + flow := client.NewAuthorizationCode(xurl.MustParsef("http://%s%s", addr, callbackPath)) + + code, err := await(ctx, listener, flow) + if err != nil { + return oidc.Token{}, err + } + return flow.Exchange(ctx, code) +} + +type callback struct { + code string + err error +} + +func await(ctx context.Context, listener net.Listener, flow oidc.AuthorizationCodeFlow) (string, error) { + // Buffered, so the handler never blocks on a caller that has already given up. + arrived := make(chan callback, 1) + server := &gohttp.Server{ + Handler: handler(flow, arrived), + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, gohttp.ErrServerClosed) { + arrived <- callback{err: fmt.Errorf("the loopback listener for the login redirect failed: %w", err)} + } + }() + defer func() { + // A fresh context: the caller's may already be cancelled, and the browser is still + // reading the page the handler wrote. + shutdown, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + if err := server.Shutdown(shutdown); err != nil { + slog.DebugContext(shutdown, "the loopback listener did not shut down cleanly", "error", err) + } + }() + + openBrowser(ctx, flow.BrowserUrl()) + + select { + case result := <-arrived: + return result.code, result.err + case <-ctx.Done(): + return "", fmt.Errorf("the browser login ended before the redirect arrived: %w", ctx.Err()) + } +} + +func handler(flow oidc.AuthorizationCodeFlow, arrived chan<- callback) gohttp.Handler { + return gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + if r.URL.Path != callbackPath { + gohttp.NotFound(w, r) + return + } + query := r.URL.Query() + + if refused := query.Get("error"); refused != "" { + detail := refused + if description := query.Get("error_description"); description != "" { + detail += ": " + description + } + page(r.Context(), w, gohttp.StatusBadRequest, "Login failed", detail) + arrived <- callback{err: fmt.Errorf("the identity provider refused the login: %s", detail)} + return + } + if err := flow.CheckState(query.Get("state")); err != nil { + page(r.Context(), w, gohttp.StatusBadRequest, "Login failed", "The redirect carried the wrong state parameter, so it did not belong to this login.") + arrived <- callback{err: err} + return + } + if query.Get("code") == "" { + page(r.Context(), w, gohttp.StatusBadRequest, "Login failed", "The redirect carried no authorization code.") + arrived <- callback{err: errors.New("the login redirect carried no authorization code")} + return + } + page(r.Context(), w, gohttp.StatusOK, "You are logged in", "The meshStack CLI has your login. You can close this tab and return to your terminal.") + arrived <- callback{code: query.Get("code")} + }) +} + +// noBrowserEnv lets a caller act as the browser itself, as cmd/internal/testacc does: the +// authorization URL still goes to stderr, and nothing is launched on the machine. It is not a +// setting.Setting, because it has no flag, no profile entry and no help text. +const noBrowserEnv = "MESHSTACK_CLI_NO_BROWSER" + +func openBrowser(ctx context.Context, authURL xurl.URL) { + out := io.Stderr(ctx) + _, _ = fmt.Fprintln(out, "Opening your browser to log in to meshStack. If it does not open, visit:") + _, _ = fmt.Fprintf(out, "\n %s\n\n", authURL) + if deadline, ok := ctx.Deadline(); ok { + _, _ = fmt.Fprintf(out, "Waiting up to %s for you to finish.\n", time.Until(deadline).Round(time.Second)) + } + + if _, suppressed := os.LookupEnv(noBrowserEnv); suppressed { + slog.DebugContext(ctx, "not opening a browser, because "+noBrowserEnv+" is set") + return + } + + // A platform with no execBrowserOpen fails to build rather than falling back to nothing, so + // adding a release target in .goreleaser.yml means adding the file that opens a browser on it. + cmd := execBrowserOpen(ctx, authURL.String()) + if err := cmd.Start(); err != nil { + slog.DebugContext(ctx, "cannot open a browser, waiting for a manually opened one instead", + "command", strings.Join(cmd.Args, " "), "error", err) + } +} diff --git a/internal/oidc/browser/callback.go b/internal/oidc/browser/callback.go new file mode 100644 index 00000000..bdd400af --- /dev/null +++ b/internal/oidc/browser/callback.go @@ -0,0 +1,22 @@ +package browser + +import ( + "context" + _ "embed" + "html/template" + "log/slog" + gohttp "net/http" +) + +//go:embed callback.html +var callbackPage string + +var resultPage = template.Must(template.New("callback").Parse(callbackPage)) + +func page(ctx context.Context, w gohttp.ResponseWriter, status int, title, message string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := resultPage.Execute(w, struct{ Title, Message string }{title, message}); err != nil { + slog.DebugContext(ctx, "cannot write the login result page", "error", err) + } +} diff --git a/internal/oidc/browser/callback.html b/internal/oidc/browser/callback.html new file mode 100644 index 00000000..067f3c4e --- /dev/null +++ b/internal/oidc/browser/callback.html @@ -0,0 +1,8 @@ + + +meshStack CLI + +

{{.Title}}

+

{{.Message}}

+ + diff --git a/internal/oidc/browser/open_darwin.go b/internal/oidc/browser/open_darwin.go new file mode 100644 index 00000000..acdb4776 --- /dev/null +++ b/internal/oidc/browser/open_darwin.go @@ -0,0 +1,11 @@ +package browser + +import ( + "context" + "os/exec" +) + +func execBrowserOpen(ctx context.Context, authURL string) *exec.Cmd { + //nolint:gosec // the URL is this process's own authorization request, not input + return exec.CommandContext(ctx, "open", authURL) +} diff --git a/internal/oidc/browser/open_linux.go b/internal/oidc/browser/open_linux.go new file mode 100644 index 00000000..b1c5a0c6 --- /dev/null +++ b/internal/oidc/browser/open_linux.go @@ -0,0 +1,13 @@ +package browser + +import ( + "context" + "os/exec" +) + +// xdg-open comes from xdg-utils, so it is only there on the desktops that ship freedesktop.org +// tools. The BSDs have it too, but they are no release target and get no file of their own. +func execBrowserOpen(ctx context.Context, authURL string) *exec.Cmd { + //nolint:gosec // the URL is this process's own authorization request, not input + return exec.CommandContext(ctx, "xdg-open", authURL) +} diff --git a/internal/oidc/browser/open_windows.go b/internal/oidc/browser/open_windows.go new file mode 100644 index 00000000..95a6252c --- /dev/null +++ b/internal/oidc/browser/open_windows.go @@ -0,0 +1,13 @@ +package browser + +import ( + "context" + "os/exec" +) + +// url.dll's FileProtocolHandler rather than `start`, which is a cmd.exe builtin and would need a +// shell — and a shell would treat the & in the query string as a command separator. +func execBrowserOpen(ctx context.Context, authURL string) *exec.Cmd { + //nolint:gosec // the URL is this process's own authorization request, not input + return exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", authURL) +} diff --git a/internal/oidc/client.go b/internal/oidc/client.go new file mode 100644 index 00000000..fe817b49 --- /dev/null +++ b/internal/oidc/client.go @@ -0,0 +1,121 @@ +package oidc + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/url" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/json" + "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" + "github.com/meshcloud/meshstack-cli/internal/oidc/scope" +) + +type Client struct { + http.Client + + ClientConfig + + Id string +} + +type ClientConfig struct { + Issuer xurl.URL `json:"issuer"` + AuthorizationEndpoint xurl.URL `json:"authorization_endpoint"` + TokenEndpoint xurl.URL `json:"token_endpoint"` + // Both are optional in the discovery document, and either one ends a session. + EndSessionEndpoint *xurl.URL `json:"end_session_endpoint"` + RevocationEndpoint *xurl.URL `json:"revocation_endpoint"` +} + +func NewClient(ctx context.Context, httpClient http.Client, issuer xurl.URL, clientId string) (Client, error) { + clientConfig, err := httpClient.DoRequest[ClientConfig](ctx, http.MethodGet, + issuer.JoinPath(".well-known", "openid-configuration")) + if err != nil { + return Client{}, err + } + return Client{Client: httpClient, ClientConfig: clientConfig, Id: clientId}, nil +} + +type Token struct { + AccessToken jwt.JWT `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +func (c Client) Refresh(ctx context.Context, refreshToken string, scopes scope.Scopes) (resp Token, err error) { + resp, err = c.doPost[Token](ctx, c.TokenEndpoint.URL, map[string]any{ + "grant_type": "refresh_token", + "refresh_token": refreshToken, + "client_id": c.Id, + "scope": scopes.String(), + }) + if err != nil { + return resp, err + } + if resp.RefreshToken == "" { + // An identity provider that does not rotate its refresh tokens returns none here. + // Keycloak always rotates, so this branch is not the one a meshStack login takes. + slog.DebugContext(ctx, "Re-using previous refresh token as identity provider returned empty refresh token") + resp.RefreshToken = refreshToken + } + return +} + +// ExchangeAuthCode ends the authorization code flow, see AuthorizationCodeFlow.Exchange. +func (c Client) ExchangeAuthCode(ctx context.Context, code string, redirectUri xurl.URL, verifier string) (resp Token, err error) { + resp, err = c.doPost[Token](ctx, c.TokenEndpoint.URL, map[string]any{ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirectUri, + "client_id": c.Id, + "code_verifier": verifier, + }) + if err == nil && resp.RefreshToken == "" { + err = fmt.Errorf("the identity provider granted no refresh token, only the scopes %q", resp.Scope) + } + return +} + +func (c Client) EndSession(ctx context.Context, refreshToken string) error { + endpoint, payload := c.EndSessionEndpoint, map[string]any{ + "client_id": c.Id, + "refresh_token": refreshToken, + } + if endpoint == nil { + endpoint, payload = c.RevocationEndpoint, map[string]any{ + "client_id": c.Id, + "token": refreshToken, + "token_type_hint": "refresh_token", + } + } + if endpoint == nil { + return errors.New("the identity provider advertises neither an end_session_endpoint nor a revocation_endpoint") + } + _, err := c.doPost[any](ctx, endpoint.URL, payload) + return err +} + +func (c Client) doPost[R any](ctx context.Context, endpoint *url.URL, payload map[string]any) (result R, err error) { + result, err = c.DoRequest[R](ctx, http.MethodPost, endpoint, http.WithFormPayload(payload)) + if httpErr, ok := errors.AsType[http.Error](err); ok { + var protocolErr protocolError + if unmarshalErr := json.Unmarshal(httpErr.ResponseBody, &protocolErr); unmarshalErr != nil { + return result, errors.Join(httpErr, unmarshalErr) + } + return result, errors.Join(httpErr, protocolErr) + } + return +} + +type protocolError struct { + Code string `json:"error"` + Description string `json:"error_description"` +} + +func (e protocolError) Error() string { + return fmt.Sprintf("%s: %s", e.Code, e.Description) +} diff --git a/internal/oidc/jwt/claim.go b/internal/oidc/jwt/claim.go new file mode 100644 index 00000000..0c13ee7b --- /dev/null +++ b/internal/oidc/jwt/claim.go @@ -0,0 +1,61 @@ +package jwt + +import ( + "time" + + "github.com/meshcloud/meshstack-cli/internal/meshstack" +) + +type Claim[V any] struct { + key string + converter func(v any) V +} + +var ( + ExpiryClaim = Claim[Expiry]{ + key: "exp", + converter: func(v any) (expiry Expiry) { + // JSON numbers decode as float64, and exp counts seconds since the epoch. + seconds, ok := v.(float64) + if !ok { + return + } + expiry.Time = time.Unix(int64(seconds), 0) + return + }, + } + // WorkspaceClaim is written by keycloak's MC_CUSTOMER script mapper, which strips the c: scope + // prefix, so this carries the identifier rather than the scope. It is absent altogether where + // the user holds no role on the workspace the token was asked for, which is NoWorkspace here. + WorkspaceClaim = StringClaim[meshstack.Workspace]("MC_CUSTOMER") +) + +// StringClaim reads a claim written as a JSON string, and is the zero value of V where the claim +// is absent or is not a string. +func StringClaim[V ~string](key string) Claim[V] { + return Claim[V]{key: key, converter: func(claim any) V { + value, _ := claim.(string) + return V(value) + }} +} + +// getFrom is the zero value of V for a Claim with no converter. A claim's JSON value rarely has +// the type V itself, so asserting on it would pass off a wrong value as a missing one. +func (c Claim[V]) getFrom(jwt JWT) (value V) { + if c.converter == nil { + return + } + return c.converter(jwt.claims[c.key]) +} + +type Expiry struct { + time.Time +} + +func (expiry Expiry) Expired(grace time.Duration) bool { + if expiry == (Expiry{}) { + // A token that carries no exp claim counts as expired. + return true + } + return time.Until(expiry.Time) <= grace +} diff --git a/internal/oidc/jwt/jwt.go b/internal/oidc/jwt/jwt.go new file mode 100644 index 00000000..638b98b3 --- /dev/null +++ b/internal/oidc/jwt/jwt.go @@ -0,0 +1,46 @@ +package jwt + +import ( + "bytes" + "encoding" + "encoding/base64" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" +) + +var ( + _ encoding.TextUnmarshaler = &JWT{} + _ encoding.TextMarshaler = JWT{} +) + +type JWT struct { + encoded []byte + claims map[string]any +} + +func (jwt JWT) String() string { + return string(jwt.encoded) +} + +func (jwt JWT) GetClaim[V any](claim Claim[V]) V { + return claim.getFrom(jwt) +} + +func (jwt JWT) MarshalText() (text []byte, err error) { + return jwt.encoded, nil +} + +//goland:noinspection GoMixedReceiverTypes +func (jwt *JWT) UnmarshalText(text []byte) error { + jwt.encoded = text + parts := bytes.Split(jwt.encoded, []byte(".")) + if len(parts) != 3 { + return fmt.Errorf("the access token is not a JWT: it has %d dot-separated parts rather than 3", len(parts)) + } + payload := base64.NewDecoder(base64.RawURLEncoding, bytes.NewBuffer(parts[1])) + if err := json.UnmarshalDecode(jsontext.NewDecoder(payload), &jwt.claims); err != nil { + return fmt.Errorf("cannot parse JWT payload as JSON: %w", err) + } + return nil +} diff --git a/internal/oidc/jwt/jwt_test.go b/internal/oidc/jwt/jwt_test.go new file mode 100644 index 00000000..d9efb824 --- /dev/null +++ b/internal/oidc/jwt/jwt_test.go @@ -0,0 +1,82 @@ +package jwt + +import ( + _ "embed" + "encoding/json/v2" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/testutil/jsontest" +) + +var ( + //go:embed testdata/jwt_unscoped.json + unscopedToken []byte + //go:embed testdata/jwt_unscoped_no_exp.json + unscopedTokenNoExp []byte + //go:embed testdata/jwt_scoped.json + scopedToken []byte + //go:embed testdata/jwt_opaque.json + opaqueToken []byte + //go:embed testdata/jwt_not_base64.json + notBase64Token []byte + //go:embed testdata/jwt_not_json.json + notJsonToken []byte +) + +func TestJWTClaims(t *testing.T) { + t.Run("an unscoped token", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + token := jsontest.MustUnmarshal[JWT](t, unscopedToken) + expiry := ExpiryClaim.getFrom(token) + assert.Equal(t, Expiry{time.Unix(1767225600, 0)}, expiry) + assert.False(t, expiry.Expired(0)) + assert.True(t, expiry.Expired(time.Until(expiry.Time))) + synctest.Sleep(1000000 * time.Hour) + assert.True(t, expiry.Expired(30*time.Second)) + }) + }) + t.Run("an unscoped token without exp", func(t *testing.T) { + token := jsontest.MustUnmarshal[JWT](t, unscopedTokenNoExp) + expiry := ExpiryClaim.getFrom(token) + assert.Equal(t, Expiry{}, expiry) + assert.True(t, expiry.Expired(0)) + }) + t.Run("a token scoped to a workspace", func(t *testing.T) { + token := jsontest.MustUnmarshal[JWT](t, scopedToken) + // Verified against a live keycloak: the claim carries the identifier, not the c: scope the + // token was asked for. + assert.Equal(t, meshstack.Workspace("demo-partner"), WorkspaceClaim.getFrom(token)) + }) + t.Run("an unscoped token names no workspace", func(t *testing.T) { + token := jsontest.MustUnmarshal[JWT](t, unscopedToken) + assert.Equal(t, meshstack.NoWorkspace, WorkspaceClaim.getFrom(token)) + }) +} + +func TestJWTRejectsATokenItCannotRead(t *testing.T) { + for _, test := range []struct { + name string + payload []byte + wantErr assert.ErrorAssertionFunc + }{ + {"an opaque token", opaqueToken, func(t assert.TestingT, err error, args ...any) bool { + return assert.ErrorContains(t, err, "not a JWT", args...) + }}, + {"a payload that is not base64", notBase64Token, func(t assert.TestingT, err error, args ...any) bool { + return assert.ErrorContains(t, err, "cannot parse JWT payload as JSON", args...) + }}, + {"a payload that is not JSON", notJsonToken, func(t assert.TestingT, err error, args ...any) bool { + return assert.ErrorContains(t, err, "cannot parse JWT payload as JSON", args...) + }}, + } { + t.Run(test.name, func(t *testing.T) { + var token JWT + test.wantErr(t, json.Unmarshal(test.payload, &token)) + }) + } +} diff --git a/internal/oidc/jwt/testdata/jwt_not_base64.json b/internal/oidc/jwt/testdata/jwt_not_base64.json new file mode 100644 index 00000000..263c3a14 --- /dev/null +++ b/internal/oidc/jwt/testdata/jwt_not_base64.json @@ -0,0 +1 @@ +"bogus.a.bogus" diff --git a/internal/oidc/jwt/testdata/jwt_not_json.json b/internal/oidc/jwt/testdata/jwt_not_json.json new file mode 100644 index 00000000..0fcc090b --- /dev/null +++ b/internal/oidc/jwt/testdata/jwt_not_json.json @@ -0,0 +1 @@ +"bogus.cGxhaW4.bogus" diff --git a/internal/oidc/jwt/testdata/jwt_opaque.json b/internal/oidc/jwt/testdata/jwt_opaque.json new file mode 100644 index 00000000..b42a70e0 --- /dev/null +++ b/internal/oidc/jwt/testdata/jwt_opaque.json @@ -0,0 +1 @@ +"an-opaque-token" diff --git a/internal/oidc/jwt/testdata/jwt_scoped.json b/internal/oidc/jwt/testdata/jwt_scoped.json new file mode 100644 index 00000000..f884f4f8 --- /dev/null +++ b/internal/oidc/jwt/testdata/jwt_scoped.json @@ -0,0 +1 @@ +"bogus.eyJzdWIiOiJmN2MxIiwiZXhwIjoxNzY3MjI1NjAwLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqYW5lIiwiTUNfQ1VTVE9NRVIiOiJkZW1vLXBhcnRuZXIifQ.bogus" diff --git a/internal/oidc/jwt/testdata/jwt_unscoped.json b/internal/oidc/jwt/testdata/jwt_unscoped.json new file mode 100644 index 00000000..c40e4c1d --- /dev/null +++ b/internal/oidc/jwt/testdata/jwt_unscoped.json @@ -0,0 +1 @@ +"bogus.eyJzdWIiOiJmN2MxIiwiZXhwIjoxNzY3MjI1NjAwLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqYW5lIn0.bogus" diff --git a/internal/oidc/jwt/testdata/jwt_unscoped_no_exp.json b/internal/oidc/jwt/testdata/jwt_unscoped_no_exp.json new file mode 100644 index 00000000..22671bfe --- /dev/null +++ b/internal/oidc/jwt/testdata/jwt_unscoped_no_exp.json @@ -0,0 +1 @@ +"bogus.eyJzdWIiOiJmN2MxIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiamFuZSJ9.bogus" diff --git a/internal/oidc/scope/scope.go b/internal/oidc/scope/scope.go new file mode 100644 index 00000000..7e874fd2 --- /dev/null +++ b/internal/oidc/scope/scope.go @@ -0,0 +1,26 @@ +package scope + +import "strings" + +type ( + Scope string + Scopes []Scope +) + +// The standard scopes. offline_access is an optional client scope, so it has to be +// named or a login lasts as long as one access token. +const ( + OpenId Scope = "openid" + Profile Scope = "profile" + Email Scope = "email" + OfflineAccess Scope = "offline_access" +) + +// String renders the scope request parameter, which RFC 6749 defines as space-delimited. +func (s Scopes) String() string { + parts := make([]string, len(s)) + for i, one := range s { + parts[i] = string(one) + } + return strings.Join(parts, " ") +} diff --git a/internal/profile/credentials.go b/internal/profile/credentials.go new file mode 100644 index 00000000..1a68320d --- /dev/null +++ b/internal/profile/credentials.go @@ -0,0 +1,193 @@ +package profile + +import ( + "context" + "encoding/json/jsontext" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "reflect" + + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/json" + "github.com/meshcloud/meshstack-cli/internal/lock" +) + +const ( + credentialsVersion = 1 +) + +type Credentials struct { + credential.Credentials + + Version int `json:"version"` + FilePath string `json:"-"` + cacheLockers map[credential.Name]cacheLocker +} + +type cacheLocker struct { + lock.Locker + + CacheFilePath string +} + +func (p Profile) Credentials(ctx context.Context) (out Credentials, err error) { + out.FilePath = p.ConfigDir.CredentialsJsonFor(p.Name) + err = json.UnmarshalFrom(ctx, out.FilePath, &out) + if errors.Is(err, fs.ErrNotExist) { + out.Version = credentialsVersion + err = nil + } else if err != nil { + return + } else if out.Version != credentialsVersion { + err = fmt.Errorf("credentials file %s has version %d != %d", out.FilePath, out.Version, credentialsVersion) + return + } + + out.cacheLockers = make(map[credential.Name]cacheLocker, len(credential.Names)) + for _, credentialName := range credential.Names { + cacheFilePath := p.ConfigDir.CredentialsCacheJsonFor(p.Name, credentialName) + locker := cacheLocker{lock.New(cacheFilePath), cacheFilePath} + out.cacheLockers[credentialName] = locker + if cred := out.ByName(credentialName); cred != nil { + if err = locker.loadCache(ctx, cred); err != nil { + return + } + } + } + return +} + +func (p Profile) RemoveCredentials(ctx context.Context) error { + var errs []error + removeFileIfPresent := func(f string) { + err := os.Remove(f) + if err == nil { + slog.DebugContext(ctx, "Removed file "+f) + } else if !errors.Is(err, fs.ErrNotExist) { + errs = append(errs, fmt.Errorf("failed to remove %s: %w", f, err)) + } + } + removeFileIfPresent(p.ConfigDir.CredentialsJsonFor(p.Name)) + for _, credentialName := range credential.Names { + removeFileIfPresent(p.ConfigDir.CredentialsCacheJsonFor(p.Name, credentialName)) + } + return errors.Join(errs...) +} + +// cacheFile carries the identity the cached tokens were minted for, so that a cache +// written by another api key or after a rotated secret is dropped instead of sent. +type cacheFile struct { + Version int `json:"version"` + Identity string `json:"identity"` + Cache jsontext.Value `json:"cache,omitzero"` +} + +func (l cacheLocker) loadCache(ctx context.Context, cred credential.Credential) error { + return l.WithRLock(ctx, func() error { + _, err := l.readCache(ctx, cred) + return err + }) +} + +// readCache decodes the cache of cred, leaving it untouched when the file holds one minted for +// another identity. +func (l cacheLocker) readCache(ctx context.Context, cred credential.Credential) (onDisk bool, err error) { + var file cacheFile + err = json.UnmarshalFrom(ctx, l.CacheFilePath, &file) + if errors.Is(err, fs.ErrNotExist) { + err = nil + return + } else if err != nil { + return + } + onDisk = true + + switch { + case file.Version != credentialsVersion: + err = fmt.Errorf("credentials cache file %s has version %d != %d", l.CacheFilePath, file.Version, credentialsVersion) + case file.Identity != cred.Identity().Hash: + slog.WarnContext(ctx, fmt.Sprintf("Discarding cache %s, it was minted for another identity", l.CacheFilePath)) + default: + credential.WithCacheOf(cred, func(cache reflect.Value) { + err = json.Unmarshal(file.Cache, cache.Addr().Interface()) + }) + } + return +} + +func (l cacheLocker) writeCache(ctx context.Context, cred credential.Credential) (err error) { + file := cacheFile{ + Version: credentialsVersion, + Identity: cred.Identity().Hash, + } + credential.WithCacheOf(cred, func(cache reflect.Value) { + file.Cache, err = json.Marshal(cache.Interface()) + }) + if err != nil { + return err + } + return json.MarshalTo(ctx, l.CacheFilePath, file, json.UserOnlyFilePerms()) +} + +func (c Credentials) ReadCache(ctx context.Context, cred credential.Credential, action func() error) (err error) { + if !credential.WithCacheOf(cred, func(_ reflect.Value) { + err = c.cacheLockers[c.NameOf(cred)].WithRLock(ctx, func() error { + return action() + }) + }) { + err = action() + } + return +} + +func (c Credentials) ModifyCache(ctx context.Context, cred credential.Credential, action func() error) (err error) { + if !credential.WithCacheOf(cred, func(_ reflect.Value) { + locker := c.cacheLockers[c.NameOf(cred)] + err = locker.WithLock(ctx, func() (err error) { + // The exclusive lock is held anyway, so pick up what another process wrote first. A + // cache is only written back when one is already on disk: a caller that never stored + // its credentials gets no file as a side effect of using them. + onDisk, err := locker.readCache(ctx, cred) + if err != nil { + return err + } + // A failed action still gets its cache written, because it may have rotated a refresh + // token before failing, see [credential.OidcLogin.RefreshCachedToken]. + err = action() + if onDisk { + if writeErr := locker.writeCache(ctx, cred); writeErr != nil { + slog.DebugContext(ctx, fmt.Sprintf("Cannot update cache %s, continuing with the token in memory: %s", locker.CacheFilePath, writeErr.Error())) + } + } + return + }) + }) { + err = action() + } + return +} + +func (c Credentials) Store(ctx context.Context) error { + var errs []error + errs = append(errs, json.MarshalTo(ctx, c.FilePath, c, json.UserOnlyFilePerms())) + for _, credentialName := range credential.Names { + cred := c.ByName(credentialName) + if cred == nil { + continue + } + + locker := c.cacheLockers[credentialName] + credential.WithCacheOf(cred, func(cache reflect.Value) { + if cache.IsNil() { + return + } + errs = append(errs, locker.WithLock(ctx, func() error { + return locker.writeCache(ctx, cred) + })) + }) + } + return errors.Join(errs...) +} diff --git a/internal/profile/name.go b/internal/profile/name.go new file mode 100644 index 00000000..5a4fecc4 --- /dev/null +++ b/internal/profile/name.go @@ -0,0 +1,59 @@ +package profile + +import ( + "encoding" + "fmt" + "regexp" + + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +type Name string + +var NameSetting = setting.Setting[Name]{ + Env: "MESHSTACK_PROFILE", + Short: func(envKey string) string { + return fmt.Sprintf("The profile whose credentials and defaults this run uses. Also read from %s.", envKey) + }, + Long: func(envKey string) string { + return fmt.Sprintf("The profile whose credentials and defaults this run uses, also read from `%s`.\n\n"+ + "A profile is a named bundle of endpoint and credential, written by "+ + "`meshstack auth login` into the meshStack CLI's configuration directory. It supplies each of those "+ + "only where nothing above it did, so it is never an override.\n\n"+ + "With no name given, the profile is the one whose endpoint matches the endpoint in use, else the one "+ + "the last `meshstack auth login` selected, else `default`.", envKey) + }, + Default: setting.StaticDefault("default"), + Parse: setting.ParseTextUnmarshaler[Name], +} + +var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) + +func parseName(name string) (Name, error) { + if nameRegex.MatchString(name) { + return Name(name), nil + } + return "", fmt.Errorf("a profile name must match %s", nameRegex) +} + +var ( + _ encoding.TextMarshaler = Name("") + _ encoding.TextUnmarshaler = new(Name("")) +) + +func (n Name) String() string { + return string(n) +} + +func (n Name) MarshalText() ([]byte, error) { + if _, err := parseName(string(n)); err != nil { + return nil, err + } + return []byte(n), nil +} + +//goland:noinspection GoMixedReceiverTypes +func (n *Name) UnmarshalText(text []byte) (err error) { + *n, err = parseName(string(text)) + return +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 00000000..03155f5b --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,58 @@ +package profile + +import ( + "context" + "fmt" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/config" + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +//nolint:recvcheck // only exception is init() to set fields after unmarshalling +type Profile struct { + Endpoint *xurl.URL `json:"endpoint,omitzero"` + DefaultWorkspace meshstack.Workspace `json:"default_workspace,omitzero"` + Credential credential.Name `json:"credential,omitzero"` + + // Name and ConfigDir are set by [Profile.init] after load or create, not by the JSON. + Name Name `json:"-"` + ConfigDir config.Directory `json:"-"` +} + +func (p Profile) String() string { + return string(p.Name) +} + +// EndpointSource ranks below the environment, which is what lets ResolveSession catch an endpoint +// that does not match the profile rather than quietly using the profile's own. +func (p Profile) EndpointSource() setting.FallbackSource { + return setting.FallbackSource{Source: setting.LookupSource{ + Description: fmt.Sprintf("endpoint in profile %s", p.Name), + Func: func(_ context.Context) (string, error) { + if p.Endpoint != nil { + return p.Endpoint.String(), nil + } + return "", nil + }, + }} +} + +// WorkspaceSource ranks below the environment and below an interactive prompt: it is what a login +// remembered, never an override of what this run asks for. +func (p Profile) WorkspaceSource() setting.FallbackSource { + return setting.FallbackSource{Source: setting.LookupSource{ + Description: fmt.Sprintf("default workspace in profile %s", p.Name), + Func: func(_ context.Context) (string, error) { + return string(p.DefaultWorkspace), nil + }, + }} +} + +//goland:noinspection GoMixedReceiverTypes +func (p *Profile) init(name Name, configDir config.Directory) { + p.Name = name + p.ConfigDir = configDir +} diff --git a/internal/profile/profiles.go b/internal/profile/profiles.go new file mode 100644 index 00000000..48d15dae --- /dev/null +++ b/internal/profile/profiles.go @@ -0,0 +1,86 @@ +package profile + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + + "github.com/meshcloud/meshstack-cli/internal/config" + "github.com/meshcloud/meshstack-cli/internal/json" + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +const ( + version = 1 +) + +type Profiles struct { + Version int `json:"version"` + CurrentProfile Name `json:"currentProfile,omitzero"` + Profiles map[Name]*Profile `json:"profiles,omitzero"` + + configDir config.Directory +} + +func LoadProfiles(ctx context.Context, opts ResolveProfileOptions) (profiles Profiles, err error) { + profiles.configDir, err = opts.ResolveSetting(ctx, config.DirectorySetting) + if err != nil { + return + } + err = json.UnmarshalFrom(ctx, profiles.configDir.ProfilesJson(), &profiles, json.ModifyAfterUnmarshal(func(target *map[Name]*Profile) { + for name, profile := range *target { + if profile == nil { + continue // a null entry in the file; validate reports it + } + profile.init(name, profiles.configDir) + } + })) + if errors.Is(err, fs.ErrNotExist) { + err = initEmptyProfiles(ctx, opts, &profiles) + } else if err == nil { + err = profiles.validate() + } + return +} + +func (ps Profiles) Store(ctx context.Context) error { + return json.MarshalTo(ctx, ps.configDir.ProfilesJson(), ps) +} + +func initEmptyProfiles(ctx context.Context, opts ResolveProfileOptions, profiles *Profiles) error { + profiles.Version = version + if name, err := opts.ResolveSetting(ctx, NameSetting); err != nil { + return err + } else { + slog.InfoContext(ctx, fmt.Sprintf("Initializing first-time use profile '%s'", name)) + profiles.CurrentProfile = name + } + + currentProfile := &Profile{} + currentProfile.init(profiles.CurrentProfile, profiles.configDir) + profiles.Profiles = map[Name]*Profile{profiles.CurrentProfile: currentProfile} + + if endpoint, err := opts.ResolveSetting(ctx, meshstack.EndpointSetting); err == nil { + currentProfile.Endpoint = &endpoint + } else if !errors.Is(err, setting.ErrNoSourceProvidedValue) { + return err + } + + slog.DebugContext(ctx, fmt.Sprintf("Initial profile %s resolved to %+v", profiles.CurrentProfile, *currentProfile)) + return nil +} + +func (ps Profiles) validate() (err error) { + if ps.Version != version { + err = errors.Join(err, fmt.Errorf("version in %s mismatch %d vs expected %d", ps.configDir, ps.Version, version)) + } + for name, profile := range ps.Profiles { + if profile == nil { + err = errors.Join(err, fmt.Errorf("profile '%s' in %s is null", name, ps.configDir)) + } + } + return +} diff --git a/internal/profile/resolve.go b/internal/profile/resolve.go new file mode 100644 index 00000000..e61b1662 --- /dev/null +++ b/internal/profile/resolve.go @@ -0,0 +1,88 @@ +package profile + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +type ( + SettingSources = setting.Sources + ResolveProfileOptions struct { + SettingSources + } +) + +// ResolveProfile loads every profile from disk, creating a default one when there is none. The +// returned *Profile points into Profiles.Profiles, so a change through the pointer is persisted by +// a later Profiles.Store. +func ResolveProfile(ctx context.Context, opts ResolveProfileOptions) (*Profile, Profiles, error) { + loadProfiles := sync.OnceValues(func() (Profiles, error) { + return LoadProfiles(ctx, opts) + }) + + // Both are fallback sources, so a name given in MESHSTACK_PROFILE wins over what is on disk, + // as NameSetting's own help text says it does. The endpoint match comes first, so that a run + // against a known endpoint picks its profile rather than the one last selected. + endpointMatchingSource := setting.FallbackSource{Source: setting.LookupSource{ + Description: "unique match by endpoint", + Func: func(ctx context.Context) (string, error) { + profiles, err := loadProfiles() + if err != nil { + return "", err + } + return profiles.findProfileNameByMatchingEndpoint(ctx, opts) + }, + }} + + currentProfileSource := setting.FallbackSource{Source: setting.LookupSource{ + Description: "current profile", + Func: func(_ context.Context) (string, error) { + profiles, err := loadProfiles() + return string(profiles.CurrentProfile), err + }, + }} + + name, err := opts.ResolveSetting(ctx, NameSetting, + endpointMatchingSource, + currentProfileSource, + ) + if err != nil { + return nil, Profiles{}, err + } + + if profiles, err := loadProfiles(); err != nil { + return nil, profiles, err + } else if profile, ok := profiles.Profiles[name]; !ok { + return nil, profiles, fmt.Errorf("no profile found with name %s", name) + } else { + return profile, profiles, nil + } +} + +func (ps Profiles) findProfileNameByMatchingEndpoint(ctx context.Context, opts ResolveProfileOptions) (string, error) { + endpoint, err := opts.ResolveSetting(ctx, meshstack.EndpointSetting) + if errors.Is(err, setting.ErrNoSourceProvidedValue) { + slog.DebugContext(ctx, "No endpoint known at this point, cannot search for matching profile") + return "", nil + } else if err != nil { + return "", err + } + var matchingProfiles []*Profile + for _, profile := range ps.Profiles { + if profile.Endpoint != nil && profile.Endpoint.Equal(endpoint) { + matchingProfiles = append(matchingProfiles, profile) + } + } + if len(matchingProfiles) == 1 { + profile := matchingProfiles[0] + slog.DebugContext(ctx, fmt.Sprintf("Using profile %s by uniquely matching endpoint '%s'", profile, endpoint)) + return string(profile.Name), nil + } + return "", nil +} diff --git a/internal/profile/resolve_test.go b/internal/profile/resolve_test.go new file mode 100644 index 00000000..2da6cb54 --- /dev/null +++ b/internal/profile/resolve_test.go @@ -0,0 +1,195 @@ +package profile + +import ( + "context" + _ "embed" + "os" + "testing" + "uuid" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/config" + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" + "github.com/meshcloud/meshstack-cli/internal/setting" + "github.com/meshcloud/meshstack-cli/internal/setting/setting_test" + "github.com/meshcloud/meshstack-cli/internal/testutil/jsontest" +) + +//go:embed testdata/jwt.json +var jwtJson []byte + +func TestResolveProfileCreatesADefaultProfileWhenTheConfigDirectoryIsMissing(t *testing.T) { + givenNoMeshstackEnvironment(t) + t.Setenv(config.DirectorySetting.EnvKey(), "really-does-not-exists/and-should-never-exist/so-thats-a-unique-path") + + currentProfile, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) + + require.NoError(t, err) + expected := &Profile{Name: "default"} + assert.EqualExportedValues(t, expected, withoutConfigDir(currentProfile)) + assertProfiles(t, profiles, expected) +} + +func TestResolveProfilePrefersAProfileNameFromAFrontEndSourceOverTheEnvironment(t *testing.T) { + givenNoMeshstackEnvironment(t) + t.Setenv(config.DirectorySetting.EnvKey(), t.TempDir()) + t.Setenv(NameSetting.EnvKey(), "from-the-environment") + + currentProfile, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{ + SettingSources: profileNameFromFrontend("from-the-front-end"), + }) + + require.NoError(t, err) + expected := &Profile{Name: "from-the-front-end"} + assert.EqualExportedValues(t, expected, withoutConfigDir(currentProfile)) + assertProfiles(t, profiles, expected) +} + +func TestResolveProfileFindsAStoredProfileWithoutItsNameOrEndpointInTheEnvironment(t *testing.T) { + givenNoMeshstackEnvironment(t) + t.Setenv(config.DirectorySetting.EnvKey(), t.TempDir()) + t.Setenv(NameSetting.EnvKey(), "dev-local") + // Upper case and a trailing slash, so that the stored endpoint shows the canonical form. + t.Setenv(meshstack.EndpointSetting.EnvKey(), "https://LOCALHOST:1337/") + devLocal := &Profile{Name: "dev-local", Endpoint: new(xurl.MustParsef("https://localhost:%d", 1337))} + cachedToken := jsontest.MustUnmarshal[jwt.JWT](t, jwtJson) + + currentProfile, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) + require.NoError(t, err) + assert.EqualExportedValues(t, devLocal, withoutConfigDir(currentProfile)) + assertProfiles(t, profiles, devLocal) + require.NoError(t, profiles.Store(t.Context())) + storeApiKeyWithCachedToken(t, currentProfile, cachedToken) + + // An empty value is skipped as no value at all, so this is the unset case. + t.Setenv(NameSetting.EnvKey(), "") + t.Setenv(meshstack.EndpointSetting.EnvKey(), "") + reloaded, reloadedProfiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) + + require.NoError(t, err) + assert.EqualExportedValues(t, devLocal, withoutConfigDir(reloaded)) + assertProfiles(t, reloadedProfiles, devLocal) + assertApiKeyWithCachedToken(t, reloaded, "08be9109-45bf-42ba-a965-2097b9d0d181", "super-test-secret", cachedToken) +} + +func TestResolveProfileReadsTheCurrentProfileAndItsCachedTokenFromDisk(t *testing.T) { + givenNoMeshstackEnvironment(t) + t.Setenv(config.DirectorySetting.EnvKey(), "testdata/configdir") + + currentProfile, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) + + require.NoError(t, err) + devLocal := &Profile{Name: "dev-local", Endpoint: new(xurl.MustParsef("https://localhost:1337")), Credential: "apiKey"} + assert.EqualExportedValues(t, devLocal, withoutConfigDir(currentProfile)) + assertProfiles(t, profiles, + devLocal, + &Profile{Name: "default", Endpoint: new(xurl.MustParsef("https://api.dev.meshcloud.io/"))}, + &Profile{Name: "empty"}, + ) + assertApiKeyWithCachedToken(t, currentProfile, + "08be9109-45bf-42ba-a965-2097b9d0d181", "super-test-secret", jsontest.MustUnmarshal[jwt.JWT](t, jwtJson)) + + // Storing what was just loaded leaves the files as they are, so testdata stays the fixture. + require.NoError(t, profiles.Store(t.Context())) +} + +func TestLoadProfilesRejectsANullProfile(t *testing.T) { + givenNoMeshstackEnvironment(t) + configDir := t.TempDir() + t.Setenv(config.DirectorySetting.EnvKey(), configDir) + require.NoError(t, os.WriteFile(config.Directory(configDir).ProfilesJson(), + []byte(`{"version":1,"profiles":{"broken":null}}`), 0o600)) + + _, err := LoadProfiles(t.Context(), ResolveProfileOptions{}) + + require.ErrorContains(t, err, "'broken'") +} + +// givenNoMeshstackEnvironment keeps a developer's own shell out of the resolutions under test, +// which would otherwise put its endpoint into every profile created here. +func givenNoMeshstackEnvironment(t *testing.T) { + t.Helper() + for _, envKey := range []string{ + config.DirectorySetting.EnvKey(), + NameSetting.EnvKey(), + meshstack.EndpointSetting.EnvKey(), + meshstack.WorkspaceSetting.EnvKey(), + } { + t.Setenv(envKey, "") + } +} + +func profileNameFromFrontend(name Name) SettingSources { + return setting.Sources{setting.FrontendSource{Source: setting_test.LookupFunc( + func(_ context.Context, key string) (string, error) { + if key == NameSetting.EnvKey() { + return string(name), nil + } + return "", nil + }, + )}} +} + +func storeApiKeyWithCachedToken(t *testing.T, p *Profile, token jwt.JWT) { + t.Helper() + creds, err := p.Credentials(t.Context()) + require.NoError(t, err) + creds.SetIdentity(&credential.ApiKey{ + Endpoint: *p.Endpoint, + ClientId: uuid.MustParse("08be9109-45bf-42ba-a965-2097b9d0d181"), + ClientSecret: "super-test-secret", + }) + require.NoError(t, creds.Store(t.Context())) + require.NoError(t, creds.ModifyCache(t.Context(), creds.ApiKey, func() error { + creds.ApiKey.Cache = &struct { + Token jwt.JWT `json:"token,omitzero"` + }{Token: token} + return nil + })) + require.NoError(t, creds.Store(t.Context())) +} + +func assertApiKeyWithCachedToken(t *testing.T, p *Profile, clientId, clientSecret string, token jwt.JWT) { + t.Helper() + creds, err := p.Credentials(t.Context()) + require.NoError(t, err) + require.NotNil(t, creds.ApiKey) + assert.Equal(t, uuid.MustParse(clientId), creds.ApiKey.ClientId) + assert.Equal(t, clientSecret, creds.ApiKey.ClientSecret) + require.NoError(t, creds.ReadCache(t.Context(), creds.ApiKey, func() error { + assert.Equal(t, token, creds.ApiKey.Cache.Token) + return nil + })) +} + +func assertProfiles(t *testing.T, actual Profiles, expected ...*Profile) { + t.Helper() + expectedProfiles := Profiles{Version: 1} + if len(expected) > 0 { + expectedProfiles.CurrentProfile = expected[0].Name + } + expectedProfiles.Profiles = make(map[Name]*Profile, len(expected)) + for _, profile := range expected { + expectedProfiles.Profiles[profile.Name] = profile + } + // ConfigDir is whatever directory the run resolved, so it is asserted non-empty rather than compared. + actualStripped := actual + actualStripped.Profiles = make(map[Name]*Profile, len(actual.Profiles)) + for name, actualProfile := range actual.Profiles { + assert.NotEmpty(t, actualProfile.ConfigDir) + actualStripped.Profiles[name] = withoutConfigDir(actualProfile) + } + assert.EqualExportedValues(t, expectedProfiles, actualStripped) + assert.NotEmpty(t, actual.configDir) +} + +func withoutConfigDir(p *Profile) *Profile { + stripped := *p + stripped.ConfigDir = "" + return &stripped +} diff --git a/internal/profile/testdata/configdir/.gitignore b/internal/profile/testdata/configdir/.gitignore new file mode 100644 index 00000000..f4ac7532 --- /dev/null +++ b/internal/profile/testdata/configdir/.gitignore @@ -0,0 +1,2 @@ +*.lock +*.tmp* diff --git a/internal/profile/testdata/configdir/credentials-cache/dev-local/apiKey.json b/internal/profile/testdata/configdir/credentials-cache/dev-local/apiKey.json new file mode 100644 index 00000000..e4f2cf7f --- /dev/null +++ b/internal/profile/testdata/configdir/credentials-cache/dev-local/apiKey.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "identity": "e56b12dfb5ce5c6142f6524a022d9cc40a15517b3c3dc69fcff69775e6154b0e", + "cache": { + "token": "bogus.eyJzdWIiOiJmN2MxIiwiZXhwIjoxNzY3MjI1NjAwLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqYW5lIn0.bogus" + } +} diff --git a/internal/profile/testdata/configdir/credentials/dev-local.json b/internal/profile/testdata/configdir/credentials/dev-local.json new file mode 100644 index 00000000..02ef0af2 --- /dev/null +++ b/internal/profile/testdata/configdir/credentials/dev-local.json @@ -0,0 +1,8 @@ +{ + "apiKey": { + "Endpoint": "https://localhost:1337", + "client_id": "08be9109-45bf-42ba-a965-2097b9d0d181", + "client_secret": "super-test-secret" + }, + "version": 1 +} diff --git a/internal/profile/testdata/configdir/profiles.json b/internal/profile/testdata/configdir/profiles.json new file mode 100644 index 00000000..4b224bc6 --- /dev/null +++ b/internal/profile/testdata/configdir/profiles.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "currentProfile": "dev-local", + "profiles": { + "default": { + "endpoint": "https://api.dev.meshcloud.io" + }, + "dev-local": { + "endpoint": "https://localhost:1337", + "credential": "apiKey" + }, + "empty": {} + } +} diff --git a/internal/profile/testdata/jwt.json b/internal/profile/testdata/jwt.json new file mode 100644 index 00000000..c40e4c1d --- /dev/null +++ b/internal/profile/testdata/jwt.json @@ -0,0 +1 @@ +"bogus.eyJzdWIiOiJmN2MxIiwiZXhwIjoxNzY3MjI1NjAwLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqYW5lIn0.bogus" diff --git a/internal/setting/env.go b/internal/setting/env.go new file mode 100644 index 00000000..5b223386 --- /dev/null +++ b/internal/setting/env.go @@ -0,0 +1,24 @@ +package setting + +import ( + "context" + "fmt" + "os" +) + +type EnvKey string + +func (k EnvKey) Lookup(_ context.Context, key string) (string, error) { + if k != EnvKey(key) { + // A panic, not an error: an EnvKey asked for another setting's key can only be a wiring + // mistake, and returning empty would hide it as a missing value. + panic(fmt.Sprintf("env key %s does not match %s", k, key)) + } + return os.Getenv(string(k)), nil +} + +func (k EnvKey) Describe(key string) string { + return "environment variable " + key +} + +var _ Source = EnvKey("") diff --git a/internal/setting/resolve.go b/internal/setting/resolve.go new file mode 100644 index 00000000..53fc78c1 --- /dev/null +++ b/internal/setting/resolve.go @@ -0,0 +1,75 @@ +package setting + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" +) + +var ErrNoSourceProvidedValue = errors.New("no source provided a value") + +type Sources []Source + +// ResolveSetting takes the value from the first source that carries one, in this order: +// FrontendSource, any other source, the environment, FallbackSource, the setting's default. +// Two sources of the same kind keep the order they were given in. It returns +// ErrNoSourceProvidedValue when every source stayed empty. +func (sources Sources) ResolveSetting[T any](ctx context.Context, setting Setting[T], extraSources ...Source) (value T, err error) { + var emptySources Sources + defer func() { + if err != nil { + errs := []error{err} + for _, source := range emptySources { + if _, isDefault := source.(DefaultSource); isDefault { + continue + } else if describeSource := source.Describe(setting.EnvKey()); describeSource != "" { + errs = append(errs, fmt.Errorf("try setting %s", describeSource)) + } + } + err = errors.Join(errs...) + } + }() + + var frontendSources, otherSources, fallbackSources Sources + for _, source := range slices.Concat(sources, extraSources) { + if _, isFrontend := source.(FrontendSource); isFrontend { + frontendSources = append(frontendSources, source) + } else if _, isFallback := source.(FallbackSource); isFallback { + fallbackSources = append(fallbackSources, source) + } else { + otherSources = append(otherSources, source) + } + } + + allSources := slices.Concat( + frontendSources, + otherSources, + Sources{setting.Env}, + fallbackSources, + setting.Default.asSourcesIfPresent(), + ) + + for _, source := range allSources { + if source == nil { + continue + } + var text string + text, err = source.Lookup(ctx, setting.EnvKey()) + if err != nil { + return value, fmt.Errorf("value from source '%s' could not be looked up: %w", source.Describe(setting.EnvKey()), err) + } + text = strings.TrimSpace(text) + if text == "" { + emptySources = append(emptySources, source) + continue + } + value, err = setting.Parse(text) + if err != nil { + return value, fmt.Errorf("value from source '%s' could not be parsed: %w", source.Describe(setting.EnvKey()), err) + } + return + } + return value, fmt.Errorf("%w for %s", ErrNoSourceProvidedValue, setting.EnvKey()) +} diff --git a/internal/setting/resolve_test.go b/internal/setting/resolve_test.go new file mode 100644 index 00000000..b47352ee --- /dev/null +++ b/internal/setting/resolve_test.go @@ -0,0 +1,120 @@ +package setting_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/setting" + "github.com/meshcloud/meshstack-cli/internal/setting/setting_test" +) + +func TestResolveSettingPrecedence(t *testing.T) { + staticSource := func(value string) setting.Source { + return setting_test.LookupFunc(func(_ context.Context, _ string) (string, error) { + return value, nil + }) + } + precedence := setting.Setting[string]{ + Env: "MESHSTACK_TEST_PRECEDENCE", + Default: setting.StaticDefault("from the default"), + Parse: setting.ParseText[string], + } + var ( + frontend = setting.FrontendSource{Source: staticSource("from the front end")} + other = staticSource("from another source") + fallback = setting.FallbackSource{Source: staticSource("from the fallback")} + ) + + tests := []struct { + name string + sources setting.Sources + extra setting.Sources + env string + want string + }{ + { + name: "a front end source outranks every other kind", + sources: setting.Sources{fallback, other, frontend}, + env: "from the environment", + want: "from the front end", + }, + { + name: "an extra source ranks as the sources it is resolved with", + sources: setting.Sources{fallback}, + extra: setting.Sources{frontend}, + env: "from the environment", + want: "from the front end", + }, + { + name: "another source outranks the environment", + sources: setting.Sources{fallback, other}, + env: "from the environment", + want: "from another source", + }, + { + name: "the environment outranks a fallback source", + sources: setting.Sources{fallback}, + env: "from the environment", + want: "from the environment", + }, + { + name: "a fallback source outranks the default", + sources: setting.Sources{fallback}, + want: "from the fallback", + }, + { + name: "the default is last", + want: "from the default", + }, + // Two of the same kind keeping their order is what lets an interactive prompt outrank the + // profile default: the prompt is a source of the front end, the profile an extra source. + { + name: "the first of two front end sources wins", + sources: setting.Sources{frontend, setting.FrontendSource{Source: staticSource("from a second front end source")}}, + want: "from the front end", + }, + { + name: "a front end source outranks one given as an extra source", + sources: setting.Sources{frontend}, + extra: setting.Sources{setting.FrontendSource{Source: staticSource("from a second front end source")}}, + want: "from the front end", + }, + { + name: "a fallback source outranks one given as an extra source", + sources: setting.Sources{fallback}, + extra: setting.Sources{setting.FallbackSource{Source: staticSource("from a second fallback source")}}, + want: "from the fallback", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // An empty value is skipped as no value at all, so this is also the unset case. + t.Setenv(precedence.EnvKey(), tt.env) + value, err := tt.sources.ResolveSetting(t.Context(), precedence, tt.extra...) + require.NoError(t, err) + assert.Equal(t, tt.want, value) + }) + } +} + +func TestResolveSettingNamesEverySourceThatCouldHaveCarriedTheValue(t *testing.T) { + withoutDefault := setting.Setting[string]{ + Env: "MESHSTACK_TEST_NO_VALUE", + Parse: setting.ParseText[string], + } + t.Setenv(withoutDefault.EnvKey(), "") + prompt := setting.FrontendSource{Source: setting.LookupSource{ + Description: "the --endpoint flag", + Func: func(context.Context) (string, error) { return "", nil }, + }} + + _, err := setting.Sources{prompt}.ResolveSetting(t.Context(), withoutDefault) + + require.ErrorIs(t, err, setting.ErrNoSourceProvidedValue) + require.ErrorContains(t, err, "MESHSTACK_TEST_NO_VALUE") + require.ErrorContains(t, err, "try setting the --endpoint flag") + require.ErrorContains(t, err, "try setting environment variable MESHSTACK_TEST_NO_VALUE") +} diff --git a/internal/setting/setting.go b/internal/setting/setting.go new file mode 100644 index 00000000..72533ce7 --- /dev/null +++ b/internal/setting/setting.go @@ -0,0 +1,64 @@ +package setting + +import ( + "encoding" + "strings" +) + +// Setting declares one input. Its environment variable key is also its identity, so every setting +// can be controlled through the environment and there is no second identifier to hold in step. +type Setting[T any] struct { + Env EnvKey + + // Short is plain text for a cobra flag, Long is Markdown for the Terraform provider's schema. + // Both state facts about the setting rather than about a front end, so neither says "flag", + // "block" or "attribute". + Short func(envKey string) string + Long func(envKey string) string + + Default DefaultSource + // Parse always receives a non-empty string: Resolve trims what a source returned and skips + // that source when nothing is left. + Parse func(v string) (T, error) +} + +func (s Setting[T]) EnvKey() string { + return string(s.Env) +} + +func (s Setting[T]) Help() string { + if s.Short == nil { + return "" + } + return s.Short(s.EnvKey()) +} + +func (s Setting[T]) HelpMarkdown() string { + if s.Long == nil { + return s.Help() + } + if long := s.Long(s.EnvKey()); long != "" { + return long + } + return s.Help() +} + +func ParseText[T ~string](s string) (T, error) { return T(s), nil } + +func ParseBool(s string) (bool, error) { + switch strings.ToLower(s) { + case "n", "no", "0", "false": + return false, nil + default: + return true, nil + } +} + +func ParseTextUnmarshaler[T any, P interface { + *T + encoding.TextUnmarshaler +}](s string) (T, error) { + instance := new(T) + err := P(instance).UnmarshalText(([]byte)(s)) + return *instance, err +} diff --git a/internal/setting/setting_test.go b/internal/setting/setting_test.go new file mode 100644 index 00000000..13294d98 --- /dev/null +++ b/internal/setting/setting_test.go @@ -0,0 +1,30 @@ +package setting_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +func TestParseBoolReadsOnlyASpellingOfNoAsFalse(t *testing.T) { + falseSpellings := []string{"false", "FALSE", "no", "n", "N", "0"} + for _, spelling := range falseSpellings { + t.Run(spelling, func(t *testing.T) { + parsed, err := setting.ParseBool(spelling) + require.NoError(t, err) + assert.False(t, parsed) + }) + } + + trueSpellings := []string{"true", "TRUE", "yes", "y", "1", "whatever-else"} + for _, spelling := range trueSpellings { + t.Run(spelling, func(t *testing.T) { + parsed, err := setting.ParseBool(spelling) + require.NoError(t, err) + assert.True(t, parsed) + }) + } +} diff --git a/internal/setting/setting_test/setting.go b/internal/setting/setting_test/setting.go new file mode 100644 index 00000000..122a50bd --- /dev/null +++ b/internal/setting/setting_test/setting.go @@ -0,0 +1,20 @@ +package setting_test + +import ( + "context" + "fmt" + + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +type LookupFunc func(ctx context.Context, key string) (string, error) + +func (l LookupFunc) Lookup(ctx context.Context, key string) (string, error) { + return l(ctx, key) +} + +func (l LookupFunc) Describe(key string) string { + return fmt.Sprintf("%T for %s", l, key) +} + +var _ setting.Source = LookupFunc(nil) diff --git a/internal/setting/source.go b/internal/setting/source.go new file mode 100644 index 00000000..ca1a8cec --- /dev/null +++ b/internal/setting/source.go @@ -0,0 +1,66 @@ +package setting + +import "context" + +type Source interface { + // Lookup returns an empty string and no error when it carries no value. An error stops the + // whole resolution, so return one only for a condition no other source can make up for. + Lookup(ctx context.Context, key string) (string, error) + // Describe names this source to a person, so that a failed resolution can list what to set. + Describe(key string) string +} + +type DefaultSource func() (string, error) + +var _ Source = DefaultSource(nil) + +func (d DefaultSource) Lookup(_ context.Context, _ string) (string, error) { + return d() +} + +func (d DefaultSource) Describe(key string) string { + return "default value for " + key +} + +func (d DefaultSource) asSourcesIfPresent() Sources { + if d == nil { + return nil + } + return Sources{d} +} + +func StaticDefault(v string) DefaultSource { + return func() (string, error) { + return v, nil + } +} + +type LookupSource struct { + MatchingKey string + Description string + Func func(ctx context.Context) (string, error) +} + +func (s LookupSource) Lookup(ctx context.Context, key string) (string, error) { + if s.MatchingKey != "" && s.MatchingKey != key { + return "", nil + } + return s.Func(ctx) +} + +func (s LookupSource) Describe(key string) string { + if s.MatchingKey != "" && s.MatchingKey != key { + return "" + } + return s.Description +} + +// FallbackSource ranks the wrapped source below the environment, see Sources.ResolveSetting. +type FallbackSource struct { + Source +} + +// FrontendSource ranks the wrapped source above the environment, see Sources.ResolveSetting. +type FrontendSource struct { + Source +} diff --git a/internal/testutil/jsontest/jsontest.go b/internal/testutil/jsontest/jsontest.go new file mode 100644 index 00000000..c5964723 --- /dev/null +++ b/internal/testutil/jsontest/jsontest.go @@ -0,0 +1,16 @@ +// Package jsontest holds the json helpers that only a test needs. +package jsontest + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/json" +) + +func MustUnmarshal[T any](t *testing.T, in []byte) (out T) { + t.Helper() + require.NoError(t, json.Unmarshal(in, &out)) + return +} diff --git a/internal/testutil/testserver/testserver.go b/internal/testutil/testserver/testserver.go new file mode 100644 index 00000000..86b00b8b --- /dev/null +++ b/internal/testutil/testserver/testserver.go @@ -0,0 +1,214 @@ +// Package testserver stands in for meshStack's login endpoint and for one endpoint behind it, +// so that a test can drive the token cache without a backend. +// +// It imports internal/auth for the api key settings. A package whose own tests live in the +// internal test package therefore cannot use it without creating an import cycle, and has to +// move those tests to an external test package first. +package testserver + +import ( + "context" + "encoding/base64" + "encoding/json/v2" + "fmt" + "io" + gohttp "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/auth" +) + +const greeting = "hello" + +type ApiKey struct { + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + +// SetEnv points the api key settings at this key for the rest of the test, so that the code +// under test resolves this credential. +func (k ApiKey) SetEnv(t *testing.T) { + t.Helper() + t.Setenv(auth.ApiKeyClientIdSetting.EnvKey(), k.ClientId) + t.Setenv(auth.ApiKeyClientSecretSetting.EnvKey(), k.ClientSecret) +} + +// GreetingClient is all this package needs of the code under test: a GET that parses a JSON +// string, carrying whatever authorization the caller wired up. +type GreetingClient func(ctx context.Context, url *url.URL) (string, error) + +type Counts struct { + Logins int64 + Greetings int64 + RevokedTokens int64 + // UnknownTokens counts the 401s no test asked for: a token this Server never minted, or + // no token at all. It is the one count that stays at zero. + UnknownTokens int64 +} + +type Server struct { + url *url.URL + honored []ApiKey + + logins atomic.Int64 + greetings atomic.Int64 + revokedTokens atomic.Int64 + unknownTokens atomic.Int64 + + mu sync.Mutex + minted []mintedToken +} + +type mintedToken struct { + value string + honored bool +} + +// New starts a server that honors the given api keys, and stops it when the test ends. +func New(t *testing.T, honored ...ApiKey) *Server { + t.Helper() + server := &Server{honored: honored} + httpServer := httptest.NewServer(gohttp.HandlerFunc(server.handle)) + t.Cleanup(httpServer.Close) + serverUrl, err := url.Parse(httpServer.URL) + require.NoError(t, err) + server.url = serverUrl + return server +} + +func (s *Server) Url(t *testing.T) *url.URL { + t.Helper() + return s.url +} + +func (s *Server) GreetingUrl(t *testing.T) *url.URL { + t.Helper() + return s.url.JoinPath("greeting") +} + +func (s *Server) Counts(t *testing.T) Counts { + t.Helper() + return Counts{ + Logins: s.logins.Load(), + Greetings: s.greetings.Load(), + RevokedTokens: s.revokedTokens.Load(), + UnknownTokens: s.unknownTokens.Load(), + } +} + +// MintToken issues a token this Server honors, exactly as its login endpoint does, for a test +// that hands one to the code under test directly. +func (s *Server) MintToken(t *testing.T, validFor time.Duration) string { + t.Helper() + return s.mint(validFor) +} + +// RevokeNewestToken stops honoring the newest token still honored, and reports whether there +// was one. The newest is the one a cache is most likely to be holding, so it is the one that +// makes a caller refresh. +// +// A concurrent caller must not have a request in flight while this runs. An authorized client +// retries a 401 exactly once, and the token it retries with can be one it adopted from another +// process's cache, so revoking during a request can leave a 401 that nothing recovers from. +func (s *Server) RevokeNewestToken(t *testing.T) (revoked bool) { + t.Helper() + s.mu.Lock() + defer s.mu.Unlock() + for i := len(s.minted) - 1; i >= 0; i-- { + if s.minted[i].honored { + s.minted[i].honored = false + return true + } + } + return false +} + +// Greeting calls the authorized endpoint and returns what went wrong, for a caller on its own +// goroutine, where require may not be used. +func (s *Server) Greeting(t *testing.T, ctx context.Context, greet GreetingClient) error { + t.Helper() + answered, err := greet(ctx, s.GreetingUrl(t)) + if err != nil { + return err + } + if answered != greeting { + return fmt.Errorf("the endpoint answered %q rather than %q", answered, greeting) + } + return nil +} + +func (s *Server) RequireGreeting(t *testing.T, greet GreetingClient) { + t.Helper() + require.NoError(t, s.Greeting(t, t.Context(), greet)) +} + +func (s *Server) handle(resp gohttp.ResponseWriter, req *gohttp.Request) { + switch req.URL.Path { + case "/api/login": + s.handleLogin(resp, req) + case "/greeting": + s.handleGreeting(resp, req) + default: + resp.WriteHeader(gohttp.StatusNotFound) + } +} + +func (s *Server) handleLogin(resp gohttp.ResponseWriter, req *gohttp.Request) { + body, err := io.ReadAll(req.Body) + var offered ApiKey + if err != nil || json.Unmarshal(body, &offered) != nil { + resp.WriteHeader(gohttp.StatusBadRequest) + return + } + if !slices.Contains(s.honored, offered) { + resp.WriteHeader(gohttp.StatusUnauthorized) + return + } + s.logins.Add(1) + resp.WriteHeader(gohttp.StatusOK) + _, _ = fmt.Fprintf(resp, `{"access_token":%q}`, s.mint(time.Hour)) +} + +func (s *Server) handleGreeting(resp gohttp.ResponseWriter, req *gohttp.Request) { + offered, _ := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ") + + s.mu.Lock() + minted := slices.IndexFunc(s.minted, func(candidate mintedToken) bool { + return candidate.value == offered + }) + honored := minted >= 0 && s.minted[minted].honored + s.mu.Unlock() + + switch { + case honored: + s.greetings.Add(1) + resp.WriteHeader(gohttp.StatusOK) + _, _ = fmt.Fprintf(resp, "%q", greeting) + case minted >= 0: + s.revokedTokens.Add(1) + resp.WriteHeader(gohttp.StatusUnauthorized) + default: + s.unknownTokens.Add(1) + resp.WriteHeader(gohttp.StatusUnauthorized) + } +} + +func (s *Server) mint(validFor time.Duration) string { + s.mu.Lock() + defer s.mu.Unlock() + // The jti counter is what tells two tokens minted in the same second apart. + claims := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"exp":%d,"jti":"%d"}`, + time.Now().Add(validFor).Unix(), len(s.minted)+1)) + minted := mintedToken{value: "e30." + claims + ".test-signature", honored: true} + s.minted = append(s.minted, minted) + return minted.value +} diff --git a/pkg/auth/method.go b/pkg/auth/method.go new file mode 100644 index 00000000..30e10270 --- /dev/null +++ b/pkg/auth/method.go @@ -0,0 +1,17 @@ +package auth + +import ( + "github.com/meshcloud/meshstack-cli/internal/auth/credential" +) + +// Method names one way of authenticating, as [ResolveSessionOptions.ForceAuthWith] takes it. +type Method = credential.Name + +const ( + // ApiKeyMethod mints a token from an API key id and secret. + ApiKeyMethod = credential.ApiKeyName + // ManualMethod sends an access token as it is. + ManualMethod = credential.ManualName + // OidcLoginMethod logs a person in through a browser, so it resolves only when asked for by name. + OidcLoginMethod = credential.OidcLoginName +) diff --git a/pkg/auth/session.go b/pkg/auth/session.go new file mode 100644 index 00000000..2da0df8f --- /dev/null +++ b/pkg/auth/session.go @@ -0,0 +1,63 @@ +package auth + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/auth" +) + +type ( + // ResolveSessionOptions carries the setting sources, the calling front end and the credential to insist on. + ResolveSessionOptions = auth.ResolveSessionOptions + // Session is one resolved profile and credential, and builds an authorized client from them. + Session struct { + internal auth.Session + } + // Status is what the meshStack backend reports about itself, and the endpoint it was asked at. + Status struct { + client.MeshInfo + + Endpoint xurl.URL + } +) + +// ResolveSession resolves the profile, creating a default one when none exists. It checks no +// backend version, so prefer ResolveClient where the Session itself is not needed. +func ResolveSession(ctx context.Context, opts ResolveSessionOptions) (result Session, err error) { + result.internal, err = auth.ResolveSession(ctx, opts) + return +} + +// Client builds the authorized client and checks the backend version, unless MESHSTACK_SKIP_VERSION_CHECK +// asks it not to. It is built once, from the context ResolveSession was given, so it takes none of its own. +func (s Session) Client() (client.Client, error) { + return s.internal.Client() +} + +// ResolveClient resolves a session and builds its client in one call. +func ResolveClient(ctx context.Context, opts ResolveSessionOptions) (client.Client, error) { + session, err := ResolveSession(ctx, opts) + if err != nil { + return client.Client{}, err + } + return session.Client() +} + +// Status reads the backend's info and mints a bearer token, so that an unusable credential fails +// here. A [Session.Store] afterwards persists that token. +func (s Session) Status(ctx context.Context) (status Status, err error) { + status.Endpoint = s.internal.Endpoint + status.MeshInfo, err = s.internal.MeshInfo() + if err != nil { + return + } + _, err = s.internal.GetBearerToken(ctx) + return +} + +// Store writes the resolved session, its credentials and its cached tokens into the current profile. +func (s Session) Store(ctx context.Context) error { + return s.internal.Store(ctx) +} diff --git a/pkg/io/stderr.go b/pkg/io/stderr.go new file mode 100644 index 00000000..c0ff5d24 --- /dev/null +++ b/pkg/io/stderr.go @@ -0,0 +1,14 @@ +package io + +import ( + "context" + "io" + + internal "github.com/meshcloud/meshstack-cli/internal/io" +) + +// WithStderr sets the writer a login sends what a person has to read to, such as the authorization +// URL of a browser login. A context carrying none leaves os.Stderr. +func WithStderr(ctx context.Context, stderr io.Writer) context.Context { + return internal.WithStderr(ctx, stderr) +} diff --git a/pkg/profile/profile.go b/pkg/profile/profile.go new file mode 100644 index 00000000..20544bd0 --- /dev/null +++ b/pkg/profile/profile.go @@ -0,0 +1,25 @@ +package profile + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/internal/profile" +) + +type ( + // Name identifies a profile, and is what MESHSTACK_PROFILE carries. + Name = profile.Name + // Profile is a named bundle of endpoint, default workspace and credential. + Profile = profile.Profile + // ResolveProfileOptions carries the setting sources the resolution reads. + ResolveProfileOptions = profile.ResolveProfileOptions +) + +// ResolveProfile returns the current profile, creating a default one when none exists. +func ResolveProfile(ctx context.Context, opts ResolveProfileOptions) (Profile, error) { + currentProfile, _, err := profile.ResolveProfile(ctx, opts) + if err != nil { + return Profile{}, err + } + return *currentProfile, err +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go new file mode 100644 index 00000000..44080f16 --- /dev/null +++ b/pkg/setting/setting.go @@ -0,0 +1,26 @@ +package setting + +import ( + "github.com/meshcloud/meshstack-cli/internal/auth" + "github.com/meshcloud/meshstack-cli/internal/meshstack" +) + +type ( + // Setting is one declared input, identified by its environment variable key. + Setting interface { + EnvKey() string + Help() string + HelpMarkdown() string + } +) + +// The settings a front end may supply through a FrontendSource. +var ( + Endpoint Setting = meshstack.EndpointSetting + Workspace Setting = meshstack.WorkspaceSetting + SkipVersionCheck Setting = meshstack.SkipVersionCheckSetting + + ApiKeyClientId Setting = auth.ApiKeyClientIdSetting + ApiKeyClientSecret Setting = auth.ApiKeyClientSecretSetting + ApiToken Setting = auth.ApiTokenSetting +) diff --git a/pkg/setting/source.go b/pkg/setting/source.go new file mode 100644 index 00000000..4f006b92 --- /dev/null +++ b/pkg/setting/source.go @@ -0,0 +1,53 @@ +package setting + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +type ( + // Sources are the setting sources a front end contributes to every resolution. + Sources = setting.Sources + // FrontendSource outranks the environment, as a flag or a block attribute does. + FrontendSource = setting.FrontendSource + // FallbackSource ranks below the environment, as a prompt does. + FallbackSource = setting.FallbackSource + + // Workspaces are the workspaces reachable while resolving Workspace, see WorkspacesFromContext. + Workspaces = meshstack.Workspaces + // MeshWorkspace is one workspace, and its String is what a person picks from. + MeshWorkspace = meshstack.MeshWorkspace +) + +// SingleSource wraps one source as the front end's only contribution, ranking it above the +// environment and the default. +func SingleSource(source setting.Source) Sources { + return Sources{setting.FrontendSource{Source: source}} +} + +// LookupSource builds a source for one setting that ranks above the environment. The lookup runs +// only when the resolution reaches it, so it may prompt or call the backend. +func LookupSource(matchingEnvKey, description string, lookup func(ctx context.Context) (string, error)) setting.FrontendSource { + return setting.FrontendSource{Source: setting.LookupSource{ + MatchingKey: matchingEnvKey, + Description: description, + Func: lookup, + }} +} + +// FallbackLookupSource is a LookupSource that ranks below the environment instead of above it. +func FallbackLookupSource(matchingEnvKey, description string, lookup func(ctx context.Context) (string, error)) setting.FallbackSource { + return setting.FallbackSource{Source: setting.LookupSource{ + MatchingKey: matchingEnvKey, + Description: description, + Func: lookup, + }} +} + +// WorkspacesFromContext returns the workspaces the credential can see. They are only in the +// context while Workspace is being resolved, so a lookup for any other setting gets an error. +func WorkspacesFromContext(ctx context.Context) (meshstack.Workspaces, error) { + return meshstack.WorkspacesFromContext(ctx) +} From 577cff32bbf479587efd4378597c4b49ed3a834e Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 18 Sep 2026 21:28:37 +0200 Subject: [PATCH 210/215] feat: add the meshstack command line interface `meshstack auth login` opens a browser, lets the person pick a workspace and writes the profile and its cached token to the config directory; `meshstack auth logout` ends that session and drops the token. `meshstack login` and `meshstack logout` are the same commands registered a second time at the top level, because cobra's aliases only rename a command within its parent. The command tree is the CLI's one settings source: every flag it defines resolves through the same mechanism the environment and the stored profile go through, so a flag and an environment variable that disagree produce the same error here as anywhere else. CU-86cb61rzz Co-Authored-By: Claude Opus 5 (1M context) --- cmd/auth/auth.go | 21 +++ cmd/auth/login.go | 255 +++++++++++++++++++++++++++++++++++++ cmd/auth/logout.go | 27 ++++ cmd/internal/client.go | 14 ++ cmd/internal/flags.go | 67 ++++++++++ cmd/internal/flags_test.go | 20 +++ cmd/internal/run.go | 18 +++ cmd/internal/session.go | 36 ++++++ cmd/internal/version.go | 26 ++++ cmd/meshstack/meshstack.go | 70 ++++++++++ 10 files changed, 554 insertions(+) create mode 100644 cmd/auth/auth.go create mode 100644 cmd/auth/login.go create mode 100644 cmd/auth/logout.go create mode 100644 cmd/internal/client.go create mode 100644 cmd/internal/flags.go create mode 100644 cmd/internal/flags_test.go create mode 100644 cmd/internal/run.go create mode 100644 cmd/internal/session.go create mode 100644 cmd/internal/version.go create mode 100644 cmd/meshstack/meshstack.go diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go new file mode 100644 index 00000000..16817c55 --- /dev/null +++ b/cmd/auth/auth.go @@ -0,0 +1,21 @@ +package auth + +import ( + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Manage authentication with meshStack", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(NewLogin()) + cmd.AddCommand(newLogout()) + + return cmd +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go new file mode 100644 index 00000000..9c62385d --- /dev/null +++ b/cmd/auth/login.go @@ -0,0 +1,255 @@ +package auth + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "strconv" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" + + "github.com/meshcloud/meshstack-cli/cmd/internal" + "github.com/meshcloud/meshstack-cli/pkg/auth" + "github.com/meshcloud/meshstack-cli/pkg/setting" +) + +func NewLogin() *cobra.Command { + const apiKeyIdDefault = "" + var ( + openStdinFlag = internal.Flag[bool]{Name: "stdin", Help: "prompt the API key secret or API token from stdin"} + apiKeyFlag = internal.NewFlagForSetting[string]("apikey", setting.ApiKeyClientId) + apiTokenFlag = newFlagWithPrompt("apitoken", setting.ApiToken) + ) + + cmd := &cobra.Command{ + Use: "login", + Short: "Log in to meshStack", + Long: `Log in to meshStack and store the credential in a profile. + +With no flag this is a browser login, and it asks which workspace to work in unless --workspace or +MESHSTACK_WORKSPACE already says. An API key login asks the same way, while --apitoken asks nothing.`, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + if cmd.Flags().Changed(apiKeyFlag.Name.String()) { + return fmt.Errorf("an API key id needs an equals sign: write `--%s=%s`", + apiKeyFlag.Name, args[0]) + } + return fmt.Errorf("the meshstack auth login does not take any arguments such as '%q'; everything comes from flags and the environment", args) + }, + RunE: func(cmd *cobra.Command, _ []string) error { + timeout := internal.DefaultTimeout + var forceAuthWith auth.Method + var sources setting.Sources + promptedFrom := newPrompt(cmd) + switch { + case cmd.Flags().Changed(apiKeyFlag.Name.String()): + forceAuthWith = auth.ApiKeyMethod + if apiKeyFlag.Value == "" { + return fmt.Errorf("the API key id is empty; --%s= was given without an id; specify --%s to read from env", + apiKeyFlag.Name, apiKeyFlag.Name) + } + sources = append(sources, + apiKeyFlag.AsSourceUnless(func(value string) bool { + return value == apiKeyIdDefault + }), + newPromptingSource(setting.ApiKeyClientSecret.EnvKey(), &openStdinFlag, promptedFrom, "API Client Secret"), + ) + case apiTokenFlag.Value: + forceAuthWith = auth.ManualMethod + sources = append(sources, apiTokenFlag.AsSource(&openStdinFlag, promptedFrom, "API Token")) + default: + timeout = 5 * time.Minute // a browser login waits for the person to finish it + forceAuthWith = auth.OidcLoginMethod + } + + // A token given with --apitoken already names the workspace it belongs to, and a + // building block runner's token — the usual reason to pass one — may list none at all. + if forceAuthWith != auth.ManualMethod { + sources = append(sources, newWorkspaceSelectionSource(promptedFrom)) + } + + return internal.RunWith(cmd.Context(), timeout, func(ctx context.Context) error { + session, err := internal.ResolveSession(ctx, func(opts *auth.ResolveSessionOptions) { + opts.SettingSources = append(opts.SettingSources, sources...) + opts.ForceAuthWith = forceAuthWith + }) + if err != nil { + return err + } + sessionStatus, err := session.Status(ctx) + if err != nil { + return err + } + if err := session.Store(ctx); err != nil { + return err + } + // TODO render Markdown output from model instead of logging?! + slog.InfoContext(ctx, fmt.Sprintf("%s (version %s) logged in at meshStack %s at %s", + sessionStatus.CliClientId, internal.Version, sessionStatus.Version, sessionStatus.Endpoint)) + return nil + }) + }, + } + + cmd.MarkFlagsMutuallyExclusive( + apiKeyFlag.Register(cmd.Flags()), + apiTokenFlag.Register(cmd.Flags()), + ) + // NoOptDefVal is what makes a bare --apikey, with no value after it, parse. + cmd.Flags().Lookup(apiKeyFlag.Name.String()).NoOptDefVal = apiKeyIdDefault + + openStdinFlag.Register(cmd.Flags()) + + return cmd +} + +// newWorkspaceSelectionSource lets the person pick one of the workspaces this login can reach. It +// is a fallback source, so --workspace and MESHSTACK_WORKSPACE are taken as given, while the +// profile's default ranks below it: a login is what changes that default. It needs no --stdin, +// unlike the secret prompts above, because the list has to be shown for the choice to make sense. +func newWorkspaceSelectionSource(prompt Prompt) setting.FallbackSource { + return setting.FallbackLookupSource(setting.Workspace.EnvKey(), "the workspace selection of this login", + func(ctx context.Context) (string, error) { + workspaces, err := setting.WorkspacesFromContext(ctx) + if err != nil { + return "", err + } + if single := workspaces.Single(ctx); single != nil { + return string(single.Name()), nil + } + selected, err := selectWorkspace(ctx, prompt, workspaces) + if err != nil { + return "", err + } + return string(selected.Name()), nil + }) +} + +// selectWorkspace asks until it gets a number. An abandoned prompt is an error rather than no +// workspace at all: a login that stored none leaves every later command without one. +func selectWorkspace(ctx context.Context, prompt Prompt, workspaces setting.Workspaces) (*setting.MeshWorkspace, error) { + profileDefault := workspaces.ProfileDefault(ctx) + // The printed numbers have to stay answerable, so the list is kept rather than looked up again. + listed := make([]setting.MeshWorkspace, 0, len(workspaces.Items)) + defaultNumber := 0 + defaultQuestionMarker := "" + for number, workspace := range workspaces.All() { + listed = append(listed, workspace) + defaultMarker := " " + if profileDefault != nil && workspace.Matches(*profileDefault) { + defaultMarker = "*" + defaultNumber = number + 1 + defaultQuestionMarker = fmt.Sprintf(", default=%d", defaultNumber) + } + if err := prompt.Printf(" %s[%d] %s\n", defaultMarker, number+1, workspace); err != nil { + return nil, err + } + } + + question := fmt.Sprintf("Select a workspace [1-%d%s]: ", len(listed), defaultQuestionMarker) + + for { + if err := prompt.Printf("%s", question); err != nil { + return nil, err + } + answer, err := prompt.Next(ctx, "workspace selection") + if err != nil { + return nil, err + } + // An empty answer takes the marked default, and asks again where there is none: zero is + // out of range below. + number, convErr := defaultNumber, error(nil) + if answer != "" { + number, convErr = strconv.Atoi(answer) + } + if convErr != nil || number < 1 || number > len(listed) { + if err := prompt.Printf("Answer with a number between 1 and %d.\n", len(listed)); err != nil { + return nil, err + } + continue + } + return &listed[number-1], nil + } +} + +type Prompt struct { + in func() <-chan string + out io.Writer +} + +func newPrompt(cmd *cobra.Command) Prompt { + return Prompt{ + in: sync.OnceValue(func() <-chan string { + lines := make(chan string) + go func() { + defer close(lines) + answers := bufio.NewScanner(cmd.InOrStdin()) + for answers.Scan() { + lines <- answers.Text() + } + }() + return lines + }), + out: cmd.ErrOrStderr(), + } +} + +func (p Prompt) Next(ctx context.Context, what string) (string, error) { + select { + case <-ctx.Done(): + return "", fmt.Errorf("nothing was entered for the %s: %w", what, ctx.Err()) + case line, open := <-p.in(): + if !open { + return "", fmt.Errorf("nothing was entered for the %s, as the prompt reached the end of its input", what) + } + return strings.TrimSpace(line), nil + } +} + +func (p Prompt) Printf(format string, args ...any) (err error) { + _, err = fmt.Fprintf(p.out, format, args...) + return +} + +type FlagWithPrompt struct { + internal.Flag[bool] +} + +func newFlagWithPrompt(name internal.FlagName, s setting.Setting) FlagWithPrompt { + return FlagWithPrompt{Flag: internal.NewFlagForSetting[bool](name, s)} +} + +func (flag *FlagWithPrompt) AsSource(stdinFlag *internal.Flag[bool], prompt Prompt, what string) (source setting.FrontendSource) { + return newPromptingSource(flag.SettingEnvKey, stdinFlag, prompt, what) +} + +func newPromptingSource(settingEnvKey string, openStdinFlag *internal.Flag[bool], prompt Prompt, what string) setting.FrontendSource { + description := fmt.Sprintf("%s to read the %s from stdin", openStdinFlag.Name.SourceDescription(), what) + return setting.LookupSource(settingEnvKey, description, func(ctx context.Context) (string, error) { + if !openStdinFlag.Value { + return "", nil + } + if err := prompt.Printf("%s (finish with Enter or Ctrl-D): ", what); err != nil { + return "", err + } + answer, err := prompt.Next(ctx, what) + if err != nil { + return "", err + } + if answer == "" { + // An error, not an empty value: ResolveSetting reads an empty value as this source + // having nothing and falls through to the environment, and a token exported there is + // not what was asked for at this prompt. + return "", errors.New("no non-whitespace input provided in prompt") + } + return answer, nil + }) +} diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go new file mode 100644 index 00000000..f2abeb06 --- /dev/null +++ b/cmd/auth/logout.go @@ -0,0 +1,27 @@ +package auth + +import ( + "github.com/spf13/cobra" + + "github.com/meshcloud/meshstack-cli/cmd/internal" + "github.com/meshcloud/meshstack-cli/pkg/profile" +) + +func newLogout() *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Remove this profile's stored credentials", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + currentProfile, err := profile.ResolveProfile(cmd.Context(), profile.ResolveProfileOptions{ + SettingSources: internal.SettingSources(), + }) + if err != nil { + return err + } + return currentProfile.RemoveCredentials(cmd.Context()) + }, + } + // TODO add flag --revoke-session to also revoke the login session (refresh token) + return cmd +} diff --git a/cmd/internal/client.go b/cmd/internal/client.go new file mode 100644 index 00000000..6c26d037 --- /dev/null +++ b/cmd/internal/client.go @@ -0,0 +1,14 @@ +package internal + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/pkg/auth" +) + +// ResolveClient is the only place a client is built, so every command sends the same user agent +// and resolves the same global flags as settings. +func ResolveClient(ctx context.Context) (client.Client, error) { + return auth.ResolveClient(ctx, resolveSessionOptions()) +} diff --git a/cmd/internal/flags.go b/cmd/internal/flags.go new file mode 100644 index 00000000..f7d6d61e --- /dev/null +++ b/cmd/internal/flags.go @@ -0,0 +1,67 @@ +package internal + +import ( + "context" + "fmt" + + "github.com/spf13/pflag" + + "github.com/meshcloud/meshstack-cli/pkg/setting" +) + +var ( + SkipVersionCheckFlag = NewFlagForSetting[bool]("skip-version-check", setting.SkipVersionCheck) + EndpointFlag = NewFlagForSetting[string]("endpoint", setting.Endpoint) + WorkspaceFlag = NewFlagForSetting[string]("workspace", setting.Workspace) +) + +type FlagName string + +func (n FlagName) SourceDescription() string { + return fmt.Sprintf("flag --%s", n) +} + +func (n FlagName) String() string { + return string(n) +} + +type Flag[T string | bool] struct { + Name FlagName + Help string + Value T + // SettingEnvKey is required to use the Flag as a setting.FrontendSource. + SettingEnvKey string +} + +func NewFlagForSetting[T string | bool](name FlagName, s setting.Setting) Flag[T] { + return Flag[T]{Name: name, Help: s.Help(), SettingEnvKey: s.EnvKey()} +} + +func (flag *Flag[T]) Register(flags *pflag.FlagSet) (flagName string) { + switch v := any(&flag.Value).(type) { + case *string: + flags.StringVar(v, flag.Name.String(), *v, flag.Help) + case *bool: + flags.BoolVar(v, flag.Name.String(), *v, flag.Help) + default: + panic(fmt.Sprintf("cannot register flag with value type %T", flag.Value)) + } + return flag.Name.String() +} + +func (flag *Flag[T]) AsSource() setting.FrontendSource { + return setting.LookupSource(flag.SettingEnvKey, flag.Name.SourceDescription(), func(_ context.Context) (string, error) { + return fmt.Sprintf("%v", flag.Value), nil + }) +} + +// AsSourceUnless contributes nothing but its own name while the flag still carries the +// placeholder. The source is registered anyway, so setting resolution can name it in its error. +func (flag *Flag[T]) AsSourceUnless(predicate func(T) bool) setting.FrontendSource { + return setting.LookupSource(flag.SettingEnvKey, flag.Name.SourceDescription(), func(_ context.Context) (string, error) { + if predicate(flag.Value) { + return "", nil + } + return fmt.Sprintf("%v", flag.Value), nil + }) +} diff --git a/cmd/internal/flags_test.go b/cmd/internal/flags_test.go new file mode 100644 index 00000000..0bfbc680 --- /dev/null +++ b/cmd/internal/flags_test.go @@ -0,0 +1,20 @@ +package internal_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/cmd/internal" + "github.com/meshcloud/meshstack-cli/internal/meshstack" +) + +func TestTheEnvironmentDecidesWhileTheBoolFlagIsUnset(t *testing.T) { + t.Setenv(meshstack.SkipVersionCheckSetting.EnvKey(), "true") + + skip, err := internal.SettingSources().ResolveSetting(t.Context(), meshstack.SkipVersionCheckSetting) + + require.NoError(t, err) + assert.True(t, skip) +} diff --git a/cmd/internal/run.go b/cmd/internal/run.go new file mode 100644 index 00000000..464b5da0 --- /dev/null +++ b/cmd/internal/run.go @@ -0,0 +1,18 @@ +package internal + +import ( + "context" + "os" + "os/signal" + "time" +) + +const DefaultTimeout = 45 * time.Second + +func RunWith(ctx context.Context, timeout time.Duration, action func(context.Context) error) error { + ctx, stopSignals := signal.NotifyContext(context.WithoutCancel(ctx), os.Interrupt) + defer stopSignals() + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return action(ctx) +} diff --git a/cmd/internal/session.go b/cmd/internal/session.go new file mode 100644 index 00000000..dd2a3c7c --- /dev/null +++ b/cmd/internal/session.go @@ -0,0 +1,36 @@ +package internal + +import ( + "context" + + "github.com/meshcloud/meshstack-cli/pkg/auth" + "github.com/meshcloud/meshstack-cli/pkg/setting" +) + +func ResolveSession(ctx context.Context, options ...ResolveSessionOption) (auth.Session, error) { + opts := resolveSessionOptions() + for _, option := range options { + option(&opts) + } + return auth.ResolveSession(ctx, opts) +} + +type ResolveSessionOption func(*auth.ResolveSessionOptions) + +func resolveSessionOptions() auth.ResolveSessionOptions { + return auth.ResolveSessionOptions{ + SettingSources: SettingSources(), + Version: Version, + GitHubRepo: "meshcloud/meshstack-cli", + } +} + +func SettingSources() setting.Sources { + return setting.Sources{ + EndpointFlag.AsSource(), + WorkspaceFlag.AsSource(), + // An unset bool flag reads false, and a frontend source outranks the environment, so + // contributing that false would mask MESHSTACK_SKIP_VERSION_CHECK. + SkipVersionCheckFlag.AsSourceUnless(func(skip bool) bool { return !skip }), + } +} diff --git a/cmd/internal/version.go b/cmd/internal/version.go new file mode 100644 index 00000000..633c2ad4 --- /dev/null +++ b/cmd/internal/version.go @@ -0,0 +1,26 @@ +package internal + +import ( + "runtime/debug" +) + +const devVersion = "dev" + +// Version identifies the CLI to the meshStack API through the +// client's UserAgent. A release overrides it with +// -ldflags "-X github.com/meshcloud/meshstack-cli/cmd/internal.Version=". +var Version = devVersion + +func init() { + if Version == devVersion { + Version = versionFromGoBuild() + } +} + +func versionFromGoBuild() string { + info, ok := debug.ReadBuildInfo() + if !ok || info.Main.Version == "" || info.Main.Version == "(devel)" { + return devVersion + } + return info.Main.Version +} diff --git a/cmd/meshstack/meshstack.go b/cmd/meshstack/meshstack.go new file mode 100644 index 00000000..51e43b5f --- /dev/null +++ b/cmd/meshstack/meshstack.go @@ -0,0 +1,70 @@ +// Command meshstack is the command line interface (CLI) for meshStack. +package main + +import ( + "context" + "log/slog" + "os" + + clog "github.com/charmbracelet/log" + "github.com/spf13/cobra" + + "github.com/meshcloud/meshstack-cli/cmd/auth" + "github.com/meshcloud/meshstack-cli/cmd/internal" + "github.com/meshcloud/meshstack-cli/pkg/io" +) + +func main() { + if err := internal.RunWith(context.Background(), internal.DefaultTimeout, func(ctx context.Context) error { + //nolint:contextcheck // the root's PersistentPreRun derives from cmd.Context(), which cobra sets from this ctx + return newRootCommand().ExecuteContext(ctx) + }); err != nil { + // cobra has already written the error to stderr. + os.Exit(1) + } +} + +func newRootCommand() *cobra.Command { + var debug bool + cmd := &cobra.Command{ + Use: "meshstack", + Short: "Command line interface for meshStack", + // RunE has to be set for cobra to render the usage block at all: its help template + // skips usage while the command is neither runnable nor a parent of subcommands. + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + Version: internal.Version, + // A command that fails prints its error, not the whole help text. + SilenceUsage: true, + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + setupLogging(debug) + cmd.SetContext(io.WithStderr(cmd.Context(), cmd.ErrOrStderr())) + }, + } + + persistentFlags := cmd.PersistentFlags() + persistentFlags.BoolVar(&debug, "debug", false, "log at debug level") + internal.EndpointFlag.Register(persistentFlags) + internal.WorkspaceFlag.Register(persistentFlags) + internal.SkipVersionCheckFlag.Register(persistentFlags) + + cmd.AddCommand(auth.New()) + // `meshstack login` is a shortcut for `meshstack auth login`. Calling the constructor a second + // time is the only way to get one: cobra's Aliases rename a command inside its own parent. + cmd.AddCommand(auth.NewLogin()) + + return cmd +} + +func setupLogging(debug bool) { + options := clog.Options{ + ReportTimestamp: true, + Level: clog.InfoLevel, + } + if debug { + options.Level = clog.DebugLevel + } + slog.SetDefault(slog.New(clog.NewWithOptions(os.Stderr, options))) +} From ea3b94d8fdcba4644e14dfb4db6c1be6b758e8dd Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 18 Sep 2026 21:28:57 +0200 Subject: [PATCH 211/215] feat: warn when a newer CLI release is on GitHub A person who installed the binary once has no other way to learn that a release fixed the thing they are hitting. The check runs at most once a day, remembers the answer in the config directory, and never fails the command it rides on: a GitHub outage or an offline machine leaves the invocation alone. It sits beside the session rather than in its own package because the release lookup needs the shared HTTP client, and the client's dependency rules keep that reachable only from here. CU-86cb61rzz Co-Authored-By: Claude Opus 5 (1M context) --- internal/auth/session.go | 3 ++ internal/auth/version_check.go | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 internal/auth/version_check.go diff --git a/internal/auth/session.go b/internal/auth/session.go index 7f7c8693..4181942e 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -153,6 +153,9 @@ func (s Session) buildClient(ctx context.Context, opts ResolveSessionOptions) (c if _, err := s.checkedMeshInfo(); err != nil { return client.Client{}, err } + if err := warnIfNewerReleasePresent(ctx, s.ConfigDir, s.httpClient, opts); err != nil { + slog.WarnContext(ctx, "Cannot check for a newer release on GitHub: "+err.Error()) + } return c, nil } diff --git a/internal/auth/version_check.go b/internal/auth/version_check.go new file mode 100644 index 00000000..12386ed0 --- /dev/null +++ b/internal/auth/version_check.go @@ -0,0 +1,74 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + "time" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/config" + "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/json" + "github.com/meshcloud/meshstack-cli/internal/version" +) + +const ( + // checkInterval is recorded on disk, as one invocation is too short-lived to hold it. + checkInterval = 24 * time.Hour + releaseCheckTimeout = 10 * time.Second +) + +// warnIfNewerReleasePresent lives here rather than in internal/version, which the depguard rule +// in .golangci.yml keeps closed to everything but the standard library. +func warnIfNewerReleasePresent(ctx context.Context, configDir config.Directory, client http.Client, opts ResolveSessionOptions) (err error) { + currentVersion, err := version.Parse(opts.Version) + if err != nil { + slog.DebugContext(ctx, fmt.Sprintf("Skipping release check for build with unparsable version: %s", err)) + return nil + } + if !configDir.Exists() { + slog.DebugContext(ctx, fmt.Sprintf("Skipping release check as config dir %s does not exist", configDir)) + return nil + } + lastCheckFile := configDir.VersionCheckJson() + versionCheck := struct { + LastCheck time.Time `json:"lastCheck"` + }{} + if loadErr := json.UnmarshalFrom(ctx, lastCheckFile, &versionCheck); loadErr != nil && !errors.Is(loadErr, fs.ErrNotExist) { + return loadErr + } + if durationUntilNextCheck := checkInterval - time.Since(versionCheck.LastCheck); durationUntilNextCheck > 0 { + slog.DebugContext(ctx, fmt.Sprintf("Skipping release check, next one due in %s", durationUntilNextCheck)) + return nil + } + // Recorded even when the request below fails, so an unreachable GitHub is asked once a day. + versionCheck.LastCheck = time.Now() + defer func() { + err = errors.Join(err, json.MarshalTo(ctx, lastCheckFile, versionCheck)) + }() + + ctxRequest, cancel := context.WithTimeout(ctx, releaseCheckTimeout) + defer cancel() + apiUrl := xurl.MustParsef("https://api.github.com/repos/%s/releases/latest", opts.GitHubRepo) + latestRelease, err := client.DoRequest[struct { + TagName string `json:"tag_name"` + }](ctxRequest, http.MethodGet, apiUrl.URL, http.Retryable()) + if httpError, ok := errors.AsType[http.Error](err); ok && httpError.IsNotFound() { + return fmt.Errorf("no latest release found at %s: %w", apiUrl, err) + } else if err != nil { + return fmt.Errorf("cannot fetch %s: %w", apiUrl, err) + } + latestReleaseVersion, err := version.Parse(latestRelease.TagName) + if err != nil { + return fmt.Errorf("cannot parse the latest release from %s: %w", apiUrl, err) + } + if currentVersion.Less(latestReleaseVersion) { + releaseUrl := xurl.MustParsef("https://github.com/%s/releases/latest", opts.GitHubRepo) + slog.WarnContext(ctx, fmt.Sprintf("Please download the latest release %s, newer than %s, at %s", + latestReleaseVersion, currentVersion, releaseUrl)) + } + return nil +} From 33125beb42da69ccc40092137163115ac635db7a Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 18 Sep 2026 21:29:10 +0200 Subject: [PATCH 212/215] test: drive the login command against a local meshStack The browser login is the one flow no unit test reaches: it needs a real keycloak to answer the authorization request and a real meshStack to accept the token that comes back. This suite builds the binary, plays the browser against keycloak's HTML forms with its own cookie jar, and then checks that the profile and the cached token it wrote let a second invocation run without logging in again. meshfed-release runs it as a satellite suite, because a whole meshStack only exists there. It skips without one, so `go test ./...` stays green here. CU-86cb61rzz Co-Authored-By: Claude Opus 5 (1M context) --- cmd/internal/testacc/credentials_test.go | 64 +++++ cmd/internal/testacc/login_test.go | 330 +++++++++++++++++++++++ cmd/internal/testacc/testacc_test.go | 184 +++++++++++++ 3 files changed, 578 insertions(+) create mode 100644 cmd/internal/testacc/credentials_test.go create mode 100644 cmd/internal/testacc/login_test.go create mode 100644 cmd/internal/testacc/testacc_test.go diff --git a/cmd/internal/testacc/credentials_test.go b/cmd/internal/testacc/credentials_test.go new file mode 100644 index 00000000..98df0d67 --- /dev/null +++ b/cmd/internal/testacc/credentials_test.go @@ -0,0 +1,64 @@ +package testacc + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/pkg/auth" + "github.com/meshcloud/meshstack-cli/pkg/setting" +) + +// unsignedEmptyJwt is an unsigned JWT with an empty payload, all MESHSTACK_API_TOKEN needs to parse. +const unsignedEmptyJwt = "eyJhbGciOiJub25lIn0.e30." + +// These three refusals run in-process, because no invocation of the binary reaches them: +// `meshstack login` always names the credential it wants. + +func TestAccCredentialResolutionRefusesAnUnknownForcedCredential(t *testing.T) { + newInProcessCLI(t) + + _, err := auth.ResolveSession(t.Context(), resolveOptions(auth.Method("nope"))) + require.ErrorContains(t, err, "cannot authenticate with credential 'nope'; pick one of [apiKey manual oidcLogin]") +} + +func TestAccCredentialResolutionRefusesTwoCredentialsAtOnce(t *testing.T) { + newInProcessCLI(t) + t.Setenv(setting.ApiKeyClientId.EnvKey(), "11111111-45bf-42ba-a965-2097b9d0d181") + t.Setenv(setting.ApiKeyClientSecret.EnvKey(), "not-a-real-secret") + t.Setenv(setting.ApiToken.EnvKey(), unsignedEmptyJwt) + + _, err := auth.ResolveSession(t.Context(), resolveOptions("")) + require.ErrorContains(t, err, "resolved more than one credential") +} + +func TestAccCredentialResolutionNamesEveryCredentialItLooksFor(t *testing.T) { + newInProcessCLI(t) + + _, err := auth.ResolveSession(t.Context(), resolveOptions("")) + require.ErrorContains(t, err, "selects none") + require.ErrorContains(t, err, setting.ApiKeyClientId.EnvKey()) + require.ErrorContains(t, err, setting.ApiKeyClientSecret.EnvKey()) + require.ErrorContains(t, err, setting.ApiToken.EnvKey()) +} + +// newInProcessCLI is newCLI's counterpart for a resolution that runs in this process. +func newInProcessCLI(t *testing.T) { + t.Helper() + endpoint := requireLocalStack(t) + t.Setenv(envConfigDir, t.TempDir()) + t.Setenv(envEndpoint, endpoint) + t.Setenv(envProfile, "") + t.Setenv(envWorkspace, "") + t.Setenv(setting.ApiKeyClientId.EnvKey(), "") + t.Setenv(setting.ApiKeyClientSecret.EnvKey(), "") + t.Setenv(setting.ApiToken.EnvKey(), "") +} + +func resolveOptions(forced auth.Method) auth.ResolveSessionOptions { + return auth.ResolveSessionOptions{ + Version: "testacc", + GitHubRepo: "meshcloud/meshstack-cli", + ForceAuthWith: forced, + } +} diff --git a/cmd/internal/testacc/login_test.go b/cmd/internal/testacc/login_test.go new file mode 100644 index 00000000..5fa97fab --- /dev/null +++ b/cmd/internal/testacc/login_test.go @@ -0,0 +1,330 @@ +package testacc + +import ( + "bufio" + "encoding/json/v2" + "html" + "io" + "maps" + gohttp "net/http" + "net/http/cookiejar" + "net/url" + "os" + "os/exec" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/pkg/setting" +) + +// envTestUsers carries auth.openid.users out of ../meshfed-release, whose logins its +// DevLocalUserBootstrapService reconciles into keycloak on every startup. +const envTestUsers = "MESHSTACK_CLI_TEST_USERS" + +type devLogin struct { + Username string `json:"username"` + Password string `json:"password"` + Workspaces map[string]string `json:"workspaces"` +} + +func devLogins(t *testing.T) []devLogin { + t.Helper() + var logins []devLogin + require.NoErrorf(t, json.Unmarshal([]byte(requireEnv(t, envTestUsers)), &logins), "%s does not decode", envTestUsers) + require.NotEmptyf(t, logins, "%s carries no login, so there is nobody to log in as", envTestUsers) + return logins +} + +// keycloak lets a login with no workspace in, and the workspace list then answers 403. +const refusedWithoutAWorkspace = "cannot list workspaces" + +// TestAccOidcLogin drives the authorization code flow with no browser and no terminal, which is the +// shape CI has: the CLI prints the authorization URL to stderr and waits on a loopback listener, so +// anything that can read stderr and speak HTTP can finish the login. +func TestAccOidcLogin(t *testing.T) { + endpoint := requireLocalStack(t) + issuer := meshInfo(t, endpoint).Issuer.String() + logins := devLogins(t) + + t.Run("a wrong password logs nobody in", func(t *testing.T) { + c := newCLI(t, endpoint) + login := startLogin(t, c, issuer, "1") + + page := keycloakLogin(t, login.awaitAuthorizationURL(t), logins[0].Username, "not-the-password") + stillAsksForALogin := strings.Contains(page.body, "kc-form-login") + assert.Truef(t, stillAsksForALogin, "keycloak took a wrong password and answered %s", page.url) + assert.Equal(t, "Invalid username or password.", keycloakFeedback(page.body)) + + login.abort() + assert.NoFileExists(t, c.credentialsJson()) + }) + + for _, login := range logins { + t.Run(login.Username, func(t *testing.T) { + c := newCLI(t, endpoint) + run := startLogin(t, c, issuer, "1") + + completeKeycloakLogin(t, run.awaitAuthorizationURL(t), login.Username, login.Password) + + if len(login.Workspaces) == 0 { + require.Errorf(t, run.wait(), "the login stored a credential it cannot use:\n%s", run.output.String()) + assert.Contains(t, run.output.String(), refusedWithoutAWorkspace) + assert.NoFileExists(t, c.credentialsJson()) + return + } + require.NoErrorf(t, run.wait(), "the browser login did not finish:\n%s", run.output.String()) + assert.Contains(t, run.output.String(), "logged in at meshStack", "the login reports what it reached") + requireStoredLogin(t, c, run.output.String()) + }) + } +} + +// TestAccApiKeyLogin logs in with the API key the Terraform provider's acceptance suite uses, and +// finishes with the token that login cached: reading it back off disk is the only way to reach +// --apitoken without minting a token, and a configuration directory is writable in CI too. +func TestAccApiKeyLogin(t *testing.T) { + endpoint := requireLocalStack(t) + c := newCLI(t, endpoint) + c.setEnv(setting.ApiKeyClientId.EnvKey(), requireEnv(t, setting.ApiKeyClientId.EnvKey())) + c.setEnv(setting.ApiKeyClientSecret.EnvKey(), requireEnv(t, setting.ApiKeyClientSecret.EnvKey())) + + // A bare --apikey reads the id from the environment, which is what its NoOptDefVal is for. + login := c.command("login", "--apikey") + login.Stdin = strings.NewReader("1\n") + output, err := login.CombinedOutput() + require.NoErrorf(t, err, "the API key login did not finish:\n%s", output) + assert.Contains(t, string(output), "logged in at meshStack", "the login reports what it reached") + requireStoredLogin(t, c, string(output)) + + t.Run("--apitoken sends the token the API key login cached", func(t *testing.T) { + withToken := newCLI(t, endpoint) + withToken.setEnv(setting.ApiToken.EnvKey(), cachedApiKeyToken(t, c)) + + output, err := withToken.command("login", "--apitoken").CombinedOutput() + require.NoErrorf(t, err, "the API token login did not finish:\n%s", output) + assert.Contains(t, string(output), "logged in at meshStack", "the login reports what it reached") + require.FileExists(t, withToken.credentialsJson()) + }) +} + +func requireStoredLogin(t *testing.T, c *cli, output string) { + t.Helper() + require.FileExists(t, c.profilesJson()) + require.FileExists(t, c.credentialsJson()) + + profiles, err := os.ReadFile(c.profilesJson()) + require.NoError(t, err) + assert.Contains(t, string(profiles), `"default_workspace"`, "the login stored the workspace it resolved") + // The identifier is read out of the list the login printed, which only appears where more than + // one workspace is reachable, so this holds for any seed. + if offered := regexp.MustCompile(`\[1\] .*\((\S+)\)`).FindStringSubmatch(output); offered != nil { + assert.Contains(t, string(profiles), offered[1], "the selected workspace is the profile's default") + } +} + +func cachedApiKeyToken(t *testing.T, c *cli) string { + t.Helper() + content, err := os.ReadFile(c.credentialsCacheJson("apiKey")) + require.NoError(t, err) + var cacheFile struct { + Cache struct { + Token string `json:"token"` + } `json:"cache"` + } + require.NoError(t, json.Unmarshal(content, &cacheFile)) + require.NotEmpty(t, cacheFile.Cache.Token, "the API key login cached no token to log in with") + return cacheFile.Cache.Token +} + +type loginRun struct { + cmd *exec.Cmd + output *syncBuffer + authURL chan string + drained chan struct{} +} + +// startLogin drains stderr while the command still runs: login writes the authorization URL and +// then blocks on the redirect, so nothing about it is readable after the fact. +func startLogin(t *testing.T, c *cli, issuer, workspaceAnswer string) *loginRun { + t.Helper() + cmd := c.command("login") + cmd.Stdin = strings.NewReader(workspaceAnswer + "\n") + stderr, err := cmd.StderrPipe() + require.NoError(t, err) + cmd.Stdout = nil + + run := &loginRun{ + cmd: cmd, + output: &syncBuffer{}, + // Buffered, so the scanner never blocks on a test that has already given up. + authURL: make(chan string, 1), + drained: make(chan struct{}), + } + require.NoError(t, cmd.Start()) + + printed := regexp.MustCompile(regexp.QuoteMeta(issuer) + `/\S+`) + go func() { + defer close(run.drained) + lines := bufio.NewScanner(stderr) + for lines.Scan() { + line := lines.Bytes() + _, _ = run.output.Write(append(line, '\n')) + if found := printed.Find(line); found != nil { + select { + case run.authURL <- string(found): + default: + } + } + } + }() + return run +} + +func (r *loginRun) awaitAuthorizationURL(t *testing.T) string { + t.Helper() + select { + case found := <-r.authURL: + return found + case <-r.drained: + t.Fatalf("`meshstack login` ended without printing an authorization URL. It said:\n%s", r.output.String()) + case <-time.After(time.Minute): + t.Fatalf("no authorization URL appeared within a minute. `meshstack login` said:\n%s", r.output.String()) + } + return "" +} + +// wait reads stderr to its end before reaping the command, which is what os/exec requires of a +// StderrPipe. +func (r *loginRun) wait() error { + <-r.drained + return r.cmd.Wait() +} + +// abort ends a login that will never finish, because the redirect it waits for never comes. +func (r *loginRun) abort() { + _ = r.cmd.Process.Kill() + <-r.drained + _ = r.cmd.Wait() +} + +func completeKeycloakLogin(t *testing.T, authURL, username, password string) { + t.Helper() + page := keycloakLogin(t, authURL, username, password) + // Asserted as a bool: a Contains assertion would quote two hundred lines of patternfly. + stillAsksForALogin := strings.Contains(page.body, "kc-form-login") + require.Falsef(t, stillAsksForALogin, + "keycloak is still asking for a login, so it refused %s: %s", username, keycloakFeedback(page.body)) +} + +// keycloakLogin is the browser's part: fetch the authorization URL, post the forms keycloak answers +// with, and follow every redirect. The last redirect goes to http://127.0.0.1:/callback, and +// making that request is what hands the CLI its authorization code and ends its wait. +func keycloakLogin(t *testing.T, authURL, username, password string) htmlPage { + t.Helper() + jar, err := cookiejar.New(nil) + require.NoError(t, err) + // Keycloak carries the authentication session in a cookie, so the jar is not a convenience. + browser := &gohttp.Client{Jar: jar, Timeout: 30 * time.Second} + + page := fetch(t, browser, authURL) + asksForALogin := strings.Contains(page.body, "kc-form-login") + require.Truef(t, asksForALogin, + "keycloak served no login form at %s, but %s", authURL, keycloakFeedback(page.body)) + page = submitForm(t, browser, page, `id="kc-form-login"`, url.Values{ + "username": {username}, + "password": {password}, + // Posted empty as keycloak's own form does: leaving it out picks a different authenticator. + "credentialId": {""}, + }) + + // The consent screen appears on a first login for this client and not on later ones. + if strings.Contains(page.body, consentAction) { + page = submitForm(t, browser, page, `action="[^"]*`+consentAction, url.Values{ + "code": {firstSubmatch(t, page.body, `name="code" value="([^"]*)"`)}, + "accept": {"Yes"}, + }) + } + return page +} + +// consentAction identifies the consent form, which carries no id of its own. +const consentAction = "login-actions/consent" + +// keycloakFeedback pulls the one sentence keycloak puts on the page when it refuses something. +func keycloakFeedback(body string) string { + said := regexp.MustCompile(`kc-feedback-text[^>]*>([^<]*)<`).FindStringSubmatch(body) + if len(said) != 2 { + return "and said nothing about why" + } + return strings.TrimSpace(said[1]) +} + +type htmlPage struct { + // url is where the page was finally served from, which a relative form action resolves against. + url *url.URL + body string +} + +func fetch(t *testing.T, browser *gohttp.Client, target string) htmlPage { + t.Helper() + req, err := gohttp.NewRequestWithContext(t.Context(), gohttp.MethodGet, target, nil) + require.NoError(t, err) + return read(t, browser, req) +} + +// submitForm posts every hidden input the matched form carries as well: keycloak puts a session +// code in the action URL and, on some screens, more in hidden fields. +func submitForm(t *testing.T, browser *gohttp.Client, page htmlPage, formPattern string, values url.Values) htmlPage { + t.Helper() + form := formMatching(t, page.body, formPattern) + action, err := page.url.Parse(html.UnescapeString(firstSubmatch(t, form, `action="([^"]*)"`))) + require.NoError(t, err) + + posted := url.Values{} + for _, hidden := range regexp.MustCompile(`]*type="hidden"[^>]*>`).FindAllString(form, -1) { + name := regexp.MustCompile(`name="([^"]*)"`).FindStringSubmatch(hidden) + value := regexp.MustCompile(`value="([^"]*)"`).FindStringSubmatch(hidden) + if len(name) == 2 && len(value) == 2 { + posted.Set(name[1], html.UnescapeString(value[1])) + } + } + maps.Copy(posted, values) + + req, err := gohttp.NewRequestWithContext(t.Context(), gohttp.MethodPost, action.String(), strings.NewReader(posted.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return read(t, browser, req) +} + +// formMatching cuts out the one
whose opening tag matches. A keycloak page carries more than +// one — the locale picker is a form too — so the first one is not the one to post to. +func formMatching(t *testing.T, body, pattern string) string { + t.Helper() + opening := regexp.MustCompile(`]*` + pattern + `[^>]*>`).FindStringIndex(body) + require.NotNilf(t, opening, "no matching %s in:\n%s", pattern, body) + element, _, _ := strings.Cut(body[opening[0]:], "") + return element +} + +func read(t *testing.T, browser *gohttp.Client, req *gohttp.Request) htmlPage { + t.Helper() + resp, err := browser.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + // resp.Request is the last request in the redirect chain, so its URL is the page's own. + return htmlPage{url: resp.Request.URL, body: string(body)} +} + +func firstSubmatch(t *testing.T, body, pattern string) string { + t.Helper() + match := regexp.MustCompile(pattern).FindStringSubmatch(body) + require.Lenf(t, match, 2, "nothing matched %s in:\n%s", pattern, body) + return match[1] +} diff --git a/cmd/internal/testacc/testacc_test.go b/cmd/internal/testacc/testacc_test.go new file mode 100644 index 00000000..f64d7362 --- /dev/null +++ b/cmd/internal/testacc/testacc_test.go @@ -0,0 +1,184 @@ +// Package testacc runs the real `meshstack` binary against a live local meshStack, and acts as the +// browser itself where a login needs one. +package testacc + +import ( + "context" + "encoding/json/v2" + "fmt" + gohttp "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/pkg/setting" +) + +const ( + envTestAcc = "MESHSTACK_CLI_TEST_ACC" + // envNoBrowser is internal/oidc/browser's own escape hatch, and not a setting.Setting. + envNoBrowser = "MESHSTACK_CLI_NO_BROWSER" + envEndpoint = "MESHSTACK_ENDPOINT" + envConfigDir = "MESHSTACK_CONFIG_DIR" + envProfile = "MESHSTACK_PROFILE" + envWorkspace = "MESHSTACK_WORKSPACE" + testAccOn = "1" + loopbackHosts = "http://localhost http://127.0.0.1" +) + +var meshstack string + +func TestMain(m *testing.M) { + os.Exit(func() int { + if os.Getenv(envTestAcc) != testAccOn { + return m.Run() + } + dir, err := os.MkdirTemp("", "meshstack-testacc") + if err != nil { + fmt.Fprintln(os.Stderr, "cannot create a directory for the binary under test:", err) + return 1 + } + defer func() { _ = os.RemoveAll(dir) }() + + // -o names the directory, so the binary keeps the name `go build ./cmd/meshstack` gives it. + build := exec.CommandContext(context.Background(), "go", "build", "-o", dir, "./cmd/meshstack") + build.Dir = filepath.Join("..", "..", "..") + build.Stdout, build.Stderr = os.Stdout, os.Stderr + if err := build.Run(); err != nil { + fmt.Fprintln(os.Stderr, "cannot build the meshstack binary under test:", err) + return 1 + } + meshstack = filepath.Join(dir, "meshstack") + return m.Run() + }()) +} + +func requireLocalStack(t *testing.T) string { + t.Helper() + if os.Getenv(envTestAcc) != testAccOn { + t.Skipf("acceptance tests are off. Bring up a local dev stack, export its values with `set -a; . ../.env-satellites-testacc; set +a`, and run `%s=%s go test ./cmd/internal/testacc/... -run TestAcc`", + envTestAcc, testAccOn) + } + endpoint := strings.TrimSuffix(os.Getenv(envEndpoint), "/") + require.Truef(t, isLoopback(endpoint), + "%s=%q does not name a loopback address. These tests log in and write objects, so they run against a local dev stack and nothing else.", + envEndpoint, os.Getenv(envEndpoint)) + return endpoint +} + +func isLoopback(endpoint string) bool { + for host := range strings.FieldsSeq(loopbackHosts) { + if strings.HasPrefix(endpoint, host) { + return true + } + } + return false +} + +func requireEnv(t *testing.T, key string) string { + t.Helper() + value := os.Getenv(key) + require.NotEmptyf(t, value, + "%s is not set. `./gradlew satelliteEnv` in ../meshfed-release writes ../.env-satellites-testacc, and `set -a; . ../.env-satellites-testacc; set +a` exports it.", key) + return value +} + +// meshInfo decodes the public document into the struct the CLI decodes it into, so this suite fails +// when client.MeshInfo and the backend disagree about it. +func meshInfo(t *testing.T, endpoint string) client.MeshInfo { + t.Helper() + req, err := gohttp.NewRequestWithContext(t.Context(), gohttp.MethodGet, endpoint+"/mesh/info", nil) + require.NoError(t, err) + req.Header.Set("Accept", "application/json") + + resp, err := gohttp.DefaultClient.Do(req) + require.NoErrorf(t, err, "%s is not answering; bring the local dev stack up first", endpoint) + defer func() { _ = resp.Body.Close() }() + require.Equalf(t, gohttp.StatusOK, resp.StatusCode, "%s/mesh/info answered %s", endpoint, resp.Status) + + var info client.MeshInfo + require.NoError(t, json.UnmarshalRead(resp.Body, &info)) + require.NotEmptyf(t, info.Issuer.String(), "%s/mesh/info names no issuer, so there is no keycloak to log in at", endpoint) + require.NotEmptyf(t, info.CliClientId, "%s/mesh/info names no cliClientId, so the CLI has no OIDC client", endpoint) + return info +} + +type cli struct { + t *testing.T + configDir string + endpoint string + extraEnv []string +} + +func newCLI(t *testing.T, endpoint string) *cli { + t.Helper() + return &cli{t: t, configDir: t.TempDir(), endpoint: endpoint} +} + +func (c *cli) setEnv(key, value string) { + c.extraEnv = append(c.extraEnv, key+"="+value) +} + +// environ blanks every MESHSTACK_* name this suite does not set on purpose, so that a developer's +// .env cannot decide what a test proves. +func (c *cli) environ() []string { + return append(append(os.Environ(), + envConfigDir+"="+c.configDir, + envNoBrowser+"="+testAccOn, + envEndpoint+"="+c.endpoint, + envProfile+"=", + envWorkspace+"=", + setting.ApiKeyClientId.EnvKey()+"=", + setting.ApiKeyClientSecret.EnvKey()+"=", + setting.ApiToken.EnvKey()+"=", + ), c.extraEnv...) +} + +func (c *cli) command(args ...string) *exec.Cmd { + cmd := exec.CommandContext(c.t.Context(), meshstack, args...) + cmd.Env = c.environ() + cmd.Stdin = nil + return cmd +} + +// Every test brings its own configuration directory, so the profile is always the default one. +const defaultProfile = "default" + +// The paths mirror config.Directory's layout, spelled out rather than imported: where the files +// land is what is under test. +func (c *cli) credentialsJson() string { + return filepath.Join(c.configDir, "credentials", defaultProfile+".json") +} + +func (c *cli) credentialsCacheJson(credential string) string { + return filepath.Join(c.configDir, "credentials-cache", defaultProfile, credential+".json") +} + +func (c *cli) profilesJson() string { + return filepath.Join(c.configDir, "profiles.json") +} + +// syncBuffer collects a subprocess's output while a test reads it, so the two need a lock. +type syncBuffer struct { + mu sync.Mutex + data []byte +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.data = append(b.data, p...) + return len(p), nil +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return string(b.data) +} From 297c2c4b66fb18805e3d0dd633edc1be22d1fd65 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 18 Sep 2026 21:45:05 +0200 Subject: [PATCH 213/215] feat: take the profile name from a --profile flag The profile setting becomes part of the surface the Terraform provider imports, which declares a profile block attribute from it, so the declaration stays in the package that owns profiles and each front end only contributes a source. As a front end source the flag outranks MESHSTACK_PROFILE, the profile matching the endpoint and the last selected profile. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/internal/flags.go | 1 + cmd/internal/session.go | 1 + cmd/meshstack/meshstack.go | 1 + pkg/setting/setting.go | 2 ++ 4 files changed, 5 insertions(+) diff --git a/cmd/internal/flags.go b/cmd/internal/flags.go index f7d6d61e..55938f52 100644 --- a/cmd/internal/flags.go +++ b/cmd/internal/flags.go @@ -13,6 +13,7 @@ var ( SkipVersionCheckFlag = NewFlagForSetting[bool]("skip-version-check", setting.SkipVersionCheck) EndpointFlag = NewFlagForSetting[string]("endpoint", setting.Endpoint) WorkspaceFlag = NewFlagForSetting[string]("workspace", setting.Workspace) + ProfileFlag = NewFlagForSetting[string]("profile", setting.Profile) ) type FlagName string diff --git a/cmd/internal/session.go b/cmd/internal/session.go index dd2a3c7c..8e67447f 100644 --- a/cmd/internal/session.go +++ b/cmd/internal/session.go @@ -32,5 +32,6 @@ func SettingSources() setting.Sources { // An unset bool flag reads false, and a frontend source outranks the environment, so // contributing that false would mask MESHSTACK_SKIP_VERSION_CHECK. SkipVersionCheckFlag.AsSourceUnless(func(skip bool) bool { return !skip }), + ProfileFlag.AsSource(), } } diff --git a/cmd/meshstack/meshstack.go b/cmd/meshstack/meshstack.go index 51e43b5f..416a43b1 100644 --- a/cmd/meshstack/meshstack.go +++ b/cmd/meshstack/meshstack.go @@ -49,6 +49,7 @@ func newRootCommand() *cobra.Command { internal.EndpointFlag.Register(persistentFlags) internal.WorkspaceFlag.Register(persistentFlags) internal.SkipVersionCheckFlag.Register(persistentFlags) + internal.ProfileFlag.Register(persistentFlags) cmd.AddCommand(auth.New()) // `meshstack login` is a shortcut for `meshstack auth login`. Calling the constructor a second diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 44080f16..d48a9407 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -3,6 +3,7 @@ package setting import ( "github.com/meshcloud/meshstack-cli/internal/auth" "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/profile" ) type ( @@ -19,6 +20,7 @@ var ( Endpoint Setting = meshstack.EndpointSetting Workspace Setting = meshstack.WorkspaceSetting SkipVersionCheck Setting = meshstack.SkipVersionCheckSetting + Profile Setting = profile.NameSetting ApiKeyClientId Setting = auth.ApiKeyClientIdSetting ApiKeyClientSecret Setting = auth.ApiKeyClientSecretSetting From e3659cb34f38bd8a5b1a6d25bc8e53201db2a97c Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 18 Sep 2026 21:48:03 +0200 Subject: [PATCH 214/215] feat: create the profile a login names when it does not exist yet Profiles were only ever created on first-time use, when there is no configuration file at all, so a name other than the default named a profile that could never come to exist and --profile had almost nothing to select. A login is the command that writes a profile, so it is the one that may create one; for every other command an unknown name is a typo, and an empty profile written from it would hide the mistake instead of reporting it. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/auth/login.go | 4 +++- internal/auth/session.go | 7 ++++++- internal/auth/session_test.go | 28 +++++++++++++++++++++++++ internal/profile/profiles.go | 31 +++++++++++++++++---------- internal/profile/resolve.go | 19 +++++++++++++---- internal/profile/resolve_test.go | 36 ++++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 17 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 9c62385d..f9fc9c40 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -30,7 +30,8 @@ func NewLogin() *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Log in to meshStack", - Long: `Log in to meshStack and store the credential in a profile. + Long: `Log in to meshStack and store the credential in a profile, creating that profile where it +does not exist yet. With no flag this is a browser login, and it asks which workspace to work in unless --workspace or MESHSTACK_WORKSPACE already says. An API key login asks the same way, while --apitoken asks nothing.`, @@ -80,6 +81,7 @@ MESHSTACK_WORKSPACE already says. An API key login asks the same way, while --ap session, err := internal.ResolveSession(ctx, func(opts *auth.ResolveSessionOptions) { opts.SettingSources = append(opts.SettingSources, sources...) opts.ForceAuthWith = forceAuthWith + opts.CreateProfileIfMissing = true }) if err != nil { return err diff --git a/internal/auth/session.go b/internal/auth/session.go index 4181942e..85060ade 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -41,12 +41,17 @@ type ( GitHubRepo string ForceAuthWith credential.Name + + // CreateProfileIfMissing writes the profile this run names as a new one instead of failing + // on it. A login sets it, and no other command does: an unknown name is a typo there. + CreateProfileIfMissing bool } ) func ResolveSession(ctx context.Context, opts ResolveSessionOptions) (Session, error) { currentProfile, profiles, err := profile.ResolveProfile(ctx, profile.ResolveProfileOptions{ - SettingSources: opts.SettingSources, + SettingSources: opts.SettingSources, + CreateProfileIfMissing: opts.CreateProfileIfMissing, }) if err != nil { return Session{}, err diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index 9e586b1d..f01541be 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -118,6 +118,34 @@ func TestSessionReusesAStoredTokenUntilTheApiKeyChanges(t *testing.T) { }) } +func TestSessionOfALoginCreatesAndStoresTheProfileItNames(t *testing.T) { + newTestServer(t) + testApiKey1.SetEnv(t) + // The first session writes profiles.json, so the name below is one missing from a file that + // does exist, which is the case every command but a login rejects. + firstSession, err := auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err) + require.NoError(t, firstSession.Store(t.Context())) + + t.Setenv(profile.NameSetting.EnvKey(), "dev") + _, err = auth.ResolveSession(t.Context(), testSessionOpts) + require.ErrorContains(t, err, "no profile found with name dev") + + loginOpts := testSessionOpts + loginOpts.CreateProfileIfMissing = true + login, err := auth.ResolveSession(t.Context(), loginOpts) + require.NoError(t, err) + require.NoError(t, login.Store(t.Context())) + + _, err = auth.ResolveSession(t.Context(), testSessionOpts) + require.NoError(t, err, "the created profile is on disk for every later command") + + t.Setenv(profile.NameSetting.EnvKey(), "") + current, _, err := profile.ResolveProfile(t.Context(), profile.ResolveProfileOptions{}) + require.NoError(t, err) + assert.Equal(t, profile.Name("dev"), current.Name, "the login selected the profile it created") +} + var ( testSessionOpts = auth.ResolveSessionOptions{Version: "dev", GitHubRepo: "meshcloud/test-client"} testApiKey1 = testserver.ApiKey{ClientId: "11111111-45bf-42ba-a965-2097b9d0d181", ClientSecret: "super-test-secret-1"} diff --git a/internal/profile/profiles.go b/internal/profile/profiles.go index 48d15dae..18f6f08d 100644 --- a/internal/profile/profiles.go +++ b/internal/profile/profiles.go @@ -52,25 +52,34 @@ func (ps Profiles) Store(ctx context.Context) error { func initEmptyProfiles(ctx context.Context, opts ResolveProfileOptions, profiles *Profiles) error { profiles.Version = version - if name, err := opts.ResolveSetting(ctx, NameSetting); err != nil { + name, err := opts.ResolveSetting(ctx, NameSetting) + if err != nil { return err - } else { - slog.InfoContext(ctx, fmt.Sprintf("Initializing first-time use profile '%s'", name)) - profiles.CurrentProfile = name } + slog.InfoContext(ctx, fmt.Sprintf("Initializing first-time use profile '%s'", name)) + _, err = addProfile(ctx, opts, profiles, name) + return err +} - currentProfile := &Profile{} - currentProfile.init(profiles.CurrentProfile, profiles.configDir) - profiles.Profiles = map[Name]*Profile{profiles.CurrentProfile: currentProfile} +// addProfile creates the named profile and makes it the current one, with the endpoint of this run +// where there is one. +func addProfile(ctx context.Context, opts ResolveProfileOptions, profiles *Profiles, name Name) (*Profile, error) { + added := &Profile{} + added.init(name, profiles.configDir) + if profiles.Profiles == nil { + profiles.Profiles = make(map[Name]*Profile, 1) + } + profiles.Profiles[name] = added + profiles.CurrentProfile = name if endpoint, err := opts.ResolveSetting(ctx, meshstack.EndpointSetting); err == nil { - currentProfile.Endpoint = &endpoint + added.Endpoint = &endpoint } else if !errors.Is(err, setting.ErrNoSourceProvidedValue) { - return err + return nil, err } - slog.DebugContext(ctx, fmt.Sprintf("Initial profile %s resolved to %+v", profiles.CurrentProfile, *currentProfile)) - return nil + slog.DebugContext(ctx, fmt.Sprintf("Profile %s resolved to %+v", name, *added)) + return added, nil } func (ps Profiles) validate() (err error) { diff --git a/internal/profile/resolve.go b/internal/profile/resolve.go index e61b1662..c21f7af1 100644 --- a/internal/profile/resolve.go +++ b/internal/profile/resolve.go @@ -15,6 +15,11 @@ type ( SettingSources = setting.Sources ResolveProfileOptions struct { SettingSources + + // CreateProfileIfMissing writes the resolved name as a new profile instead of failing on + // it. Only a login may ask for that: for every other command an unknown name is a typo, + // and an empty profile is no help to it. + CreateProfileIfMissing bool } ) @@ -56,13 +61,19 @@ func ResolveProfile(ctx context.Context, opts ResolveProfileOptions) (*Profile, return nil, Profiles{}, err } - if profiles, err := loadProfiles(); err != nil { + profiles, err := loadProfiles() + if err != nil { return nil, profiles, err - } else if profile, ok := profiles.Profiles[name]; !ok { + } + if currentProfile, ok := profiles.Profiles[name]; ok { + return currentProfile, profiles, nil + } + if !opts.CreateProfileIfMissing { return nil, profiles, fmt.Errorf("no profile found with name %s", name) - } else { - return profile, profiles, nil } + slog.InfoContext(ctx, fmt.Sprintf("Creating profile '%s'", name)) + created, err := addProfile(ctx, opts, &profiles, name) + return created, profiles, err } func (ps Profiles) findProfileNameByMatchingEndpoint(ctx context.Context, opts ResolveProfileOptions) (string, error) { diff --git a/internal/profile/resolve_test.go b/internal/profile/resolve_test.go index 2da6cb54..75bb352e 100644 --- a/internal/profile/resolve_test.go +++ b/internal/profile/resolve_test.go @@ -98,6 +98,42 @@ func TestResolveProfileReadsTheCurrentProfileAndItsCachedTokenFromDisk(t *testin require.NoError(t, profiles.Store(t.Context())) } +func TestResolveProfileFailsOnANameThatIsNotOnDisk(t *testing.T) { + givenNoMeshstackEnvironment(t) + t.Setenv(config.DirectorySetting.EnvKey(), "testdata/configdir") + t.Setenv(NameSetting.EnvKey(), "dev-locl") + + _, _, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) + + require.ErrorContains(t, err, "no profile found with name dev-locl") +} + +func TestResolveProfileCreatesAMissingProfileForALoginAndUpdatesItOnTheNextOne(t *testing.T) { + givenNoMeshstackEnvironment(t) + t.Setenv(config.DirectorySetting.EnvKey(), t.TempDir()) + _, defaultOnly, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) + require.NoError(t, err) + require.NoError(t, defaultOnly.Store(t.Context())) + + t.Setenv(NameSetting.EnvKey(), "dev") + created, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{CreateProfileIfMissing: true}) + + require.NoError(t, err) + dev := &Profile{Name: "dev"} + assert.EqualExportedValues(t, dev, withoutConfigDir(created)) + assertProfiles(t, profiles, dev, &Profile{Name: "default"}) + + // What a login stores through the returned pointer has to survive the next one. + created.DefaultWorkspace = "my-workspace-ab12c" + require.NoError(t, profiles.Store(t.Context())) + reloaded, reloadedProfiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{CreateProfileIfMissing: true}) + + require.NoError(t, err) + dev.DefaultWorkspace = "my-workspace-ab12c" + assert.EqualExportedValues(t, dev, withoutConfigDir(reloaded)) + assertProfiles(t, reloadedProfiles, dev, &Profile{Name: "default"}) +} + func TestLoadProfilesRejectsANullProfile(t *testing.T) { givenNoMeshstackEnvironment(t) configDir := t.TempDir() From b8e05c384f8379d3c634647b7c2bd27e101f5554 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 18 Sep 2026 21:49:29 +0200 Subject: [PATCH 215/215] feat: report the resolved credential while the workspace is resolved The Terraform provider contributes setting sources but holds no session, so it cannot ask which credential a run authenticates with. It needs that to look a workspace up only for a browser login, where a workspace is what scopes the token, and the context carrying the workspaces to pick from is where the answer already belongs. Co-Authored-By: Claude Opus 5 (1M context) --- internal/auth/credential/name.go | 19 +++++++++++++ internal/auth/session.go | 3 +- internal/auth/workspace_test.go | 47 ++++++++++++++++++++++++++++++++ pkg/auth/method.go | 8 ++++++ 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 internal/auth/workspace_test.go diff --git a/internal/auth/credential/name.go b/internal/auth/credential/name.go index 0e11919b..76c54bfa 100644 --- a/internal/auth/credential/name.go +++ b/internal/auth/credential/name.go @@ -1,10 +1,13 @@ package credential import ( + "context" "fmt" "maps" "reflect" "slices" + + "github.com/meshcloud/meshstack-cli/internal/meshstack" ) type Name string @@ -25,6 +28,22 @@ const ( // Names lists every credential, in the order a resolution tries and reports them. var Names = []Name{ApiKeyName, ManualName, OidcLoginName} +type nameContextKey int + +func NameFromContext(ctx context.Context) (Name, error) { + name, found := ctx.Value(nameContextKey(0)).(Name) + if !found { + // An error rather than a panic, because a front end reaches this through pkg/auth and a + // panic there takes its process down. + return "", fmt.Errorf("no credential available in this context; it is only provided while resolving %s", meshstack.WorkspaceSetting.EnvKey()) + } + return name, nil +} + +func SetNameInContext(ctx context.Context, name Name) context.Context { + return context.WithValue(ctx, nameContextKey(0), name) +} + // A name that no field of Credentials carries resolves to nothing and stores to nowhere, without // saying so, which is why the two lists are checked against each other at startup. func init() { diff --git a/internal/auth/session.go b/internal/auth/session.go index 85060ade..31c26538 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -183,7 +183,8 @@ func (s Session) resolveWorkspace(ctx context.Context, currentProfile profile.Pr } return })) - return opts.ResolveSetting(ctxWithWorkspaces, meshstack.WorkspaceSetting, currentProfile.WorkspaceSource()) + ctxWithCredential := credential.SetNameInContext(ctxWithWorkspaces, s.Credentials.NameOf(s.Credential)) + return opts.ResolveSetting(ctxWithCredential, meshstack.WorkspaceSetting, currentProfile.WorkspaceSource()) } // withNoWorkspace is the session the workspace resolution itself can use: it names no workspace, so diff --git a/internal/auth/workspace_test.go b/internal/auth/workspace_test.go new file mode 100644 index 00000000..f17c034e --- /dev/null +++ b/internal/auth/workspace_test.go @@ -0,0 +1,47 @@ +package auth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/profile" + "github.com/meshcloud/meshstack-cli/internal/setting" +) + +func TestAWorkspaceSourceReadsTheResolvedCredentialFromTheContext(t *testing.T) { + for _, testCase := range []struct { + resolved credential.Credential + expected credential.Name + }{ + {&credential.OidcLogin{}, credential.OidcLoginName}, + {&credential.ApiKey{}, credential.ApiKeyName}, + } { + t.Run(testCase.expected.String(), func(t *testing.T) { + session := Session{Credential: testCase.resolved} + opts := ResolveSessionOptions{SettingSources: setting.Sources{workspaceNamedAfterTheCredential()}} + + workspace, err := session.resolveWorkspace(t.Context(), profile.Profile{}, opts) + + require.NoError(t, err) + assert.EqualValues(t, testCase.expected, workspace) + }) + } +} + +// workspaceNamedAfterTheCredential answers with the credential it found, so that the workspace the +// resolution returns is what the source read out of the context. +func workspaceNamedAfterTheCredential() setting.FrontendSource { + return setting.FrontendSource{Source: setting.LookupSource{ + MatchingKey: meshstack.WorkspaceSetting.EnvKey(), + Description: "the credential in the context", + Func: func(ctx context.Context) (string, error) { + name, err := credential.NameFromContext(ctx) + return name.String(), err + }, + }} +} diff --git a/pkg/auth/method.go b/pkg/auth/method.go index 30e10270..e6c27ca9 100644 --- a/pkg/auth/method.go +++ b/pkg/auth/method.go @@ -1,6 +1,8 @@ package auth import ( + "context" + "github.com/meshcloud/meshstack-cli/internal/auth/credential" ) @@ -15,3 +17,9 @@ const ( // OidcLoginMethod logs a person in through a browser, so it resolves only when asked for by name. OidcLoginMethod = credential.OidcLoginName ) + +// MethodFromContext returns how this session authenticates. The method is only in the context +// while MESHSTACK_WORKSPACE is being resolved, so a lookup for any other setting gets an error. +func MethodFromContext(ctx context.Context) (Method, error) { + return credential.NameFromContext(ctx) +}