diff --git a/.claude/skills/development/SKILL.md b/.claude/skills/development/SKILL.md index f02bc69d..e2fa7aac 100644 --- a/.claude/skills/development/SKILL.md +++ b/.claude/skills/development/SKILL.md @@ -1,6 +1,6 @@ --- name: development -description: How the meshStack CLI's Go code is laid out and built - where a command, its logic and an HTTP call go, and what the client and settings must keep. Use when writing, changing or reviewing Go code here - a new command or package, a change to client/, pkg/ or internal/http, an HTTP request, a new setting or dependency, or a depguard or forbidigo finding. +description: How the meshStack CLI's Go code is laid out and built - where a command, its logic and an HTTP call go, and what the client and settings must keep. Use when writing, changing or reviewing Go code here - a new command or package, a change to client/, client/http or pkg/, an HTTP request, a new setting or dependency, or a depguard or forbidigo finding. --- # Developing the meshStack CLI @@ -123,12 +123,14 @@ there, later, and not in this repository's CI. `develop` but not released. The source is the controllers and meshObjects of `../meshfed-release` (*meshcloud-internal*). -**The provider implements the client's interfaces.** Its tests plug the mocks of its -`internal/clientmock` into `client.Client`, so a method added to a `Mesh…Client` interface stops -the provider compiling at its next bump. Put a method only the CLI calls on a new `client.Client` -field instead: an interface of its own, or a concrete type such as `*client.RawClient` when nothing -mocks it. `client.New` sets the field for every caller, while the provider's mock client fills the -struct by field name and leaves a new field unset. +**The provider implements the client's interfaces.** Every kind's client is the generic +`client.MeshObjectClient[M, Id, Filter]`, with Read, List, Create, Update and Delete, under a type +alias named after the kind. The tests of the provider plug the mocks of its `internal/clientmock` +into `client.Client`, so a method added to a `Mesh…Client` interface stops the provider compiling +at its next bump. An action of a kind follows a link of the object's `_links` through +`hal.Links`, such as `run.Links.Post[struct{}](ctx, "abort", nil)`. Put anything else only the CLI +calls on a new `client.Client` field, which the provider's mock client leaves unset, or in a +function of `client`, such as `client.WithKindAndApiVersion`. Reading the pre-import history takes both paths, since the import merge re-roots the files under `client/`: @@ -138,32 +140,33 @@ git log -- client/client.go client.go # a path-limited log from client/ alone 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 +**`client/` does not log in.** `http.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.ResolveClient`, 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`, because `internal/oidc` and `pkg/auth` need them and Go's internal rule closes -`client/internal` to both. Its names carry no `Http` prefix: `http.Client`, `http.Error`. +**HTTP is `client/http`.** The client, the request options and the retry policy are there, because +`internal/oidc` and `pkg/auth` need them as well, and another module calls `client.New` with them. +No exported name of `client/` may use an `internal/` type, because another module cannot name it. +Its names carry no `Http` prefix: `http.Client`, `http.Error`. **Every request names the front end and version that sent it.** `http.NewClient` takes an `http.UserAgent`, which `auth.ResolveSessionOptions` embeds: the front end sets its `GitHubRepo` and `Version` once, the CLI in `cmd/internal.ResolveClientOptions`, and the release check reads the same two. So code below `cmd/` takes its client from its caller, and a command that needs one for anything but a meshStack client calls `internal.ResolveClientOptions().HttpClient()`. `forbidigo` -keeps `http.UserAgent` to those options, and the transport to `internal/http`. +keeps `http.UserAgent` to those options, and the transport to `client/http`. **`net/http` is always imported as `gohttp`**, which `importas` settles. That leaves the plain name -to `internal/http`, and `net/http` to the status and method constants and to the loopback server. +to `client/http`, and `net/http` to the status and method constants and to the loopback server. The `forbidigo` rule matches on the type rather than 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 constrains every log call, and `internal/http/logging.go` states how. +installed that late constrains every log call, and `client/http/logging.go` states how. ## Settings diff --git a/.claude/skills/meshobject-command/SKILL.md b/.claude/skills/meshobject-command/SKILL.md index 87650d06..6593dcdf 100644 --- a/.claude/skills/meshobject-command/SKILL.md +++ b/.claude/skills/meshobject-command/SKILL.md @@ -44,8 +44,8 @@ the `help-text` rule of the `development` skill. ## Leaf -- `list` lists the kind with `flags.Run[M](cmd, filter)` of `internal.ListFlags`, which pages through - `client.Raw.List[M]`. +- `list` lists the kind with `flags.Run(cmd, meshStack., filter)` of `internal.ListFlags`, + which pages through the kind's `List`. - `show` shows one object. - A sub-resource or an action is named after its path element in the API: `buildingblockrun logs` for `…/logs`, `buildingblock trigger-run` for `…/trigger-run`. @@ -65,11 +65,10 @@ and a `cmd/internal/markdown` template next to the command. - A command gets its client from `internal.ResolveClient`, or for the session itself from `auth.ResolveSession(ctx, internal.ResolveClientOptions(...))` and `Session.Client`. A `ResolveClientOptionsModifier` such as `internal.SkipVersionCheck` adapts the resolution. -- What the CLI writes as meshStack sent it goes through `client.Raw`: `List[M]` with the kind's - list filter struct, `Get[M]` for one object or a sub-resource below it, and `DoRequest` for any - other path. `M` is the Go type of the kind's typed client, which `client.New` registers with - `Raw`; register a kind there before a command reads it. -- A list filter struct holds every query parameter the API docs give its endpoint, a repeated one +- The CLI writes an object as meshStack sent it through `client.WithKindAndApiVersion`. A + sub-resource or an action follows a link of the typed object through its `Links`, and any other + path goes through `client.Client.HttpClient`, joined onto `client.Client.Endpoint`. +- A filter struct holds every query parameter the API docs give its endpoint, a repeated one as a slice. A parameter the command always sends, such as `includeAllPublished`, is a field the command sets. Any other client method only the CLI calls goes on a new `client.Client` field, so the Terraform provider's mocks keep compiling. diff --git a/.golangci.yml b/.golangci.yml index e79805fb..29169942 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -87,12 +87,12 @@ linters: - gosec text: G101 path: _test\.go - - path: internal/http/ + - path: client/http/ linters: - forbidigo text: one HTTP client # The options of a session carry the user agent the front end sets. - - path: (internal/http/|internal/auth/session\.go|internal/testutil/|_test\.go) + - path: (client/http/|internal/auth/session\.go|internal/testutil/|_test\.go) linters: - forbidigo text: user agent of the front end @@ -101,10 +101,6 @@ linters: linters: - forbidigo text: stdout is a command's output - - path: internal/tfstate/apiproxy/ - linters: - - forbidigo - text: one HTTP client # The fake clock is a test's; production code waits on the real one. - path-except: _test\.go linters: @@ -130,17 +126,17 @@ linters: - gosec settings: - # The process has one HTTP client, built in internal/http. + # The process has one HTTP client, built in client/http. forbidigo: analyze-types: true forbid: # Anchored to the package, as net/http has more methods named Get, such as Header.Get. - pattern: ^http\.(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 + msg: the process has one HTTP client, built in client/http; take it from there rather than making another # A user agent made anywhere else drifts from the one the front end names itself by. - pattern: ^http\.UserAgent$ - pkg: ^github\.com/meshcloud/meshstack-cli/internal/http$ + pkg: ^github\.com/meshcloud/meshstack-cli/client/http$ msg: a client carries the user agent of the front end; take the client from your caller, or from auth.ResolveSessionOptions.HttpClient # A test that sleeps for real is slow, and flaky once the machine is slower than the # sleep assumed. synctest.Sleep panics outside a bubble, so it cannot be used the same way. @@ -187,7 +183,7 @@ linters: checks: - all - -ST1000 - # SA4023 crashes on a type with a generic method, as client.RawClient has: go1.27.0 loses + # SA4023 crashes on a type with a generic method, as hal.Link has: go1.27.0 loses # the order of methods in the export data (golang/go#81188). Enable it again with go1.27.2. - -SA4023 # Replaces the default list, so that ClientId, BaseUrl and Uuid stand as the meshObject @@ -243,7 +239,15 @@ linters: - $all deny: - pkg: encoding/json$ - desc: Use encoding/json/v2 and internal/json instead + desc: Use internal/json instead + + json-v2: + files: + - $all + - "!**/internal/json/*.go" + deny: + - pkg: encoding/json/v2 + desc: Use internal/json instead # cobra is used in cmd/ and nowhere else. cmd: @@ -267,8 +271,8 @@ linters: - log/slog # a listing yields raw items, and reads a field out of one - encoding/json/jsontext - - encoding/json/v2 - # no direct access to internal/ + - github.com/meshcloud/meshstack-cli/internal/json + # no direct access to internal/, apart from the JSON codec above - github.com/meshcloud/meshstack-cli/pkg # a parent command calls its children's New, the same way cmd/meshstack calls its own - github.com/meshcloud/meshstack-cli/cmd @@ -305,17 +309,13 @@ linters: - log/slog # a request body is checked as JSON, and api-docs writes a part of the OpenAPI document - encoding/json/jsontext - - encoding/json/v2 + - github.com/meshcloud/meshstack-cli/internal/json - github.com/meshcloud/meshstack-cli/cmd/internal - github.com/meshcloud/meshstack-cli/client - # the request options client.Raw.DoRequest takes, which pkg/ does not expose: they are for the - # CLI alone - - github.com/meshcloud/meshstack-cli/internal/http # the API docs choose the version of a request - github.com/meshcloud/meshstack-cli/internal/apidocs - # a full URL picks the stored profile whose endpoint holds it, and has to fit an endpoint - # or profile named by flag or environment, so api reads the stored profiles and the - # source of both settings, which pkg/ leaves out + # api-docs takes the endpoint of the profile without a login, and a full URL gives its + # origin as the endpoint setting, both of which pkg/ leaves out - github.com/meshcloud/meshstack-cli/internal/profile - github.com/meshcloud/meshstack-cli/internal/meshstack - github.com/meshcloud/meshstack-cli/internal/setting @@ -334,8 +334,7 @@ linters: allow: - $gostd - log/slog - # the proxy that serves the state to tofu. It needs the request options of - # client.Raw.DoRequest, and pkg/ does not expose them, because they are for the CLI alone. + # the proxy that serves the state to tofu - github.com/meshcloud/meshstack-cli/internal/tfstate - github.com/meshcloud/meshstack-cli/cmd/internal - github.com/meshcloud/meshstack-cli/pkg/setting @@ -412,7 +411,7 @@ linters: - log/slog # a listing writes raw items, and a command that shows one object marshals it - encoding/json/jsontext - - encoding/json/v2 + - github.com/meshcloud/meshstack-cli/internal/json - github.com/meshcloud/meshstack-cli/cmd/internal/markdown - github.com/meshcloud/meshstack-cli/cmd/internal/color - github.com/meshcloud/meshstack-cli/pkg @@ -432,7 +431,7 @@ linters: - github.com/charmbracelet/colorprofile # The package the Terraform provider consumes most directly, so it reaches nothing - # beyond the standard library and the three packages below. + # beyond the standard library and the packages below. client: files: # Both patterns are needed: '**/dir/**/*.go' only matches files in @@ -446,13 +445,10 @@ linters: desc: Use log/slog 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: @@ -470,9 +466,9 @@ linters: - github.com/meshcloud/meshstack-cli/internal # client reaches these two, so whatever they may import, client gets. - internal-http: + client-http: files: - - "**/internal/http/*.go" + - "**/client/http/*.go" - "**/internal/json/*.go" - "!$test" list-mode: strict @@ -482,9 +478,9 @@ linters: - encoding/json/jsontext - github.com/meshcloud/meshstack-cli/internal/json - internal-version: + client-types-version: files: - - "**/internal/version/*.go" + - "**/client/types/version/*.go" - "!$test" list-mode: strict deny: @@ -499,13 +495,13 @@ linters: - "!**/cmd/**/*.go" # ...except cmd, whose internal packages the cmd rules cover - "!$test" - "!**/internal/testutil/**/*.go" + - "!**/internal/json/*.go" # client-http covers it list-mode: strict deny: - pkg: log desc: Use log/slog instead allow: - $gostd - - encoding/json/v2 - encoding/json/jsontext - log/slog - github.com/meshcloud/meshstack-cli/internal @@ -520,7 +516,6 @@ linters: list-mode: strict allow: - $gostd - - encoding/json/v2 - encoding/json/jsontext - github.com/meshcloud/meshstack-cli - github.com/stretchr/testify @@ -545,6 +540,7 @@ linters: list-mode: strict allow: - $gostd + - github.com/meshcloud/meshstack-cli/internal/json - github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack # A test may use every direct dependency in go.mod: the rules above already confine where @@ -552,10 +548,11 @@ linters: tests: files: - "$test" + - "!**/client/http/*_test.go" + - "!**/cmd/internal/testacc/*_test.go" list-mode: strict allow: - $gostd - - encoding/json/v2 - encoding/json/jsontext - github.com/meshcloud/meshstack-cli - charm.land/bubbles/v2 @@ -570,3 +567,32 @@ linters: - github.com/spf13/cobra - github.com/spf13/pflag - github.com/stretchr/testify + + # The API docs scenario breaks the connection to meshStack, as the tests of client/http do. + tests-testacc: + files: + - "**/cmd/internal/testacc/*_test.go" + list-mode: strict + allow: + - $gostd + - encoding/json/jsontext + - github.com/meshcloud/meshstack-cli + - github.com/spf13/cobra + - github.com/stretchr/testify + - github.com/Shopify/toxiproxy/v2 + # toxiproxy.NewServer takes a zerolog.Logger, and zerolog.Nop() is the only use + - github.com/rs/zerolog + + # client/http breaks connections on purpose, which toxiproxy does in process. + tests-client-http: + files: + - "**/client/http/*_test.go" + list-mode: strict + allow: + - $gostd + - encoding/json/jsontext + - github.com/meshcloud/meshstack-cli + - github.com/stretchr/testify + - github.com/Shopify/toxiproxy/v2 + # toxiproxy.NewServer takes a zerolog.Logger, and zerolog.Nop() is the only use + - github.com/rs/zerolog diff --git a/AGENTS.md b/AGENTS.md index 142dbff2..64e19f44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ The binary gets its name from its directory, `cmd/meshstack`, which is what `tas **Load the `development` skill before you write or review Go code here.** It holds the package layout, the split of a command into a front end in `cmd/` and its logic in `internal/`, the command -tree, the dependency policy, and what `client/`, `internal/http` and the settings must keep. +tree, the dependency policy, and what `client/`, `client/http` and the settings must keep. [`skills/meshstack-cli`](skills/meshstack-cli/SKILL.md) is the skill for those who **use** the CLI; other repositories link to it. A change to what a command does, prints or needs updates it in the diff --git a/client/api_key.go b/client/api_key.go index 6605a449..40dc4c72 100644 --- a/client/api_key.go +++ b/client/api_key.go @@ -1,22 +1,21 @@ package client import ( - "context" "time" - "github.com/meshcloud/meshstack-cli/client/internal" "github.com/meshcloud/meshstack-cli/client/types" ) +type MeshApiKeyMetadata = MeshObjectByUuidMetadata + type MeshApiKey struct { Metadata MeshApiKeyMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshApiKeySpec `json:"spec" tfsdk:"spec"` Status *MeshApiKeyStatus `json:"status,omitzero" tfsdk:"status"` } -type MeshApiKeyMetadata struct { - Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +func (k MeshApiKey) meshObjectId() string { + return k.Metadata.meshObjectId() } type MeshApiKeySpec struct { @@ -33,33 +32,4 @@ type MeshApiKeyStatus struct { ExpiresAt time.Time `json:"expiresAt,omitzero" tfsdk:"-"` } -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) -} +type MeshApiKeyClient = MeshObjectClient[MeshApiKey, string, NoFilter] diff --git a/client/building_block_definition.go b/client/building_block_definition.go index 3c062e02..fbafe43d 100644 --- a/client/building_block_definition.go +++ b/client/building_block_definition.go @@ -1,12 +1,9 @@ package client import ( - "context" - - "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" + "github.com/meshcloud/meshstack-cli/client/types/hal" ) type MeshBuildingBlockType string @@ -129,58 +126,25 @@ type MeshBuildingBlockDefinitionStatus struct { LatestReleasedVersion *int64 `json:"latestReleasedVersion"` LatestReleasedVersionUuid *string `json:"latestReleasedVersionUuid"` RedactedForNonOwnerAccess bool `json:"redactedForNonOwnerAccess"` + Lifecycle MeshLifecycle `json:"lifecycle"` } type MeshBuildingBlockDefinition struct { Metadata MeshBuildingBlockDefinitionMetadata `json:"metadata"` Spec MeshBuildingBlockDefinitionSpec `json:"spec"` Status *MeshBuildingBlockDefinitionStatus `json:"status,omitzero"` + Links hal.Links `json:"_links,omitzero"` } -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"), - } -} +type MeshBuildingBlockDefinitionClient = MeshObjectClient[MeshBuildingBlockDefinition, string, MeshBuildingBlockDefinitionFilter] -type MeshBuildingBlockDefinitionListFilter struct { +type MeshBuildingBlockDefinitionFilter struct { OwnedByWorkspace *string `json:"ownedByWorkspace"` // IncludeAllPublished adds the definitions published to the whole platform to the workspace's own. IncludeAllPublished bool `json:"includeAllPublished"` Sort SortCriteria `json:"sort"` } -func (c meshBuildingBlockDefinitionClient) List(ctx context.Context, workspaceIdentifier *string) ([]MeshBuildingBlockDefinition, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(MeshBuildingBlockDefinitionListFilter{ - OwnedByWorkspace: workspaceIdentifier, - IncludeAllPublished: true, - })) -} - -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) { - return c.meshObject.Post(ctx, definition) -} - -func (c meshBuildingBlockDefinitionClient) Update(ctx context.Context, uuid string, definition MeshBuildingBlockDefinition) (*MeshBuildingBlockDefinition, error) { - return c.meshObject.Put(ctx, uuid, definition) -} - -func (c meshBuildingBlockDefinitionClient) Delete(ctx context.Context, uuid string) error { - return c.meshObject.Delete(ctx, uuid) +func (d MeshBuildingBlockDefinition) meshObjectId() string { + return *d.Metadata.Uuid } diff --git a/client/building_block_definition_version.go b/client/building_block_definition_version.go index 014d761b..30854dd2 100644 --- a/client/building_block_definition_version.go +++ b/client/building_block_definition_version.go @@ -1,17 +1,13 @@ package client import ( - "context" "encoding/json/jsontext" - "encoding/json/v2" "errors" - "fmt" - "uuid" - "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" + "github.com/meshcloud/meshstack-cli/client/types/hal" + "github.com/meshcloud/meshstack-cli/internal/json" ) type MeshBuildingBlockDefinitionVersionState string @@ -179,9 +175,9 @@ type MeshBuildingBlockDefinitionOutput struct { } type MeshBuildingBlockDefinitionVersionMetadata struct { - Uuid string `json:"uuid"` - OwnedByWorkspace string `json:"ownedByWorkspace"` - CreatedOn string `json:"createdOn"` + Uuid *string `json:"uuid,omitzero"` + OwnedByWorkspace string `json:"ownedByWorkspace"` + CreatedOn string `json:"createdOn,omitzero"` } type MeshBuildingBlockDefinitionVersionSpec struct { @@ -199,63 +195,27 @@ type MeshBuildingBlockDefinitionVersionSpec struct { } type MeshBuildingBlockDefinitionVersionStatus struct { - State MeshBuildingBlockDefinitionVersionState `json:"state" tfsdk:"state"` - UsageCount *int64 `json:"usageCount,omitzero" tfsdk:"usage_count"` + State MeshBuildingBlockDefinitionVersionState `json:"state" tfsdk:"state"` + UsageCount *int64 `json:"usageCount,omitzero" tfsdk:"usage_count"` + RedactedForNonOwnerAccess bool `json:"redactedForNonOwnerAccess" tfsdk:"-"` + Lifecycle MeshLifecycle `json:"lifecycle" tfsdk:"-"` } type MeshBuildingBlockDefinitionVersion struct { Metadata MeshBuildingBlockDefinitionVersionMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshBuildingBlockDefinitionVersionSpec `json:"spec" tfsdk:"spec"` Status *MeshBuildingBlockDefinitionVersionStatus `json:"status,omitzero" tfsdk:"status"` + Links hal.Links `json:"_links,omitzero" tfsdk:"-"` } -// MeshBuildingBlockDefinitionVersionClient has no Get and no Delete: a caller always reads all -// versions of a definition with List, and the versions are deleted together with their definition. -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"), - } -} +type MeshBuildingBlockDefinitionVersionClient = MeshObjectClient[MeshBuildingBlockDefinitionVersion, string, MeshBuildingBlockDefinitionVersionFilter] -type MeshBuildingBlockDefinitionVersionListFilter struct { - BuildingBlockDefinitionUuid uuid.UUID `json:"buildingBlockDefinitionUuid,omitzero"` +type MeshBuildingBlockDefinitionVersionFilter struct { + BuildingBlockDefinitionUuid string `json:"buildingBlockDefinitionUuid,omitzero"` } -func (c meshBuildingBlockDefinitionVersionClient) List(ctx context.Context, buildingBlockDefinitionUuid string) ([]MeshBuildingBlockDefinitionVersion, error) { - definitionUuid, err := uuid.Parse(buildingBlockDefinitionUuid) - if err != nil { - return nil, fmt.Errorf("building block definition %q: %w", buildingBlockDefinitionUuid, err) - } - return c.meshObject.List(ctx, http.WithUrlQuery(MeshBuildingBlockDefinitionVersionListFilter{ - BuildingBlockDefinitionUuid: definitionUuid, - })) +func (v MeshBuildingBlockDefinitionVersion) meshObjectId() string { + return *v.Metadata.Uuid } -func (c meshBuildingBlockDefinitionVersionClient) Create(ctx context.Context, ownedByWorkspace string, versionSpec MeshBuildingBlockDefinitionVersionSpec) (*MeshBuildingBlockDefinitionVersion, error) { - return c.meshObject.Post(ctx, MeshBuildingBlockDefinitionVersion{ - 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{ - Metadata: MeshBuildingBlockDefinitionVersionMetadata{ - Uuid: uuid, - OwnedByWorkspace: ownedByWorkspace, - }, - Spec: versionSpec, - }) -} +func (MeshBuildingBlockDefinitionVersion) meshObjectInOwnOrder() {} diff --git a/client/building_block_definition_version_implementation.go b/client/building_block_definition_version_implementation.go index edc65eee..714568e3 100644 --- a/client/building_block_definition_version_implementation.go +++ b/client/building_block_definition_version_implementation.go @@ -1,13 +1,9 @@ package client import ( - "encoding/json/jsontext" - "encoding/json/v2" - "fmt" - "github.com/meshcloud/meshstack-cli/client/types" "github.com/meshcloud/meshstack-cli/client/types/enum" - "github.com/meshcloud/meshstack-cli/client/types/variant" + "github.com/meshcloud/meshstack-cli/client/types/polymorph" ) type MeshBuildingBlockImplementationType string @@ -67,52 +63,11 @@ type MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation struct { } type MeshBuildingBlockDefinitionImplementation struct { - Type enum.Entry[MeshBuildingBlockImplementationType] `json:"type" tfsdk:"-"` - 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"` -} - -// InferType accepts a version without any variant: meshStack answers that way to a workspace that -// may only consume the definition. -func (m MeshBuildingBlockDefinitionImplementation) InferType() (enum.Entry[MeshBuildingBlockImplementationType], error) { - result, err := variant.InferType( - variant.NewCandidate(MeshBuildingBlockImplementationTypeManual, m.Manual != nil), - variant.NewCandidate(MeshBuildingBlockImplementationTypeTerraform, m.Terraform != nil), - variant.NewCandidate(MeshBuildingBlockImplementationTypeGithubWorkflows, m.GithubWorkflows != nil), - variant.NewCandidate(MeshBuildingBlockImplementationTypeGitlabPipeline, m.GitlabPipeline != nil), - variant.NewCandidate(MeshBuildingBlockImplementationTypeAzureDevOpsPipeline, m.AzureDevOpsPipeline != nil), - ) - if err != nil { - return "", fmt.Errorf("cannot infer implementation type: %w", err) - } - return result, nil -} - -func (m MeshBuildingBlockDefinitionImplementation) MarshalJSONTo(enc *jsontext.Encoder) error { - type wrapped MeshBuildingBlockDefinitionImplementation - w := wrapped(m) - if len(w.Type) == 0 { - inferred, err := m.InferType() - if err != nil { - return err - } - w.Type = inferred - } - return json.MarshalEncode(enc, w, enc.Options()) -} + polymorph.Polymorph[MeshBuildingBlockDefinitionImplementation, MeshBuildingBlockImplementationType] -func (m *MeshBuildingBlockDefinitionImplementation) UnmarshalJSONFrom(dec *jsontext.Decoder) error { - type wrapped MeshBuildingBlockDefinitionImplementation - var target wrapped - if err := json.UnmarshalDecode(dec, &target, dec.Options()); err != nil { - return err - } - *m = MeshBuildingBlockDefinitionImplementation(target) - if m.Type == MeshBuildingBlockImplementationTypeManual { - m.Manual = &MeshBuildingBlockDefinitionManualImplementation{} - } - return nil + Manual *MeshBuildingBlockDefinitionManualImplementation `json:"manual,omitzero" polymorph:"manual" tfsdk:"manual"` + GithubWorkflows *MeshBuildingBlockDefinitionGitHubWorkflowsImplementation `json:"githubWorkflows,omitzero" polymorph:"githubWorkflows" tfsdk:"github_workflows"` + AzureDevOpsPipeline *MeshBuildingBlockDefinitionAzureDevOpsPipelineImplementation `json:"azureDevOpsPipeline,omitzero" polymorph:"azureDevOpsPipeline" tfsdk:"azure_devops_pipeline"` + GitlabPipeline *MeshBuildingBlockDefinitionGitLabPipelineImplementation `json:"gitlabPipeline,omitzero" polymorph:"gitlabPipeline" tfsdk:"gitlab_pipeline"` + Terraform *MeshBuildingBlockDefinitionTerraformImplementation `json:"terraform,omitzero" polymorph:"terraform" tfsdk:"terraform"` } diff --git a/client/building_block_definition_version_test.go b/client/building_block_definition_version_test.go index ea0c46b9..8d70aa4b 100644 --- a/client/building_block_definition_version_test.go +++ b/client/building_block_definition_version_test.go @@ -2,7 +2,6 @@ package client import ( "embed" - "encoding/json/v2" "path" "testing" @@ -10,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/internal/json" ) //go:embed testdata/building_block_definition_version_input diff --git a/client/building_block_run.go b/client/building_block_run.go index 565cb7bf..054269aa 100644 --- a/client/building_block_run.go +++ b/client/building_block_run.go @@ -2,17 +2,28 @@ package client import ( "context" + "encoding/json/jsontext" + "errors" + "fmt" + "io" "time" - "uuid" - "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/types/hal" + "github.com/meshcloud/meshstack-cli/internal/json" ) +const buildingBlockRunApiVersion = "v1" + +// MeshBuildingBlockRun keeps apiVersion and kind, because a runner passes the run on as it came. type MeshBuildingBlockRun struct { - Metadata MeshBuildingBlockRunMetadata `json:"metadata"` - Spec MeshBuildingBlockRunSpec `json:"spec"` - Status string `json:"status"` - Links MeshBuildingBlockRunLinks `json:"_links,omitzero"` + ApiVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata MeshBuildingBlockRunMetadata `json:"metadata"` + Spec MeshBuildingBlockRunSpec `json:"spec"` + Status string `json:"status"` + Links hal.Links `json:"_links,omitzero"` } func (run MeshBuildingBlockRun) IsRunning() bool { @@ -21,61 +32,139 @@ func (run MeshBuildingBlockRun) IsRunning() bool { type MeshBuildingBlockRunMetadata struct { Uuid string `json:"uuid"` - CreatedAt time.Time `json:"createdAt"` + CreatedAt time.Time `json:"createdAt,omitzero"` } type MeshBuildingBlockRunSpec struct { - RunNumber int64 `json:"runNumber"` + RunNumber int `json:"runNumber"` Behavior string `json:"behavior"` BuildingBlock MeshBuildingBlockRunBuildingBlock `json:"buildingBlock"` + Definition MeshBuildingBlockRunDefinition `json:"buildingBlockDefinition"` + RunToken string `json:"runToken"` + Trigger *MeshBuildingBlockRunTrigger `json:"trigger,omitzero"` } -// MeshBuildingBlockRunBuildingBlock is spec.buildingBlock of a meshBuildingBlockRun. The wire also -// carries spec.buildingBlock.spec.inputs, which holds the run's input values and is left out here -// because a sensitive one goes out encrypted for the runner alone. type MeshBuildingBlockRunBuildingBlock struct { Uuid string `json:"uuid"` Spec MeshBuildingBlockRunBuildingBlockSpec `json:"spec"` } type MeshBuildingBlockRunBuildingBlockSpec struct { - DisplayName string `json:"displayName"` - TargetRef MeshBuildingBlockV2TargetRef `json:"targetRef"` - WorkspaceIdentifier string `json:"workspaceIdentifier"` - ProjectIdentifier *string `json:"projectIdentifier"` - FullPlatformIdentifier *string `json:"fullPlatformIdentifier"` + DisplayName string `json:"displayName"` + TargetRef MeshBuildingBlockV2TargetRef `json:"targetRef"` + WorkspaceIdentifier string `json:"workspaceIdentifier"` + ProjectIdentifier string `json:"projectIdentifier,omitempty"` + FullPlatformIdentifier string `json:"fullPlatformIdentifier,omitempty"` + Inputs []MeshBuildingBlockRunInput `json:"inputs"` + ParentBuildingBlocks []MeshBuildingBlockRunParentBlock `json:"parentBuildingBlocks"` } -type MeshBuildingBlockRunListFilter struct { - BuildingBlockUuid uuid.UUID `json:"buildingBlockUuid,omitzero"` - Sort SortCriteria `json:"sort"` +type MeshBuildingBlockRunParentBlock struct { + BuildingBlockUuid string `json:"buildingBlockUuid"` + DefinitionUuid string `json:"definitionUuid"` } -type MeshBuildingBlockRunLogs struct { - Steps []MeshBuildingBlockRunStepLog `json:"steps"` +type MeshBuildingBlockRunInput struct { + Key string `json:"key"` + // Value holds an int64 for an INTEGER and a []string for a MULTI_SELECT. + Value any `json:"value"` + Type enum.Entry[MeshBuildingBlockIOType] `json:"type"` + IsSensitive bool `json:"isSensitive"` + Env bool `json:"isEnvironment"` } -type MeshBuildingBlockRunStepLog struct { - DisplayName string `json:"displayName"` - Status string `json:"status"` - UserMessage *string `json:"userMessage"` - SystemMessage *string `json:"systemMessage"` +func (i *MeshBuildingBlockRunInput) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + type wire MeshBuildingBlockRunInput + var target struct { + wire + + Value jsontext.Value `json:"value"` + } + if err := json.UnmarshalDecode(dec, &target, dec.Options()); err != nil { + return err + } + *i = MeshBuildingBlockRunInput(target.wire) + switch i.Type { + case MeshBuildingBlockIOTypeInteger: + i.Value = int64(0) + case MeshBuildingBlockIOTypeMultiSelect: + i.Value = []string(nil) + } + if err := json.Unmarshal(target.Value, &i.Value, dec.Options()); err != nil { + return fmt.Errorf("input %s: %w", i.Key, err) + } + return nil +} + +type MeshBuildingBlockRunDefinition struct { + Uuid string `json:"uuid"` + Spec MeshBuildingBlockRunDefinitionSpec `json:"spec"` +} + +type MeshBuildingBlockRunDefinitionSpec struct { + WorkspaceIdentifier string `json:"workspaceIdentifier"` + Version int `json:"version"` + Implementation MeshBuildingBlockRunImplementation `json:"implementation"` +} + +type MeshBuildingBlockRunTrigger struct { + Reason string `json:"reason"` + Author MeshObjectAuthor `json:"author"` } +type MeshBuildingBlockRunFilter struct { + BuildingBlockUuid string `json:"buildingBlockUuid,omitzero"` + Sort SortCriteria `json:"sort"` +} + +var ErrNoRunToClaim = errors.New("no run to claim") + type MeshBuildingBlockRunClient interface { - GetLogs(ctx context.Context, runUuid string) (MeshBuildingBlockRunLogs, error) + MeshObjectClient[MeshBuildingBlockRun, string, MeshBuildingBlockRunFilter] + + Claim(ctx context.Context, runnerUuid string) (MeshBuildingBlockRun, error) + // RegisterSource takes a 409 as success: the source was registered before. + RegisterSource(ctx context.Context, runUuid string, registration MeshBuildingBlockRunSourceRegistration) error + UpdateSourceSteps(ctx context.Context, runUuid, sourceId string, update MeshBuildingBlockRunStatusUpdate) (MeshBuildingBlockRunStatusUpdateResult, error) + // DownloadArtifact writes the artifact of the run that this run applies. + DownloadArtifact(ctx context.Context, runUuid string, w io.Writer) error + UploadArtifact(ctx context.Context, runUuid, path string) error } type meshBuildingBlockRunClient struct { - meshObject internal.MeshObjectClient[MeshBuildingBlockRun] + meshObjectClient[MeshBuildingBlockRun, string, MeshBuildingBlockRunFilter] } -func newBuildingBlockRunClient(ctx context.Context, httpClient internal.HttpClient) meshBuildingBlockRunClient { - return meshBuildingBlockRunClient{ - meshObject: internal.NewMeshObjectClient[MeshBuildingBlockRun](ctx, httpClient, "v1"), +func (c meshBuildingBlockRunClient) Claim(ctx context.Context, runnerUuid string) (MeshBuildingBlockRun, error) { + run, err := c.unmarshalJsonBody[MeshBuildingBlockRun](c.do(ctx, http.MethodPost, c.collection.JoinPath("create"), + http.WithUrlQuery(map[string]string{"forRunnerUuid": runnerUuid}), + http.WithContentType(c.mediaType), + http.WithHeader("X-Meshcloud-Runner-Version", c.client.UserAgentVersion()))) + switch { + case isNotFound(err): + return run, ErrNoRunToClaim + case err == nil && run.Metadata.Uuid == "": + return run, errors.New("the claimed run has no uuid") } + return run, err +} + +// MeshBuildingBlockRunApproval names the plan, because meshStack replaces it when a waiting run is planned again. +type MeshBuildingBlockRunApproval struct { + PredecessorRunUuid string `json:"predecessorRunUuid"` +} + +func (run MeshBuildingBlockRun) meshObjectId() string { + return run.Metadata.Uuid } -func (c meshBuildingBlockRunClient) GetLogs(ctx context.Context, runUuid string) (MeshBuildingBlockRunLogs, error) { - return c.meshObject.GetAtPath[MeshBuildingBlockRunLogs](ctx, runUuid, "logs") +type MeshBuildingBlockRunLogs struct { + Steps []MeshBuildingBlockRunStepLog `json:"steps"` +} + +type MeshBuildingBlockRunStepLog struct { + DisplayName string `json:"displayName"` + Status string `json:"status"` + UserMessage *string `json:"userMessage"` + SystemMessage *string `json:"systemMessage"` } diff --git a/client/building_block_run_action.go b/client/building_block_run_action.go deleted file mode 100644 index 4a707827..00000000 --- a/client/building_block_run_action.go +++ /dev/null @@ -1,79 +0,0 @@ -package client - -import ( - "context" - "fmt" - gohttp "net/http" - "net/url" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) - -// Link is a HAL link. meshStack sends the link of an action only while the caller may take it. -type Link struct { - Href string `json:"href"` -} - -type MeshBuildingBlockRunLinks struct { - Predecessor *Link `json:"predecessor"` - DownloadLogs *Link `json:"downloadLogs"` - Approve *Link `json:"approve"` - Abort *Link `json:"abort"` -} - -type MeshBuildingBlockV2Links struct { - LatestRun *Link `json:"latestRun"` - LatestDryRun *Link `json:"latestDryRun"` - MeshPanel *Link `json:"meshPanel"` -} - -type MeshBuildingBlockRunActionClient struct { - httpClient internal.HttpClient - run internal.MeshObjectApi -} - -func newBuildingBlockRunActionClient(httpClient internal.HttpClient, run meshBuildingBlockRunClient) *MeshBuildingBlockRunActionClient { - return &MeshBuildingBlockRunActionClient{httpClient: httpClient, run: run.meshObject.MeshObjectApi} -} - -func (c *MeshBuildingBlockRunActionClient) ReadRun(ctx context.Context, link Link) (MeshBuildingBlockRun, error) { - return c.doAtLink[MeshBuildingBlockRun](ctx, http.MethodGet, link) -} - -func (c *MeshBuildingBlockRunActionClient) ReadLogs(ctx context.Context, link Link) (MeshBuildingBlockRunLogs, error) { - return c.doAtLink[MeshBuildingBlockRunLogs](ctx, http.MethodGet, link) -} - -// ApproveRun takes the predecessor because meshStack replaces the plan of a run that waits for -// approval when the run is planned again. The predecessor names the plan that is approved. -func (c *MeshBuildingBlockRunActionClient) ApproveRun(ctx context.Context, approve Link, predecessorRunUuid string) error { - _, err := c.doAtLink[[]byte](ctx, http.MethodPost, approve, http.WithJsonPayload(struct { - PredecessorRunUuid string `json:"predecessorRunUuid"` - }{predecessorRunUuid}, c.run.MeshObjectMimeType())) - return err -} - -func (c *MeshBuildingBlockRunActionClient) AbortRun(ctx context.Context, abort Link) error { - _, err := c.doAtLink[[]byte](ctx, http.MethodPost, abort, - http.WithHeaders(gohttp.Header{"Content-Type": {c.run.MeshObjectMimeType()}})) - return err -} - -// doAtLink sends the request only below the endpoint, so that the bearer token never leaves for -// another host that a link names. -func (c *MeshBuildingBlockRunActionClient) doAtLink[R any](ctx context.Context, method string, link Link, options ...http.RequestOption) (R, error) { - var noResult R - href, err := url.Parse(link.Href) - if err != nil { - return noResult, fmt.Errorf("meshStack sent the link %q, which is no URL: %w", link.Href, err) - } - target := c.httpClient.EndpointUrl.ResolveReference(href) - path, isBelowEndpoint := c.httpClient.EndpointUrl.PathTo(target) - if !isBelowEndpoint { - return noResult, fmt.Errorf("meshStack sent the link %s, which is not below the endpoint %s", target.Redacted(), c.httpClient.EndpointUrl) - } - below := c.httpClient.EndpointUrl.JoinPath(path) - below.RawQuery = target.RawQuery - return c.httpClient.DoRequest[R](ctx, method, below, append(options, http.WithAccept(c.run.MeshObjectMimeType()))...) -} diff --git a/client/building_block_run_action_test.go b/client/building_block_run_action_test.go deleted file mode 100644 index 1dfe5c56..00000000 --- a/client/building_block_run_action_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package client - -import ( - "encoding/json/jsontext" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" -) - -func TestBuildingBlockRunActionClientSendsTheTokenOnlyBelowTheEndpoint(t *testing.T) { - const runUuid = "a0000000-0000-4000-8000-000000000001" - server := fakemeshstack.Start(t, fakemeshstack.Options{BuildingBlockRuns: []any{ - jsontext.Value(`{"metadata": {"uuid": "` + runUuid + `"}, "status": "IN_PROGRESS"}`), - }}) - httpClient := newTestHttpClient(server) - actions := newBuildingBlockRunActionClient(httpClient, newBuildingBlockRunClient(t.Context(), httpClient)) - - t.Run("a link below the endpoint is followed", func(t *testing.T) { - run, err := actions.ReadRun(t.Context(), Link{Href: server.URL + "/api/meshobjects/meshbuildingblockruns/" + runUuid}) - - require.NoError(t, err) - assert.Equal(t, runUuid, run.Metadata.Uuid) - server.TakeRequests() - }) - - t.Run("a link to another host is refused before anything is sent", func(t *testing.T) { - for _, href := range []string{"https://other.host/api/meshobjects/meshbuildingblockruns/" + runUuid, "//other.host/x"} { - err := actions.AbortRun(t.Context(), Link{Href: href}) - - require.ErrorContains(t, err, "which is not below the endpoint") - } - assert.Empty(t, server.TakeRequests()) - }) -} diff --git a/client/building_block_run_implementation.go b/client/building_block_run_implementation.go new file mode 100644 index 00000000..2cd549e1 --- /dev/null +++ b/client/building_block_run_implementation.go @@ -0,0 +1,77 @@ +package client + +import ( + "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/types/polymorph" +) + +type MeshBuildingBlockRunImplementationType string + +var ( + MeshBuildingBlockRunImplementationTypes = enum.Enum[MeshBuildingBlockRunImplementationType]{} + MeshBuildingBlockRunImplementationTypeManual = MeshBuildingBlockRunImplementationTypes.Entry("MANUAL") + MeshBuildingBlockRunImplementationTypeTerraform = MeshBuildingBlockRunImplementationTypes.Entry("TERRAFORM") + MeshBuildingBlockRunImplementationTypeGithubWorkflow = MeshBuildingBlockRunImplementationTypes.Entry("GITHUB_WORKFLOW") + MeshBuildingBlockRunImplementationTypeGitlabCiCd = MeshBuildingBlockRunImplementationTypes.Entry("GITLAB_CICD") + MeshBuildingBlockRunImplementationTypeAzureDevops = MeshBuildingBlockRunImplementationTypes.Entry("AZURE_DEVOPS") +) + +type MeshBuildingBlockRunImplementation struct { + polymorph.Polymorph[MeshBuildingBlockRunImplementation, MeshBuildingBlockRunImplementationType] + + Manual *MeshBuildingBlockRunManualImplementation `polymorph:"MANUAL,inline"` + Terraform *MeshBuildingBlockRunTerraformImplementation `polymorph:"TERRAFORM,inline"` + GithubWorkflow *MeshBuildingBlockRunGithubImplementation `polymorph:"GITHUB_WORKFLOW,inline"` + GitlabCiCd *MeshBuildingBlockRunGitlabImplementation `polymorph:"GITLAB_CICD,inline"` + AzureDevops *MeshBuildingBlockRunAzureDevopsImplementation `polymorph:"AZURE_DEVOPS,inline"` +} + +type MeshBuildingBlockRunManualImplementation struct{} + +type MeshBuildingBlockRunTerraformImplementation struct { + TerraformVersion string `json:"terraformVersion"` + RepositoryUrl string `json:"repositoryUrl"` + RepositoryPath *string `json:"repositoryPath,omitzero"` + RefName *string `json:"refName,omitzero"` + SshPrivateKey *string `json:"sshPrivateKey,omitzero"` + KnownHost *MeshBuildingBlockRunKnownHost `json:"knownHost,omitzero"` + UseMeshHttpBackendFallback bool `json:"useMeshHttpBackendFallback"` + PreRunScript *string `json:"preRunScript,omitzero"` + Async bool `json:"async"` +} + +type MeshBuildingBlockRunKnownHost struct { + Host string `json:"host"` + KeyType string `json:"keyType"` + KeyValue string `json:"keyValue"` +} + +type MeshBuildingBlockRunGithubImplementation struct { + GithubBaseUrl string `json:"githubBaseUrl"` + Owner string `json:"owner"` + AppId string `json:"appId"` + AppPem string `json:"appPem"` + Repository string `json:"repository"` + Branch string `json:"branch"` + ApplyWorkflow string `json:"applyWorkflow"` + DestroyWorkflow *string `json:"destroyWorkflow,omitzero"` + Async bool `json:"async"` + OmitRunObjectInput bool `json:"omitRunObjectInput"` +} + +type MeshBuildingBlockRunGitlabImplementation struct { + GitlabBaseUrl string `json:"gitlabBaseUrl"` + ProjectId string `json:"projectId"` + RefName string `json:"refName"` + PipelineTriggerToken string `json:"pipelineTriggerToken"` +} + +type MeshBuildingBlockRunAzureDevopsImplementation struct { + AzureDevOpsBaseUrl string `json:"azureDevOpsBaseUrl"` + Organization string `json:"organization"` + Project string `json:"project"` + PipelineId string `json:"pipelineId"` + PersonalAccessToken string `json:"personalAccessToken"` + Async bool `json:"async"` + RefName *string `json:"refName,omitzero"` +} diff --git a/client/building_block_run_source.go b/client/building_block_run_source.go new file mode 100644 index 00000000..f02179ed --- /dev/null +++ b/client/building_block_run_source.go @@ -0,0 +1,103 @@ +package client + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +type MeshBuildingBlockRunSourceRegistration struct { + Source MeshBuildingBlockRunSource `json:"source"` + Steps []MeshBuildingBlockRunStepRegistration `json:"steps"` +} + +type MeshBuildingBlockRunSource struct { + Id string `json:"id"` + ExternalId *string `json:"externalId,omitzero"` + ExternalUrl *string `json:"externalUrl,omitzero"` +} + +type MeshBuildingBlockRunStepRegistration struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + Status *string `json:"status"` +} + +type MeshBuildingBlockRunStatusUpdate struct { + BlockRunId string `json:"blockRunId,omitzero"` + Source string `json:"source,omitzero"` + Type enum.Entry[MeshBuildingBlockRunImplementationType] `json:"type,omitempty"` + Status *string `json:"status"` + CreatedOn time.Time `json:"createdOn,omitzero"` + Summary *string `json:"summary"` + Steps []MeshBuildingBlockRunStepStatusUpdate `json:"steps"` + ChangesDetected *bool `json:"changesDetected,omitzero"` +} + +type MeshBuildingBlockRunStepStatusUpdate struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + Status *string `json:"status,omitzero"` + UserMessage *string `json:"userMessage,omitzero"` + SystemMessage *string `json:"systemMessage,omitzero"` + Outputs map[string]MeshBuildingBlockRunOutput `json:"outputs,omitempty"` +} + +type MeshBuildingBlockRunOutput struct { + Value any `json:"value"` + Type string `json:"type"` + Sensitive bool `json:"isSensitive"` +} + +type MeshBuildingBlockRunStatusUpdateResult struct { + Abort bool `json:"runAborted"` +} + +const maxArtifactBytes = 128 << 20 + +func (c meshBuildingBlockRunClient) RegisterSource(ctx context.Context, runUuid string, registration MeshBuildingBlockRunSourceRegistration) error { + err := http.IgnoreResponse(c.do(ctx, http.MethodPost, c.collection.JoinPath(runUuid, "status", "source"), + http.WithJsonRequest(registration, c.mediaType), http.Retryable(http.RetryOnTransient))) + if httpErr, ok := errors.AsType[http.Error](err); ok && httpErr.IsConflict() { + return nil + } + return err +} + +func (c meshBuildingBlockRunClient) UpdateSourceSteps(ctx context.Context, runUuid, sourceId string, update MeshBuildingBlockRunStatusUpdate) (MeshBuildingBlockRunStatusUpdateResult, error) { + return http.UnmarshalJsonBody[MeshBuildingBlockRunStatusUpdateResult](c.do(ctx, http.MethodPatch, c.collection.JoinPath(runUuid, "status", "source", sourceId), + http.WithJsonRequest(update, c.mediaType), http.Retryable(http.RetryOnTransient))) +} + +func (c meshBuildingBlockRunClient) DownloadArtifact(ctx context.Context, runUuid string, w io.Writer) error { + body, err := c.do(ctx, http.MethodGet, c.collection.JoinPath(runUuid, "artifact"), http.WithHeader("Accept", "application/octet-stream")) + if err != nil { + return err + } + _, err = io.Copy(&cappedWriter{w, maxArtifactBytes}, body) + return errors.Join(err, body.Close()) +} + +func (c meshBuildingBlockRunClient) UploadArtifact(ctx context.Context, runUuid, path string) error { + return http.IgnoreResponse(c.do(ctx, http.MethodPut, c.collection.JoinPath(runUuid, "artifact"), + http.WithFileRequest(path, ""), http.WithHeader("Accept", "*/*"))) +} + +type cappedWriter struct { + io.Writer + + remaining int64 +} + +func (w *cappedWriter) Write(p []byte) (int, error) { + if int64(len(p)) > w.remaining { + return 0, fmt.Errorf("the artifact exceeds %d bytes", maxArtifactBytes) + } + w.remaining -= int64(len(p)) + return w.Writer.Write(p) +} diff --git a/client/building_block_runner.go b/client/building_block_runner.go index f7a9c5b6..405bb2d6 100644 --- a/client/building_block_runner.go +++ b/client/building_block_runner.go @@ -1,34 +1,19 @@ package client -import ( - "context" - "errors" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) +import "github.com/meshcloud/meshstack-cli/client/types/enum" type MeshBuildingBlockRunnerImplementationType string -// TODO Turn this into enum! -const ( - MeshBuildingBlockRunnerImplementationTypeTerraform MeshBuildingBlockRunnerImplementationType = "TERRAFORM" - MeshBuildingBlockRunnerImplementationTypeGithubWorkflow MeshBuildingBlockRunnerImplementationType = "GITHUB_WORKFLOW" - MeshBuildingBlockRunnerImplementationTypeGitlabPipeline MeshBuildingBlockRunnerImplementationType = "GITLAB_PIPELINE" - MeshBuildingBlockRunnerImplementationTypeAzureDevopsPipeline MeshBuildingBlockRunnerImplementationType = "AZURE_DEVOPS_PIPELINE" - MeshBuildingBlockRunnerImplementationTypeManual MeshBuildingBlockRunnerImplementationType = "MANUAL" - MeshBuildingBlockRunnerImplementationTypeAll MeshBuildingBlockRunnerImplementationType = "ALL" +var ( + MeshBuildingBlockRunnerImplementationTypes = enum.Enum[MeshBuildingBlockRunnerImplementationType]{} + MeshBuildingBlockRunnerImplementationTypeTerraform = MeshBuildingBlockRunnerImplementationTypes.Entry("TERRAFORM") + MeshBuildingBlockRunnerImplementationTypeGithubWorkflow = MeshBuildingBlockRunnerImplementationTypes.Entry("GITHUB_WORKFLOW") + MeshBuildingBlockRunnerImplementationTypeGitlabPipeline = MeshBuildingBlockRunnerImplementationTypes.Entry("GITLAB_PIPELINE") + MeshBuildingBlockRunnerImplementationTypeAzureDevopsPipeline = MeshBuildingBlockRunnerImplementationTypes.Entry("AZURE_DEVOPS_PIPELINE") + MeshBuildingBlockRunnerImplementationTypeManual = MeshBuildingBlockRunnerImplementationTypes.Entry("MANUAL") + MeshBuildingBlockRunnerImplementationTypeAll = MeshBuildingBlockRunnerImplementationTypes.Entry("ALL") ) -var MeshBuildingBlockRunnerImplementationTypes = []string{ - string(MeshBuildingBlockRunnerImplementationTypeTerraform), - string(MeshBuildingBlockRunnerImplementationTypeGithubWorkflow), - string(MeshBuildingBlockRunnerImplementationTypeGitlabPipeline), - string(MeshBuildingBlockRunnerImplementationTypeAzureDevopsPipeline), - string(MeshBuildingBlockRunnerImplementationTypeManual), - string(MeshBuildingBlockRunnerImplementationTypeAll), -} - type MeshBuildingBlockRunner struct { Metadata MeshBuildingBlockRunnerMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshBuildingBlockRunnerSpec `json:"spec" tfsdk:"spec"` @@ -47,12 +32,12 @@ type MeshBuildingBlockRunnerStatus struct { } 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,omitzero" tfsdk:"restriction"` - IsSelfHosted *bool `json:"isSelfHosted,omitzero" tfsdk:"is_self_hosted"` - WorkloadIdentityFederation *MeshRunnerWorkloadIdentityFed `json:"workloadIdentityFederation,omitzero" tfsdk:"workload_identity_federation"` + DisplayName string `json:"displayName" tfsdk:"display_name"` + PublicKey string `json:"publicKey" tfsdk:"public_key"` + ImplementationType enum.Entry[MeshBuildingBlockRunnerImplementationType] `json:"implementationType" tfsdk:"implementation_type"` + 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 { @@ -69,46 +54,12 @@ type MeshRunnerWifProviderConfig struct { 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) - List(ctx context.Context, filter MeshBuildingBlockRunnerFilter) ([]MeshBuildingBlockRunner, error) - Update(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) - Delete(ctx context.Context, uuid string) error -} +type MeshBuildingBlockRunnerClient = MeshObjectClient[MeshBuildingBlockRunner, string, MeshBuildingBlockRunnerFilter] type MeshBuildingBlockRunnerFilter struct { WorkspaceIdentifier *string `json:"workspaceIdentifier"` } -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) List(ctx context.Context, filter MeshBuildingBlockRunnerFilter) ([]MeshBuildingBlockRunner, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(filter)) -} - -func (c meshBuildingBlockRunnerClient) Update(ctx context.Context, runner MeshBuildingBlockRunner) (*MeshBuildingBlockRunner, error) { - if runner.Metadata.Uuid == nil || *runner.Metadata.Uuid == "" { - return nil, errors.New("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) +func (runner MeshBuildingBlockRunner) meshObjectId() string { + return *runner.Metadata.Uuid } diff --git a/client/building_block_v2.go b/client/building_block_v2.go index 01d204dc..6231019b 100644 --- a/client/building_block_v2.go +++ b/client/building_block_v2.go @@ -1,27 +1,18 @@ package client import ( - "context" "encoding/json/jsontext" - "encoding/json/v2" "errors" "fmt" "slices" - "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" + "github.com/meshcloud/meshstack-cli/client/types/hal" + "github.com/meshcloud/meshstack-cli/internal/json" ) -type BuildingBlockLifecycleState string - -var ( - BuildingBlockLifecycleStates = enum.Enum[BuildingBlockLifecycleState]{} - BuildingBlockLifecycleStateActive = BuildingBlockLifecycleStates.Entry("ACTIVE") - BuildingBlockLifecycleStateMarkedForDeletion = BuildingBlockLifecycleStates.Entry("MARKED_FOR_DELETION") - BuildingBlockLifecycleStateDeleted = BuildingBlockLifecycleStates.Entry("DELETED") -) +const buildingBlockV2ApiVersion = "v2-preview" type BuildingBlockHealthStatus string @@ -49,16 +40,14 @@ var ( BuildingBlockActionNoAction = BuildingBlockActions.Entry("NO_ACTION") ) +type MeshBuildingBlockV2Metadata = MeshObjectByUuidMetadata + +//nolint:recvcheck // a nil block is one that is gone, and meshObjectId reads one that is there type MeshBuildingBlockV2 struct { Metadata MeshBuildingBlockV2Metadata `json:"metadata" tfsdk:"metadata"` Spec MeshBuildingBlockV2Spec `json:"spec" tfsdk:"spec"` Status *MeshBuildingBlockV2Status `json:"status" tfsdk:"status"` - Links MeshBuildingBlockV2Links `json:"_links,omitzero" tfsdk:"-"` -} - -type MeshBuildingBlockV2Metadata struct { - Uuid *string `json:"uuid" tfsdk:"uuid"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` + Links hal.Links `json:"_links,omitzero" tfsdk:"-"` } type MeshBuildingBlockV2Spec struct { @@ -72,9 +61,11 @@ type MeshBuildingBlockV2Spec struct { } type MeshBuildingBlockInput struct { - Value types.SecretOrAny `json:"value" tfsdk:"value"` - ValueType *enum.Entry[MeshBuildingBlockIOType] `json:"valueType,omitzero" tfsdk:"-"` - AssignmentType enum.Entry[MeshBuildingBlockInputAssignmentType] `json:"assignmentType,omitempty" tfsdk:"-"` + Value types.SecretOrAny `json:"value" tfsdk:"value"` + ValueType *enum.Entry[MeshBuildingBlockIOType] `json:"valueType,omitzero" tfsdk:"-"` + AssignmentType enum.Entry[MeshBuildingBlockInputAssignmentType] `json:"assignmentType,omitempty" tfsdk:"-"` + UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"-"` + IsOptional bool `json:"isOptional" tfsdk:"-"` // IsSensitive decides the case of Value: [types.Secret] when true, [types.Any] otherwise. // [types.Variant] decodes into X first, so UnmarshalJSONFrom moves a non-sensitive value that also @@ -120,12 +111,8 @@ type MeshBuildingBlockV2DefinitionVersionRef struct { type MeshBuildingBlockV2TargetRef struct { Kind string `json:"kind" tfsdk:"kind"` - Uuid *string `json:"uuid" tfsdk:"uuid"` - Name *string `json:"name" tfsdk:"name"` -} - -type MeshBuildingBlockV2Lifecycle struct { - State enum.Entry[BuildingBlockLifecycleState] `json:"state" tfsdk:"state"` + Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` + Name *string `json:"name,omitzero" tfsdk:"name"` } type MeshBuildingBlockV2Status struct { @@ -133,7 +120,7 @@ type MeshBuildingBlockV2Status struct { Action enum.Entry[BuildingBlockAction] `json:"action" tfsdk:"action"` Outputs map[string]MeshBuildingBlockOutput `json:"outputs" tfsdk:"outputs"` ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` - Lifecycle MeshBuildingBlockV2Lifecycle `json:"lifecycle" tfsdk:"-"` + Lifecycle MeshLifecycle `json:"lifecycle" tfsdk:"-"` // 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). @@ -171,11 +158,11 @@ type MeshBuildingBlockOutput struct { AssignmentType enum.Entry[MeshBuildingBlockDefinitionOutputAssignmentType] `json:"assignmentType" tfsdk:"assignment_type"` } -// MeshBuildingBlockV2ListFilter never matches a soft-deleted building block: the endpoint lists +// MeshBuildingBlockV2Filter never matches a soft-deleted building block: the endpoint lists // only active ones. The json tags are the query parameter names and must match the @RequestParam // names of fetchBuildingBlocksV2 in the meshStack backend, which ignores an unknown parameter, so // a typo turns the filter off without an error. -type MeshBuildingBlockV2ListFilter struct { +type MeshBuildingBlockV2Filter struct { WorkspaceIdentifier *string `json:"workspaceIdentifier"` ProjectIdentifier *string `json:"projectIdentifier"` PlatformIdentifier *string `json:"platformIdentifier"` @@ -198,54 +185,10 @@ type MeshBuildingBlockV2ListFilter struct { Sort SortCriteria `json:"sort"` } -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 - TriggerRun(ctx context.Context, uuid string) (*MeshBuildingBlockV2, 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")} -} - -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) { - return func(ctx context.Context) (*MeshBuildingBlockV2, error) { - return c.meshObject.Get(ctx, uuid) - } -} - -func (c meshBuildingBlockV2Client) List(ctx context.Context, filter MeshBuildingBlockV2ListFilter) ([]MeshBuildingBlockV2, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(filter)) -} - -func (c meshBuildingBlockV2Client) Create(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { - return c.meshObject.Post(ctx, bb) -} +type MeshBuildingBlockV2Client = MeshObjectClient[MeshBuildingBlockV2, string, MeshBuildingBlockV2Filter] -func (c meshBuildingBlockV2Client) Update(ctx context.Context, bb *MeshBuildingBlockV2) (*MeshBuildingBlockV2, error) { - if bb.Metadata.Uuid == nil { - 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 { - if purge { - return c.meshObject.DeleteAtPath(ctx, uuid, "purge") - } - return c.meshObject.Delete(ctx, uuid) +type MeshBuildingBlockV2RunTrigger struct { + DryRun bool `json:"dryRun"` } var ( @@ -314,7 +257,7 @@ func (bb *MeshBuildingBlockV2) DeletionSuccessful(before MeshBuildingBlockV2Stat case bb == nil: // A 404: the block is gone, for example together with its definition. done = true - case bb.Status != nil && bb.Status.Lifecycle.State == BuildingBlockLifecycleStateDeleted: + case bb.Status != nil && bb.Status.Lifecycle.State == MeshLifecycleStateDeleted: // A soft-deleted block does not return 404: the backend keeps returning it with DELETED. // MARKED_FOR_DELETION means the deletion still runs, so polling goes on. done = true @@ -330,12 +273,8 @@ func (bb *MeshBuildingBlockV2) DeletionSuccessful(before MeshBuildingBlockV2Stat return } -// MeshPanelLink is "" for a block read from a meshStack that sends no meshPanel link. -func (bb *MeshBuildingBlockV2) MeshPanelLink() string { - if bb == nil || bb.Links.MeshPanel == nil { - return "" - } - return bb.Links.MeshPanel.Href +func (bb MeshBuildingBlockV2) meshObjectId() string { + return bb.Metadata.meshObjectId() } func (bb *MeshBuildingBlockV2) uuidOrUnknown() string { @@ -344,14 +283,3 @@ func (bb *MeshBuildingBlockV2) uuidOrUnknown() string { } return "" } - -// TriggerRun answers with the building block as meshStack left it on accepting the run: Action, -// LatestRunUuid, RunStartFailure and RunStartFailedOn still report the request before, because -// meshStack starts the run, or records why it could not, only after it answered. -func (c meshBuildingBlockV2Client) TriggerRun(ctx context.Context, bbUuid string) (*MeshBuildingBlockV2, error) { - // dryRun is not optional to the endpoint once a body is sent, so it goes out as false rather - // than being omitted. - return c.meshObject.PostAtPath[*MeshBuildingBlockV2](ctx, struct { - DryRun bool `json:"dryRun"` - }{}, bbUuid, "trigger-run") -} diff --git a/client/building_block_v2_test.go b/client/building_block_v2_test.go index 96663228..49eba793 100644 --- a/client/building_block_v2_test.go +++ b/client/building_block_v2_test.go @@ -108,65 +108,3 @@ func TestMeshBuildingBlockV2_RunEndedSince(t *testing.T) { require.Error(t, err) }) } - -func TestMeshBuildingBlockV2_DeletionSuccessful(t *testing.T) { - before := MeshBuildingBlockV2Status{Action: BuildingBlockActionReviewErrors, LatestRunUuid: new("apply-run")} - deleteRun := func(action enum.Entry[BuildingBlockAction]) *MeshBuildingBlockV2 { - return &MeshBuildingBlockV2{ - Metadata: MeshBuildingBlockV2Metadata{Uuid: new("test-uuid")}, - Status: &MeshBuildingBlockV2Status{Action: action, LatestRunUuid: new("delete-run")}, - } - } - tests := []struct { - name string - bb *MeshBuildingBlockV2 - wantDone bool - wantErr string - }{ - {name: "a block that is gone (404, hard deletion or purge) is deleted", bb: nil, wantDone: true}, - { - name: "a soft-deleted block is deleted", - bb: &MeshBuildingBlockV2{Status: &MeshBuildingBlockV2Status{Lifecycle: MeshBuildingBlockV2Lifecycle{State: BuildingBlockLifecycleStateDeleted}}}, - wantDone: true, - }, - { - name: "REVIEW_ERRORS of the run before the deletion goes on", - bb: &MeshBuildingBlockV2{Status: &MeshBuildingBlockV2Status{Action: BuildingBlockActionReviewErrors, LatestRunUuid: new("apply-run")}}, - }, - {name: "a delete run in progress goes on", bb: deleteRun(BuildingBlockActionInProgress)}, - {name: "a failed delete run fails", bb: deleteRun(BuildingBlockActionReviewErrors), wantErr: "the delete run of building block test-uuid ended with action REVIEW_ERRORS"}, - {name: "an aborted delete run fails", bb: deleteRun(BuildingBlockActionReviewAbortedRun), wantErr: "ended with action REVIEW_ABORTED_RUN"}, - { - name: "a delete run that could not start fails", - bb: &MeshBuildingBlockV2{Status: &MeshBuildingBlockV2Status{ - Action: BuildingBlockActionReviewErrors, LatestRunUuid: new("apply-run"), RunStartFailedOn: "2026-10-28T12:00:00Z", - }}, - wantErr: "ended with action REVIEW_ERRORS", - }, - {name: "a delete run that waits for approval fails at once", bb: deleteRun(BuildingBlockActionApproveRun), wantErr: "waits with action APPROVE_RUN"}, - {name: "a delete run that waits for a dependency fails at once", bb: deleteRun(BuildingBlockActionWaitingForDependency), wantErr: "waits with action WAITING_FOR_DEPENDENCY"}, - { - name: "a failed delete run of a force-purged block goes on, as the purge still reaches DELETED", - bb: &MeshBuildingBlockV2{Status: &MeshBuildingBlockV2Status{ - Action: BuildingBlockActionReviewErrors, LatestRunUuid: new("delete-run"), ForcePurge: true, - }}, - }, - { - name: "a failed delete run of a block without a uuid names it unknown", - bb: &MeshBuildingBlockV2{Status: &MeshBuildingBlockV2Status{Action: BuildingBlockActionReviewErrors, LatestRunUuid: new("delete-run")}}, - wantErr: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - done, err := tt.bb.DeletionSuccessful(before) - assert.Equal(t, tt.wantDone, done) - if tt.wantErr == "" { - assert.NoError(t, err) - } else { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - } - }) - } -} diff --git a/client/client.go b/client/client.go index 413ac16c..b779a470 100644 --- a/client/client.go +++ b/client/client.go @@ -3,30 +3,25 @@ package client import ( "context" - "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/client/types/version" "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 type HttpError = http.Error -type Authorization = http.Authorization - type Client struct { ApiKey MeshApiKeyClient BuildingBlockV2 MeshBuildingBlockV2Client BuildingBlockRun MeshBuildingBlockRunClient - BuildingBlockRunAction *MeshBuildingBlockRunActionClient BuildingBlockDefinition MeshBuildingBlockDefinitionClient BuildingBlockDefinitionVersion MeshBuildingBlockDefinitionVersionClient BuildingBlockRunner MeshBuildingBlockRunnerClient + EventLog MeshEventLogClient Integration MeshIntegrationClient LandingZone MeshLandingZoneClient Location MeshLocationClient @@ -43,54 +38,41 @@ type Client struct { Workspace MeshWorkspaceClient WorkspaceGroupBinding MeshWorkspaceGroupBindingClient WorkspaceUserBinding MeshWorkspaceUserBindingClient - Raw *RawClient // Endpoint is read by the Terraform provider's meshstack_instance data source. Endpoint xurl.URL + // HttpClient sends the bearer token to any host, so join its path onto Endpoint. + HttpClient http.AuthorizedClient } -func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Authorization) Client { - authorizedClient := internal.HttpClient{ - AuthorizedClient: client.WithAuthorization(auth), - EndpointUrl: endpoint, - } - buildingBlockV2 := newBuildingBlockV2Client(ctx, authorizedClient) - buildingBlockDefinition := newBuildingBlockDefinitionClient(ctx, authorizedClient) - buildingBlockDefinitionVersion := newBuildingBlockDefinitionVersionClient(ctx, authorizedClient) - buildingBlockRun := newBuildingBlockRunClient(ctx, authorizedClient) - workspace := newWorkspaceClient(ctx, authorizedClient) +func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth http.Authorization) Client { + c := client.WithAuthorization(auth) return Client{ - ApiKey: newApiKeyClient(ctx, authorizedClient), - BuildingBlockV2: buildingBlockV2, - BuildingBlockRun: buildingBlockRun, - BuildingBlockRunAction: newBuildingBlockRunActionClient(authorizedClient, buildingBlockRun), - BuildingBlockDefinition: buildingBlockDefinition, - BuildingBlockDefinitionVersion: buildingBlockDefinitionVersion, - BuildingBlockRunner: newBuildingBlockRunnerClient(ctx, authorizedClient), - Integration: newIntegrationClient(ctx, authorizedClient), - LandingZone: newLandingZoneClient(ctx, authorizedClient), - Location: newLocationClient(ctx, authorizedClient), + ApiKey: newMeshObjectClient[MeshApiKey, string, NoFilter](ctx, c, endpoint, "v1-preview"), + BuildingBlockV2: newMeshObjectClient[MeshBuildingBlockV2, string, MeshBuildingBlockV2Filter](ctx, c, endpoint, buildingBlockV2ApiVersion), + BuildingBlockRun: meshBuildingBlockRunClient{newMeshObjectClient[MeshBuildingBlockRun, string, MeshBuildingBlockRunFilter](ctx, c, endpoint, buildingBlockRunApiVersion)}, + BuildingBlockDefinition: newMeshObjectClient[MeshBuildingBlockDefinition, string, MeshBuildingBlockDefinitionFilter](ctx, c, endpoint, "v1-preview"), + BuildingBlockDefinitionVersion: newMeshObjectClient[MeshBuildingBlockDefinitionVersion, string, MeshBuildingBlockDefinitionVersionFilter](ctx, c, endpoint, "v1-preview"), + BuildingBlockRunner: newMeshObjectClient[MeshBuildingBlockRunner, string, MeshBuildingBlockRunnerFilter](ctx, c, endpoint, "v1-preview"), + EventLog: newMeshObjectClient[MeshEventLog, string, MeshEventLogFilter](ctx, c, endpoint, "v1"), + Integration: newMeshObjectClient[MeshIntegration, string, NoFilter](ctx, c, endpoint, "v1"), + LandingZone: newMeshObjectClient[MeshLandingZone, MeshObjectName, MeshLandingZoneFilter](ctx, c, endpoint, "v1"), + Location: newMeshObjectClient[MeshLocation, MeshObjectName, NoFilter](ctx, c, endpoint, "v1"), 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: workspace, - WorkspaceGroupBinding: newWorkspaceGroupBindingClient(ctx, authorizedClient), - WorkspaceUserBinding: newWorkspaceUserBindingClient(ctx, authorizedClient), - Raw: newRawClient(authorizedClient). - with(workspace.meshObject). - with(buildingBlockV2.meshObject). - with(buildingBlockRun.meshObject). - with(buildingBlockDefinition.meshObject). - withOwnOrder(buildingBlockDefinitionVersion.meshObject). - with(newEventLogMeshObject(ctx, authorizedClient)), + PaymentMethod: newMeshObjectClient[MeshPaymentMethod, MeshObjectName, NoFilter](ctx, c, endpoint, "v2"), + Platform: newMeshObjectClient[MeshPlatform, string, MeshPlatformFilter](ctx, c, endpoint, "v2"), + PlatformType: newMeshObjectClient[MeshPlatformType, MeshObjectName, MeshPlatformTypeFilter](ctx, c, endpoint, "v1"), + Project: newMeshObjectClient[MeshProject, MeshProjectId, MeshProjectFilter](ctx, c, endpoint, "v2"), + ProjectGroupBinding: newMeshObjectClient[MeshProjectGroupBinding, MeshObjectName, MeshProjectBindingFilter](ctx, c, endpoint, "v3"), + ProjectUserBinding: newMeshObjectClient[MeshProjectUserBinding, MeshObjectName, MeshProjectBindingFilter](ctx, c, endpoint, "v3"), + ServiceInstance: newMeshObjectClient[MeshServiceInstance, MeshObjectName, MeshServiceInstanceFilter](ctx, c, endpoint, "v2"), + TagDefinition: newMeshObjectClient[MeshTagDefinition, MeshObjectName, NoFilter](ctx, c, endpoint, "v1"), + Tenant: newMeshObjectClient[MeshTenant, string, MeshTenantFilter](ctx, c, endpoint, "v4"), + Workspace: newMeshObjectClient[MeshWorkspace, MeshObjectName, MeshWorkspaceFilter](ctx, c, endpoint, "v2"), + WorkspaceGroupBinding: newMeshObjectClient[MeshWorkspaceGroupBinding, MeshObjectName, MeshWorkspaceBindingFilter](ctx, c, endpoint, "v2"), + WorkspaceUserBinding: newMeshObjectClient[MeshWorkspaceUserBinding, MeshObjectName, MeshWorkspaceBindingFilter](ctx, c, endpoint, "v2"), - Endpoint: endpoint, + Endpoint: endpoint, + HttpClient: c, } } diff --git a/client/client_kind_test.go b/client/client_kind_test.go index 7d43cf6f..81b05ba5 100644 --- a/client/client_kind_test.go +++ b/client/client_kind_test.go @@ -4,29 +4,27 @@ import ( "testing" "github.com/stretchr/testify/assert" - - "github.com/meshcloud/meshstack-cli/client/internal" ) func TestKind(t *testing.T) { - assert.Equal(t, internal.InferKind[MeshApiKey](), MeshObjectKind.ApiKey) - 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[MeshEventLog](), MeshObjectKind.EventLog) - 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[MeshWorkspace](), MeshObjectKind.Workspace) - assert.Equal(t, internal.InferKind[MeshWorkspaceGroupBinding](), MeshObjectKind.WorkspaceGroupBinding) - assert.Equal(t, internal.InferKind[MeshWorkspaceUserBinding](), MeshObjectKind.WorkspaceUserBinding) + assert.Equal(t, inferKind[MeshApiKey](), MeshObjectKind.ApiKey) + assert.Equal(t, inferKind[MeshBuildingBlockV2](), MeshObjectKind.BuildingBlock) + assert.Equal(t, inferKind[MeshBuildingBlockDefinition](), MeshObjectKind.BuildingBlockDefinition) + assert.Equal(t, inferKind[MeshBuildingBlockDefinitionVersion](), MeshObjectKind.BuildingBlockDefinitionVersion) + assert.Equal(t, inferKind[MeshEventLog](), MeshObjectKind.EventLog) + assert.Equal(t, inferKind[MeshIntegration](), MeshObjectKind.Integration) + assert.Equal(t, inferKind[MeshLandingZone](), MeshObjectKind.LandingZone) + assert.Equal(t, inferKind[MeshLocation](), MeshObjectKind.Location) + assert.Equal(t, inferKind[MeshPaymentMethod](), MeshObjectKind.PaymentMethod) + assert.Equal(t, inferKind[MeshPlatform](), MeshObjectKind.Platform) + assert.Equal(t, inferKind[MeshPlatformType](), MeshObjectKind.PlatformType) + assert.Equal(t, inferKind[MeshProject](), MeshObjectKind.Project) + assert.Equal(t, inferKind[MeshProjectGroupBinding](), MeshObjectKind.ProjectGroupBinding) + assert.Equal(t, inferKind[MeshProjectUserBinding](), MeshObjectKind.ProjectUserBinding) + assert.Equal(t, inferKind[MeshServiceInstance](), MeshObjectKind.ServiceInstance) + assert.Equal(t, inferKind[MeshTagDefinition](), MeshObjectKind.TagDefinition) + assert.Equal(t, inferKind[MeshTenant](), MeshObjectKind.Tenant) + assert.Equal(t, inferKind[MeshWorkspace](), MeshObjectKind.Workspace) + assert.Equal(t, inferKind[MeshWorkspaceGroupBinding](), MeshObjectKind.WorkspaceGroupBinding) + assert.Equal(t, inferKind[MeshWorkspaceUserBinding](), MeshObjectKind.WorkspaceUserBinding) } diff --git a/client/event_log.go b/client/event_log.go index 038635e2..4f7b9147 100644 --- a/client/event_log.go +++ b/client/event_log.go @@ -1,21 +1,21 @@ package client import ( - "context" "encoding/json/jsontext" "time" - "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types/hal" ) type MeshEventLog struct { Metadata MeshEventLogMetadata `json:"metadata"` Spec MeshEventLogSpec `json:"spec"` Status MeshEventLogStatus `json:"status"` + Links hal.Links `json:"_links,omitzero"` } type MeshEventLogMetadata struct { - Uuid string `json:"uuid"` + Uuid *string `json:"uuid"` } type MeshEventLogSpec struct { @@ -42,22 +42,11 @@ type MeshEventLogStatus struct { } type MeshEventLogCreated struct { - Timestamp time.Time `json:"timestamp"` - Author *MeshEventLogAuthor `json:"author"` + Timestamp time.Time `json:"timestamp"` + Author *MeshObjectAuthor `json:"author"` } -type MeshEventLogAuthor struct { - // Type is one of ApiKey, ApiUser, System and User, and decides what Identifier is: the uuid of - // the meshApiKey, the name of the API user, "system", or the uuid of the meshUser. - Type string `json:"type"` - Identifier string `json:"identifier"` - DisplayName *string `json:"displayName"` - Username *string `json:"username"` - Email *string `json:"email"` - Euid *string `json:"euid"` -} - -type MeshEventLogListFilter struct { +type MeshEventLogFilter struct { From time.Time `json:"from,omitzero"` Until time.Time `json:"until,omitzero"` Title string `json:"title"` @@ -74,6 +63,8 @@ type MeshEventLogListFilter struct { Sort SortCriteria `json:"sort"` } -func newEventLogMeshObject(ctx context.Context, httpClient internal.HttpClient) internal.MeshObjectClient[MeshEventLog] { - return internal.NewMeshObjectClient[MeshEventLog](ctx, httpClient, "v1") +type MeshEventLogClient = MeshObjectClient[MeshEventLog, string, MeshEventLogFilter] + +func (log MeshEventLog) meshObjectId() string { + return *log.Metadata.Uuid } diff --git a/client/http/auth.go b/client/http/auth.go new file mode 100644 index 00000000..7afe4988 --- /dev/null +++ b/client/http/auth.go @@ -0,0 +1,111 @@ +package http + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + gohttp "net/http" + "net/url" + "strings" +) + +// 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) + + fmt.Stringer +} + +type AuthorizedClient struct { + client Client + auth Authorization +} + +func (c Client) WithAuthorization(auth Authorization) AuthorizedClient { + return AuthorizedClient{c, auth} +} + +func (c AuthorizedClient) DoRequest(ctx context.Context, method string, url *url.URL, options ...RequestOption) (io.ReadCloser, error) { + token, err := c.auth.GetBearerToken(ctx) + if err != nil { + return nil, err + } + body, err := c.client.DoRequest(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.auth.RefreshBearerToken(ctx, token) + switch { + case refreshErr != nil: + err = errors.Join(err, fmt.Errorf("cannot renew the rejected token: %w", refreshErr)) + case refreshedToken != token: + slog.DebugContext(ctx, "retrying after 401 with a freshly minted token", "url", url.String(), "method", method) + body, err = c.client.DoRequest(ctx, method, url, append(options, refreshedToken.asRequestOption())...) + } + } + if httpErr, ok := errors.AsType[Error](err); ok && httpErr.IsForbidden() { + err = fmt.Errorf("auth %s: %w", c.auth, err) + } + return body, err +} + +func (c AuthorizedClient) String() string { + return "authorized client " + c.client.userAgent.String() +} + +func (c AuthorizedClient) UserAgentVersion() string { + return c.client.userAgent.Version +} + +func (c AuthorizedClient) ProxyTransport() gohttp.RoundTripper { + return proxyTransport{c} +} + +type proxyTransport struct { + AuthorizedClient +} + +func (t proxyTransport) RoundTrip(in *gohttp.Request) (*gohttp.Response, error) { + out := in.Clone(in.Context()) + out.Header.Set("User-Agent", strings.TrimSpace(in.Header.Get("User-Agent")+" "+t.client.userAgent.String())) + if in.Header.Get("Authorization") != "" { + token, err := t.auth.GetBearerToken(in.Context()) + if err != nil { + return nil, err + } + out.Header.Set("Authorization", "Bearer "+string(token)) + } + return goClient.Transport.RoundTrip(out) +} + +// 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) +} + +// String shows only the start of a long token, so that formatting one never leaks it. +func (token BearerToken) String() string { + if len(token) < 32 { + return "BearerToken [REDACTED]" + } + return "BearerToken " + string(token[:6]) + "...[REDACTED]" +} + +func (token BearerToken) asRequestOption() RequestOption { + return WithHeader("Authorization", "Bearer "+string(token)) +} diff --git a/client/http/http_client.go b/client/http/http_client.go new file mode 100644 index 00000000..a32c9fa5 --- /dev/null +++ b/client/http/http_client.go @@ -0,0 +1,140 @@ +package http + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + gohttp "net/http" + "net/url" + "slices" + + "github.com/meshcloud/meshstack-cli/internal/json" +) + +type Client struct { + userAgent UserAgent +} + +var goClient = newGoClient() + +func newGoClient() *gohttp.Client { + transport := gohttp.DefaultTransport.(*gohttp.Transport).Clone() //nolint:forcetypeassert // net/http declares it a *Transport + transport.ResponseHeaderTimeout = stallTimeout + return &gohttp.Client{ + Transport: retryRoundTripper{transport}, + CheckRedirect: func(req *gohttp.Request, _ []*gohttp.Request) error { + if requestOptionsFrom(req).noRedirects { + return gohttp.ErrUseLastResponse + } + return nil + }, + } +} + +func NewClient(userAgent UserAgent) Client { + if err := userAgent.validate(); err != nil { + panic(err) + } + return Client{userAgent} +} + +func (c Client) String() string { + return "unauthorized client " + c.userAgent.String() +} + +// UnmarshalJsonBody closes the body. +func UnmarshalJsonBody[T any](body io.ReadCloser, err error, unmarshalers ...*json.Unmarshalers) (result T, _ error) { + if _, ignored := any(result).(struct{}); ignored { + return result, IgnoreResponse(body, err) + } + if err != nil { + return result, err + } + err = json.UnmarshalRead(body, &result, json.WithUnmarshalers(unmarshalers...)) + if err != nil { + err = fmt.Errorf("parsing response body as JSON failed: %w", err) + } + return result, errors.Join(err, body.Close()) +} + +func ReadAllBody(body io.ReadCloser, err error) ([]byte, error) { + if err != nil { + return nil, err + } + data, err := io.ReadAll(body) + return data, errors.Join(err, body.Close()) +} + +func IgnoreResponse(body io.ReadCloser, err error) error { + if err != nil { + return err + } + if _, err = io.CopyN(io.Discard, body, maxDrainBytes); errors.Is(err, io.EOF) { + err = nil + } + return errors.Join(err, body.Close()) +} + +// DoRequest leaves the body to the caller. 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(ctx context.Context, method string, url *url.URL, options ...RequestOption) (io.ReadCloser, error) { + opts := newRequestOptions(slices.Insert(options, 0, WithHeader("User-Agent", c.userAgent.String()))) + ctx, cancel := context.WithCancelCause(ctx) + req, err := opts.newRequest(ctx, method, url) + if err != nil { + cancel(nil) + return nil, err + } + res, err := goClient.Do(req) //nolint:bodyclose // the returned body closes it, or the error path below + if err != nil { + cancel(nil) + return nil, err + } + slog.DebugContext(ctx, "response", "status", res.StatusCode, "headers", loggedHeaders(res.Header)) + if opts.responseHeader != nil { + *opts.responseHeader = res.Header + } + body := newLoggedBody(ctx, "response body", newStallGuardedBody(res.Body, cancel)) + if isSuccess(res.StatusCode) { + return body, nil + } + responseBody, err := io.ReadAll(body) + _ = body.Close() + if err != nil { + return nil, fmt.Errorf("cannot read response body, status code %d: %w", res.StatusCode, err) + } + return nil, Error{ + Method: res.Request.Method, + Url: res.Request.URL.Redacted(), + StatusCode: res.StatusCode, + ResponseBody: responseBody, + } +} + +// newRequest opens the body after every modifier, so that a modifier's error leaves none open. +func (opts requestOptions) newRequest(ctx context.Context, method string, url *url.URL) (*gohttp.Request, error) { + req, err := gohttp.NewRequestWithContext(opts.requestContext(ctx), method, url.String(), nil) + 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 + } + } + if opts.setBody != nil { + if err := opts.setBody(req); err != nil { + return nil, fmt.Errorf("cannot build request body: %w", err) + } + } + req.Body = newLoggedBody(ctx, "request body", req.Body) + slog.DebugContext(ctx, "request", "url", req.URL.String(), "method", req.Method, "headers", loggedHeaders(req.Header)) + return req, nil +} + +func isSuccess(statusCode int) bool { + return statusCode >= 200 && statusCode <= 299 +} diff --git a/client/http/http_client_test.go b/client/http/http_client_test.go new file mode 100644 index 00000000..4431902b --- /dev/null +++ b/client/http/http_client_test.go @@ -0,0 +1,406 @@ +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + gohttp "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/Shopify/toxiproxy/v2" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/json" + "github.com/meshcloud/meshstack-cli/internal/logs" +) + +var testUserAgent = UserAgent{GitHubRepo: "meshcloud/meshstack-cli", Version: "test"} + +func TestMain(m *testing.M) { + stallTimeout = 5 * time.Second + goClient = newGoClient() + m.Run() +} + +func TestDoRequest(t *testing.T) { + var answer struct { + status int + body string + } + var received []string + server := newTestServer(t, func(w gohttp.ResponseWriter, r *gohttp.Request) { + body, _ := io.ReadAll(r.Body) + received = append(received, r.Method+" "+r.Header.Get("User-Agent")+" "+string(body)) + w.Header()["Date"] = nil + w.WriteHeader(answer.status) + _, _ = io.WriteString(w, answer.body) + }) + client := NewClient(testUserAgent) + + t.Run("sends the user agent and parses the answer", func(t *testing.T) { + answer.status, answer.body = gohttp.StatusOK, `{"name":"answered"}` + + got, err := UnmarshalJsonBody[map[string]string](client.DoRequest(t.Context(), MethodPost, server.Url, + WithJsonRequest(map[string]string{"name": "sent"}, ""))) + + require.NoError(t, err) + assert.Equal(t, map[string]string{"name": "answered"}, got) + assert.Equal(t, []string{`POST meshstack-cli/test {"name":"sent"}`}, received) + }) + + t.Run("a non-2xx is an Error with the whole body", func(t *testing.T) { + answer.status, answer.body = gohttp.StatusNotFound, strings.Repeat("x", 2*loggedBodyLimit) + + err := IgnoreResponse(client.DoRequest(t.Context(), MethodGet, server.Url)) + + httpErr, ok := errors.AsType[Error](err) + require.True(t, ok, "%v", err) + assert.Equal(t, Error{Method: MethodGet, Url: server.Url.String(), StatusCode: gohttp.StatusNotFound, ResponseBody: []byte(answer.body)}, httpErr) + }) + + t.Run("WithoutRedirects answers with the 3xx", func(t *testing.T) { + answer.status, answer.body = gohttp.StatusFound, "" + + err := IgnoreResponse(client.DoRequest(t.Context(), MethodGet, server.Url, WithoutRedirects())) + + httpErr, ok := errors.AsType[Error](err) + require.True(t, ok, "%v", err) + assert.Equal(t, gohttp.StatusFound, httpErr.StatusCode) + }) +} + +func TestRetry(t *testing.T) { + var attempts atomic.Int32 + var answers []int + var bodies []string + server := newTestServer(t, func(w gohttp.ResponseWriter, r *gohttp.Request) { + body, _ := io.ReadAll(r.Body) + bodies = append(bodies, string(body)) + status := answers[min(int(attempts.Add(1))-1, len(answers)-1)] + w.WriteHeader(status) + }) + reset := func(statuses ...int) *testBackoff { + attempts.Store(0) + answers, bodies = statuses, nil + return &testBackoff{} + } + client := NewClient(testUserAgent) + + t.Run("a GET outlasts a busy or failing gateway", func(t *testing.T) { + for _, status := range []int{429, 502, 503, 504} { + captured := logs.Capture(t) + backoff := reset(status, gohttp.StatusOK) + + err := IgnoreResponse(client.DoRequest(t.Context(), MethodGet, server.Url, backoff.option())) + + require.NoError(t, err, status) + assert.Equal(t, int32(2), attempts.Load(), status) + assert.Equal(t, []string{ + fmt.Sprintf(`level=WARN msg="retrying request" status=%d method=GET path=/ attempt=1/2 waitTime=0s`, status), + }, captured.Lines(slog.LevelWarn)) + } + }) + + t.Run("gives up after the last retry with the last answer", func(t *testing.T) { + backoff := reset(gohttp.StatusBadGateway) + + err := IgnoreResponse(client.DoRequest(t.Context(), MethodGet, server.Url, backoff.option())) + + httpErr, ok := errors.AsType[Error](err) + require.True(t, ok, "%v", err) + assert.Equal(t, gohttp.StatusBadGateway, httpErr.StatusCode) + assert.Equal(t, int32(3), attempts.Load()) + }) + + t.Run("another method than GET is replayed after a gateway error only when Retryable, body included", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "plan") + require.NoError(t, os.WriteFile(path, []byte("a plan"), 0o600)) + for name, payload := range map[string]RequestOption{ + `{"key":"value"}`: WithJsonRequest(map[string]string{"key": "value"}, ""), + "a plan": WithFileRequest(path, ""), + } { + backoff := reset(gohttp.StatusBadGateway, gohttp.StatusNoContent) + err := IgnoreResponse(client.DoRequest(t.Context(), MethodPut, server.Url, payload, backoff.option())) + require.Error(t, err) + assert.Equal(t, []string{name}, bodies, "the server may have acted on a PUT it answered with 502, so replaying it needs the caller's word") + + backoff = reset(gohttp.StatusBadGateway, gohttp.StatusNoContent) + err = IgnoreResponse(client.DoRequest(t.Context(), MethodPut, server.Url, payload, backoff.option(), Retryable(RetryOnTransient))) + require.NoError(t, err) + assert.Equal(t, []string{name, name}, bodies) + } + }) + + t.Run("another method than GET is replayed by default after a status that says the server did not act", func(t *testing.T) { + for _, status := range []int{gohttp.StatusServiceUnavailable, gohttp.StatusLocked} { + backoff := reset(status, gohttp.StatusNoContent) + err := IgnoreResponse(client.DoRequest(t.Context(), MethodPatch, server.Url, WithJsonRequest(map[string]string{"key": "value"}, ""), backoff.option())) + require.NoError(t, err) + assert.Len(t, bodies, 2) + } + }) + + t.Run("a reset connection is retried", func(t *testing.T) { + backoff := reset(gohttp.StatusOK) + server.addToxic(t, "reset_peer", "downstream", map[string]any{"timeout": 0}) + backoff.onRetry = func() { server.removeToxics(t) } + + err := IgnoreResponse(client.DoRequest(t.Context(), MethodGet, server.Url, backoff.option())) + + require.NoError(t, err) + assert.Equal(t, int32(1), backoff.calls.Load()) + }) + + t.Run("a refused connection is not retried", func(t *testing.T) { + backoff := reset(gohttp.StatusOK) + server.proxy.Stop() + t.Cleanup(func() { require.NoError(t, server.proxy.Start()) }) + + err := IgnoreResponse(client.DoRequest(t.Context(), MethodGet, server.Url, backoff.option())) + + require.ErrorIs(t, err, syscall.ECONNREFUSED) + assert.Zero(t, backoff.calls.Load()) + }) + + t.Run("a server that does not answer is given up after stallTimeout, and not retried", func(t *testing.T) { + backoff := reset(gohttp.StatusOK) + server.addToxic(t, "timeout", "upstream", map[string]any{"timeout": 0}) + t.Cleanup(func() { server.removeToxics(t) }) + start := time.Now() + + err := IgnoreResponse(client.DoRequest(t.Context(), MethodGet, server.Url, backoff.option())) + + require.ErrorContains(t, err, "timeout awaiting response headers") + assert.GreaterOrEqual(t, time.Since(start), stallTimeout) + assert.Zero(t, backoff.calls.Load()) + }) + + t.Run("stops once the context is done", func(t *testing.T) { + backoff := reset(gohttp.StatusBadGateway) + ctx, cancel := context.WithCancel(t.Context()) + backoff.onRetry = cancel + + err := IgnoreResponse(client.DoRequest(ctx, MethodGet, server.Url, backoff.option())) + + require.ErrorIs(t, err, context.Canceled) + assert.Equal(t, int32(1), backoff.calls.Load()) + }) +} + +func TestStallGuard(t *testing.T) { + readBody := func(t *testing.T, server testServer) (string, time.Duration, error) { + t.Helper() + start := time.Now() + response, err := NewClient(testUserAgent).DoRequest(t.Context(), MethodGet, server.Url) + require.NoError(t, err) + body, err := io.ReadAll(response) + _ = response.Close() + return string(body), time.Since(start), err + } + + t.Run("a body that stops arriving ends after stallTimeout", func(t *testing.T) { + server := newTestServer(t, func(w gohttp.ResponseWriter, r *gohttp.Request) { + _, _ = io.WriteString(w, "partial") + _ = gohttp.NewResponseController(w).Flush() + <-r.Context().Done() + }) + + body, took, err := readBody(t, server) + + require.ErrorContains(t, err, "response body stalled: nothing received for 5s") + assert.Equal(t, "partial", body) + assert.GreaterOrEqual(t, took, stallTimeout) + }) + + t.Run("a body that takes longer than stallTimeout but keeps arriving is read to the end", func(t *testing.T) { + answer := strings.Repeat(".", 8*1000) + server := newTestServer(t, func(w gohttp.ResponseWriter, _ *gohttp.Request) { + _, _ = io.WriteString(w, answer) + }) + server.addToxic(t, "bandwidth", "downstream", map[string]any{"rate": 1}) + + body, took, err := readBody(t, server) + + require.NoError(t, err) + assert.Equal(t, answer, body) + assert.Greater(t, took, stallTimeout) + }) +} + +func TestAuthorizedClient(t *testing.T) { + var seen []string + server := newTestServer(t, func(w gohttp.ResponseWriter, r *gohttp.Request) { + seen = append(seen, r.Header.Get("Authorization")) + if r.Header.Get("Authorization") != "Bearer fresh" { + w.WriteHeader(gohttp.StatusUnauthorized) + _, _ = io.WriteString(w, "expired") + return + } + _, _ = io.WriteString(w, `"answer"`) + }) + doRequest := func(t *testing.T, auth Authorization) (string, error) { + t.Helper() + seen = nil + client := NewClient(testUserAgent).WithAuthorization(auth) + return UnmarshalJsonBody[string](client.DoRequest(t.Context(), MethodPut, server.Url)) + } + + t.Run("a 401 is retried once with the freshly minted token", func(t *testing.T) { + auth := &refreshableAuthorization{token: "stale"} + got, err := doRequest(t, auth) + require.NoError(t, err) + assert.Equal(t, "answer", got) + assert.Equal(t, []string{"Bearer stale", "Bearer fresh"}, seen) + assert.Equal(t, []BearerToken{"stale"}, auth.rejected, "the token that was refused is what the refresh is told about") + }) + + t.Run("a re-mint that changes nothing reports the 401", func(t *testing.T) { + _, err := doRequest(t, &refreshableAuthorization{token: "stale", keepToken: true}) + httpErr, ok := errors.AsType[Error](err) + require.True(t, ok, "%v", err) + assert.Equal(t, gohttp.StatusUnauthorized, httpErr.StatusCode) + assert.Len(t, seen, 1) + }) + + t.Run("a failing re-mint reports both errors", func(t *testing.T) { + auth := &refreshableAuthorization{token: "stale", refreshErr: errors.New("the login expired")} + _, err := doRequest(t, auth) + _, ok := errors.AsType[Error](err) + assert.True(t, ok, "the 401 the request ran into must stay reachable: %v", err) + require.ErrorIs(t, err, auth.refreshErr, "and so must the reason nothing better could be tried") + assert.Len(t, seen, 1) + }) + + t.Run("a static token is not re-minted", func(t *testing.T) { + _, err := doRequest(t, BearerToken("static")) + require.Error(t, err) + assert.Len(t, seen, 1) + }) +} + +func TestConvertStructOrMapToUrlValues(t *testing.T) { + convert := func(t *testing.T, structOrMap any) url.Values { + t.Helper() + values, err := convertStructOrMapToUrlValues(structOrMap) + require.NoError(t, err) + return values + } + + t.Run("a map goes as given, a zero value and a repeated parameter included", func(t *testing.T) { + query := url.Values{"dry": {"true"}, "tag": {"a", "b"}, "empty": {""}} + assert.Equal(t, query, convert(t, query)) + assert.Equal(t, url.Values{"page": {"0"}, "at": {"1234567890123456789"}}, convert(t, map[string]any{"page": 0, "at": 1234567890123456789}), + "a large number stays the literal it marshalled to") + }) + + t.Run("a struct names its fields by json tag and drops the zero ones", func(t *testing.T) { + type filter struct { + Identifier *string `json:"identifier"` + Name string `json:"name"` + Restricted *bool `json:"restricted"` + ExcludeTitle []string `json:"excludeTitle"` + RedirectUri xurl.URL `json:"redirect_uri"` + Unset *xurl.URL `json:"unset_uri"` + } + assert.Equal(t, url.Values{ + "identifier": {"abc"}, + "excludeTitle": {"Workspace Created", "Tenant Deleted"}, + "redirect_uri": {"http://127.0.0.1:31234/callback"}, + }, convert(t, filter{ + Identifier: new("abc"), + ExcludeTitle: []string{"Workspace Created", "", "Tenant Deleted"}, + RedirectUri: xurl.MustParsef("http://127.0.0.1:31234/callback"), + })) + assert.Empty(t, convert(t, &struct { + Identifier *string `json:"identifier"` + }{}), "a pointer to a struct drops its nil fields") + }) +} + +type testServer struct { + Url *url.URL + proxy *toxiproxy.Proxy +} + +func newTestServer(t *testing.T, handler gohttp.HandlerFunc) testServer { + t.Helper() + upstream := httptest.NewServer(handler) + t.Cleanup(upstream.Close) + proxy := toxiproxy.NewProxy(toxiproxy.NewServer(toxiproxy.NewMetricsContainer(nil), zerolog.Nop()), t.Name(), "127.0.0.1:0", upstream.Listener.Addr().String()) + require.NoError(t, proxy.Start()) + t.Cleanup(proxy.Stop) + return testServer{Url: &url.URL{Scheme: "http", Host: proxy.Listen, Path: "/"}, proxy: proxy} +} + +func (s testServer) addToxic(t *testing.T, toxicType, stream string, attributes map[string]any) { + t.Helper() + toxic, err := json.Marshal(map[string]any{"type": toxicType, "stream": stream, "attributes": attributes}) + require.NoError(t, err) + _, err = s.proxy.Toxics.AddToxicJson(bytes.NewReader(toxic)) + require.NoError(t, err) +} + +func (s testServer) removeToxics(t *testing.T) { + t.Helper() + s.proxy.Toxics.ResetToxics(t.Context()) +} + +type testBackoff struct { + calls atomic.Int32 + onRetry func() +} + +func (b *testBackoff) Calculate(int) time.Duration { + b.calls.Add(1) + if b.onRetry != nil { + b.onRetry() + } + return 0 +} + +func (b *testBackoff) option() RequestOption { + return func(opts *requestOptions) { + opts.retry.maxRetries = 2 + opts.retry.backoff = b + } +} + +type refreshableAuthorization struct { + token BearerToken + keepToken bool + refreshErr error + rejected []BearerToken +} + +func (a *refreshableAuthorization) GetBearerToken(context.Context) (BearerToken, error) { + return a.token, nil +} + +func (a *refreshableAuthorization) String() string { + return "refreshableAuthorization" +} + +func (a *refreshableAuthorization) RefreshBearerToken(_ context.Context, rejected BearerToken) (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 +} diff --git a/internal/http/http_error.go b/client/http/http_error.go similarity index 78% rename from internal/http/http_error.go rename to client/http/http_error.go index e20c3a89..73e706a4 100644 --- a/internal/http/http_error.go +++ b/client/http/http_error.go @@ -1,13 +1,15 @@ package http import ( - "encoding/json/v2" "fmt" gohttp "net/http" "strings" + + "github.com/meshcloud/meshstack-cli/internal/json" ) type Error struct { + Method, Url string StatusCode int ResponseBody []byte } @@ -15,14 +17,18 @@ type Error struct { // Error describes the response from its fields, with the clientVersionHint on a line of its own. A // response of another format is shown as it came. func (e Error) Error() string { + message := e.Summary() body, ok := e.body() - if !ok { - return fmt.Sprintf("%s, but the response is not in the expected error format: '%s'", e.Summary(), string(e.ResponseBody)) + switch { + case !ok: + message = fmt.Sprintf("%s, but the response is not in the expected error format: '%s'", message, string(e.ResponseBody)) + case body.ClientVersionHint != "": + message += "\n" + body.ClientVersionHint } - if body.ClientVersionHint != "" { - return e.Summary() + "\n" + body.ClientVersionHint + if e.Method == "" { + return message } - return e.Summary() + return e.Method + " " + e.Url + ": " + message } // Summary is Error without the clientVersionHint and without a body of an unexpected format. @@ -93,6 +99,14 @@ func (e Error) IsLocked() bool { return e.StatusCode == gohttp.StatusLocked } +func (e Error) IsTemporary() bool { + switch e.StatusCode { + case gohttp.StatusRequestTimeout, gohttp.StatusLocked, gohttp.StatusTooManyRequests: + return true + } + return e.StatusCode >= gohttp.StatusInternalServerError +} + func (e Error) body() (body errorBody, ok bool) { if err := json.Unmarshal(e.ResponseBody, &body); err != nil { return errorBody{}, false diff --git a/internal/http/http_error_test.go b/client/http/http_error_test.go similarity index 100% rename from internal/http/http_error_test.go rename to client/http/http_error_test.go diff --git a/client/http/logging.go b/client/http/logging.go new file mode 100644 index 00000000..467b7b52 --- /dev/null +++ b/client/http/logging.go @@ -0,0 +1,130 @@ +package http + +import ( + "bytes" + "context" + "encoding" + "errors" + "fmt" + "io" + "log/slog" + "maps" + gohttp "net/http" + "slices" + "strings" + "unicode/utf8" +) + +// 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] { + if k == "Authorization" { + v = "[REDACTED]" + } + lines = append(lines, fmt.Sprintf("%s=%s", k, v)) + } + } + return strings.Join(lines, "\n") +} + +const loggedBodyLimit = 1024 + +var errClosedEarly = errors.New("closed before its end") + +// A goroutine owns the logged bytes, because the transport may close a body while it reads it. +func newLoggedBody(ctx context.Context, message string, body io.ReadCloser) io.ReadCloser { + if body == nil || body == gohttp.NoBody || !slog.Default().Enabled(ctx, slog.LevelDebug) { + return body + } + tap, tapped := io.Pipe() + logged := make(chan struct{}) + go func() { + defer close(logged) + head, err := io.ReadAll(io.LimitReader(tap, loggedBodyLimit+1)) + _ = tap.Close() + slog.DebugContext(ctx, message, "body", loggedBody{head, err}) + }() + return tappedBody{body, tapped, logged} +} + +type tappedBody struct { + io.ReadCloser + + tap *io.PipeWriter + logged <-chan struct{} +} + +func (b tappedBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + if n > 0 { + _, _ = b.tap.Write(p[:n]) + } + if err != nil { + _ = b.tap.CloseWithError(err) + } + return n, err +} + +func (b tappedBody) Close() error { + _ = b.tap.CloseWithError(errClosedEarly) + <-b.logged + return b.ReadCloser.Close() +} + +// Without MarshalText terraform's JSON log would show a loggedBody as {}. +type loggedBody struct { + // head holds one byte more than loggedBodyLimit when the body was longer. + head []byte + err error +} + +var ( + _ fmt.Stringer = loggedBody{} + _ encoding.TextMarshaler = loggedBody{} +) + +func (b loggedBody) MarshalText() ([]byte, error) { + return []byte(b.String()), nil +} + +func (b loggedBody) String() string { + text := b.head[:min(len(b.head), loggedBodyLimit)] + switch { + case len(text) == 0 && b.err == nil: + return "" + case isBinary(text): + return "" + case len(b.head) > loggedBodyLimit: + return string(text) + " [TRUNCATED]" + case b.err != nil: + return fmt.Sprintf("%s\n", text, b.err) + } + return string(text) +} + +// isBinary ignores an incomplete rune at the end, which the cap can cut. +func isBinary(data []byte) bool { + for range utf8.UTFMax - 1 { + if r, size := utf8.DecodeLastRune(data); r != utf8.RuneError || size != 1 { + break + } + data = data[:len(data)-1] + } + return !utf8.Valid(data) || bytes.IndexByte(data, 0) >= 0 +} diff --git a/client/http/logging_test.go b/client/http/logging_test.go new file mode 100644 index 00000000..4cb316d0 --- /dev/null +++ b/client/http/logging_test.go @@ -0,0 +1,90 @@ +package http + +import ( + "bytes" + "errors" + "fmt" + "io" + "log/slog" + gohttp "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/logs" +) + +func TestLoggedBody(t *testing.T) { + render := func(body string) string { + head := []byte(body) + return loggedBody{head: head[:min(len(head), loggedBodyLimit+1)]}.String() + } + + assert.Equal(t, "Bad Gateway", render("Bad Gateway")) + assert.Equal(t, "", render(strings.Repeat("\x00\xff", 1500))) + assert.Equal(t, "", render("")) + assert.True(t, strings.HasSuffix(render(strings.Repeat("ä", loggedBodyLimit)), "[TRUNCATED]"), + "a rune the cap cut in half does not make the text binary") + assert.Equal(t, "partial\n", + loggedBody{head: []byte("partial"), err: errors.New("connection lost")}.String()) +} + +func TestLoggedRequest(t *testing.T) { + var answer struct { + status int + body string + } + server := newTestServer(t, func(w gohttp.ResponseWriter, _ *gohttp.Request) { + w.Header()["Date"] = nil + w.WriteHeader(answer.status) + _, _ = io.WriteString(w, answer.body) + }) + const secret = "supersecret" + client := NewClient(testUserAgent).WithAuthorization(BearerToken(secret)) + + t.Run("both bodies are logged, and the bearer token is not", func(t *testing.T) { + captured := logs.Capture(t) + answer.status, answer.body = gohttp.StatusOK, `{"name":"answered"}` + + _, err := UnmarshalJsonBody[map[string]string](client.DoRequest(t.Context(), MethodPost, server.Url, + WithJsonRequest(map[string]string{"name": "asked"}, ""))) + + require.NoError(t, err) + assert.Equal(t, []string{ + fmt.Sprintf(`level=DEBUG msg=request url=%s method=POST headers="Accept=application/json\nAuthorization=[REDACTED]\nContent-Type=application/json\nUser-Agent=meshstack-cli/test"`, server.Url), + `level=DEBUG msg="request body" body="{\"name\":\"asked\"}"`, + `level=DEBUG msg=response status=200 headers="Content-Length=19\nContent-Type=text/plain; charset=utf-8"`, + `level=DEBUG msg="response body" body="{\"name\":\"answered\"}"`, + }, captured.Lines(slog.LevelDebug)) + }) + + t.Run("a long body is logged truncated", func(t *testing.T) { + captured := logs.Capture(t) + answer.status, answer.body = gohttp.StatusNotFound, strings.Repeat("x", 2*loggedBodyLimit) + + _ = IgnoreResponse(client.DoRequest(t.Context(), MethodGet, server.Url)) + + assert.Contains(t, captured.String(), answer.body[:loggedBodyLimit]+" [TRUNCATED]") + }) + + // The Terraform provider's sink encodes attributes as JSON. + t.Run("a JSON handler gets the redacted text", func(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) }) + answer.status, answer.body = gohttp.StatusOK, `{"answer":"served"}` + + _, err := UnmarshalJsonBody[map[string]string](client.DoRequest(t.Context(), MethodPost, server.Url, + WithJsonRequest(map[string]string{"asked": "for"}, ""))) + + require.NoError(t, err) + logged := written.String() + assert.NotContains(t, logged, secret) + assert.Contains(t, logged, "Authorization=[REDACTED]") + assert.Contains(t, logged, `\"asked\":\"for\"`, "the request body") + assert.Contains(t, logged, `\"answer\":\"served\"`, "the response body") + }) +} diff --git a/internal/http/method.go b/client/http/method.go similarity index 91% rename from internal/http/method.go rename to client/http/method.go index e98d85f2..d2c2fefd 100644 --- a/internal/http/method.go +++ b/client/http/method.go @@ -8,5 +8,6 @@ const ( MethodGet = gohttp.MethodGet MethodPost = gohttp.MethodPost MethodPut = gohttp.MethodPut + MethodPatch = gohttp.MethodPatch MethodDelete = gohttp.MethodDelete ) diff --git a/client/http/options.go b/client/http/options.go new file mode 100644 index 00000000..1c7755f1 --- /dev/null +++ b/client/http/options.go @@ -0,0 +1,255 @@ +package http + +import ( + "bytes" + "context" + "encoding/json/jsontext" + "fmt" + "io" + "maps" + gohttp "net/http" + "net/url" + "os" + "reflect" + "time" + + "github.com/meshcloud/meshstack-cli/internal/json" +) + +type ( + RequestOption func(opts *requestOptions) + + requestOptions struct { + requestModifiers []requestModifier + retry retryOptions + noRedirects bool + setBody requestModifier + responseHeader *gohttp.Header + } + requestModifier func(req *gohttp.Request) error +) + +func newRequestOptions(options []RequestOption) requestOptions { + opts := requestOptions{ + retry: retryOptions{ + // Outlasts a meshStack backend restart, whose gateway can answer 503 for three minutes: + // 1+2+4+8+16+30*11 seconds is about six minutes. + policy: func(method string) RetryOn { + if method == MethodGet || method == MethodDelete { + return RetryOnTransient + } + // The server did not act on a request it answered with one of these. + return RetryOnBusy | RetryOnLocked + }, + maxRetries: 16, + backoff: exponentialBackoff{MinWait: 1 * time.Second, MaxWait: 30 * time.Second}, + }, + } + for _, option := range options { + option(&opts) + } + return opts +} + +type requestOptionsKey struct{} + +func (opts requestOptions) requestContext(ctx context.Context) context.Context { + return context.WithValue(ctx, requestOptionsKey{}, opts) +} + +func requestOptionsFrom(req *gohttp.Request) requestOptions { + if opts, ok := req.Context().Value(requestOptionsKey{}).(requestOptions); ok { + return opts + } + return newRequestOptions(nil) +} + +func appendRequestModifier(modifier requestModifier) RequestOption { + return func(opts *requestOptions) { + opts.requestModifiers = append(opts.requestModifiers, modifier) + } +} + +func Retryable(retryOn RetryOn) RequestOption { + return func(opts *requestOptions) { + opts.retry.policy = func(string) RetryOn { + return retryOn + } + } +} + +func WithoutRedirects() RequestOption { + return func(opts *requestOptions) { + opts.noRedirects = true + } +} + +func WithCapturedResponseHeader(header *gohttp.Header) RequestOption { + return func(opts *requestOptions) { + opts.responseHeader = header + } +} + +// WithUrlQuery sends each field of query as a query parameter named by its `json` tag. A struct +// passed by value leaves out every field that has the zero value, so an unset filter needs neither +// a pointer nor `omitempty`. A map is sent as given, so a deliberate zero such as page=0 stays. +// Each value is sent as the JSON literal it marshals to, with a string unquoted, and an array as the +// parameter repeated. Nested objects 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) { + encoded, 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(encoded, &converted); err != nil { + return nil, fmt.Errorf("cannot decode type %T into a flat map: %w", structOrMap, err) + } + skipZero := reflect.ValueOf(structOrMap).Kind() == reflect.Struct + result := url.Values{} + for key, value := range converted { + values := []jsontext.Value{value} + if value.Kind() == jsontext.KindBeginArray { + if err := json.UnmarshalRead(bytes.NewReader(value), &values); err != nil { + return nil, fmt.Errorf("cannot read %s of type %T as an array: %w", key, structOrMap, err) + } + } + for _, value := range values { + if value.Kind() == jsontext.KindNull { + 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() == jsontext.KindString { + if err := json.UnmarshalRead(bytes.NewReader(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() == jsontext.KindFalse) { + continue + } + result[key] = append(result[key], param) + } + } + return result, nil +} + +func WithHeader(key string, values ...string) RequestOption { + return appendRequestModifier(func(req *gohttp.Request) error { + req.Header.Del(key) + for _, value := range values { + req.Header.Add(key, value) + } + return nil + }) +} + +func WithContentType(contentType string) RequestOption { + return WithHeader("Content-Type", contentType) +} + +func WithFileRequest(path string, contentType string) RequestOption { + return func(opts *requestOptions) { + if contentType == "" { + contentType = "application/octet-stream" + } + WithContentType(contentType)(opts) + opts.setBody = func(req *gohttp.Request) error { + info, err := os.Stat(path) + if err != nil { + return err + } + return setBody(req, info.Size(), func() (io.ReadCloser, error) { + return os.Open(path) //nolint:gosec // the caller names the file it uploads + }) + } + } +} + +// wireCompatibility keeps the body encoding/json v1 produced: the meshObject API may read null and +// an empty collection differently on a PUT, and the Terraform provider hashes this JSON. +var wireCompatibility = json.JoinOptions( + json.Deterministic(true), + json.FormatNilSliceAsNull(true), + json.FormatNilMapAsNull(true), +) + +func WithJsonRequest(payload any, contentType string) RequestOption { + return func(opts *requestOptions) { + if contentType == "" { + contentType = "application/json" + } + + WithHeader("Accept", contentType)(opts) + WithContentType(contentType)(opts) + WithJsonBody(payload)(opts) + } +} + +func WithJsonBody(payload any) RequestOption { + return func(opts *requestOptions) { + if payload != nil { + opts.setBody = setBytesBody(func() ([]byte, error) { + return json.Marshal(payload, wireCompatibility) + }) + } + } +} + +// WithFormRequest 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 WithFormRequest(payload any) RequestOption { + return func(opts *requestOptions) { + if payload == nil { + return + } + WithHeader("Accept", "application/json")(opts) + WithContentType("application/x-www-form-urlencoded")(opts) + opts.setBody = setBytesBody(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 + }) + } +} + +func setBytesBody(build func() ([]byte, error)) requestModifier { + return func(req *gohttp.Request) error { + body, err := build() + if err != nil { + return err + } + return setBody(req, int64(len(body)), func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + }) + } +} + +func setBody(req *gohttp.Request, length int64, open func() (io.ReadCloser, error)) (err error) { + if length == 0 { + // A ContentLength of 0 with a body would mean an unknown length, sent chunked. + open = func() (io.ReadCloser, error) { return gohttp.NoBody, nil } + } + req.ContentLength, req.GetBody = length, open + req.Body, err = open() + return err +} diff --git a/internal/http/retry.go b/client/http/retry.go similarity index 58% rename from internal/http/retry.go rename to client/http/retry.go index 8befe34f..202efa77 100644 --- a/internal/http/retry.go +++ b/client/http/retry.go @@ -8,69 +8,62 @@ import ( "io" "log/slog" "math" + "net" gohttp "net/http" "strconv" "syscall" "time" ) -type RetryOptions struct { - MaxRetries int - Backoff RetryBackoff +type RetryOn uint8 + +const ( + RetryOnBusy RetryOn = 1 << iota + // RetryOnGatewayError covers 502 and 504, after which the server may have acted on the request. + RetryOnGatewayError + RetryOnBrokenConnection + RetryOnLocked + + RetryOnTransient = RetryOnBusy | RetryOnGatewayError | RetryOnBrokenConnection | RetryOnLocked +) + +type retryOptions struct { + policy func(method string) RetryOn + maxRetries int + 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 +func (on RetryOn) backoff(resp *gohttp.Response, err error, backoff retryBackoff) retryBackoff { + isBrokenConnection := func(err error) bool { + return errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || + errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, net.ErrClosed) } - 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: func(resp *gohttp.Response, err error) RetryBackoff { - if err != nil { - // Only a connection that broke once it was up clears on a retry, as one an ingress drops - // during a rolling deploy does. An unknown host, a refused connection or a TLS error - // repeats on every attempt, and a timeout would multiply by the retries. - if isBrokenConnection(err) { - return options.Backoff - } - return nil - } - 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 - } - }, + switch { + case err != nil: + if on&RetryOnBrokenConnection != 0 && isBrokenConnection(err) { + return backoff + } + return nil + case on&RetryOnGatewayError != 0 && (resp.StatusCode == gohttp.StatusBadGateway || resp.StatusCode == gohttp.StatusGatewayTimeout): + return backoff + case on&RetryOnBusy != 0 && (resp.StatusCode == gohttp.StatusTooManyRequests || resp.StatusCode == gohttp.StatusServiceUnavailable): + return retryAfterBackoff{Response: resp, Fallback: backoff} + case on&RetryOnLocked != 0 && resp.StatusCode == gohttp.StatusLocked: + return backoff + default: + return nil } } -func isBrokenConnection(err error) bool { - return errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || - errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) -} - -type RetryBackoff interface { +type retryBackoff interface { Calculate(attempt int) time.Duration } -type ExponentialBackoff struct { +type exponentialBackoff struct { MinWait, MaxWait time.Duration } -func (b ExponentialBackoff) Calculate(attempt int) 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 @@ -82,7 +75,7 @@ var timeNow = time.Now type retryAfterBackoff struct { Response *gohttp.Response - Fallback RetryBackoff + Fallback retryBackoff } func (b retryAfterBackoff) Calculate(attempt int) (waitTime time.Duration) { @@ -112,27 +105,27 @@ func (b retryAfterBackoff) Calculate(attempt int) (waitTime time.Duration) { } type retryRoundTripper struct { - Next gohttp.RoundTripper - MaxRetries int - ShouldRetryRequest func(req *gohttp.Request) bool - ShouldRetryResponse func(resp *gohttp.Response, err error) RetryBackoff + next gohttp.RoundTripper } -func (r *retryRoundTripper) RoundTrip(req *gohttp.Request) (*gohttp.Response, error) { - if !r.ShouldRetryRequest(req) { - return r.Next.RoundTrip(req) +func (r retryRoundTripper) RoundTrip(req *gohttp.Request) (*gohttp.Response, error) { + retryOpts := requestOptionsFrom(req).retry + on := retryOpts.policy(req.Method) + if on == 0 { + return r.next.RoundTrip(req) } req = makeRequestBodyRetryable(req) for attempt := 1; ; attempt++ { - resp, err := r.Next.RoundTrip(req) + resp, err := r.next.RoundTrip(req) // A request the context ended is not retried. The context is checked rather than the error, // since the transport returns the context's cause, which for Ctrl-C is a signal error that // wraps no context.Canceled. if errors.Is(err, errRetryableBodyClose) || err != nil && req.Context().Err() != nil { return resp, err } - backoff := r.ShouldRetryResponse(resp, err) - if backoff == nil || attempt > r.MaxRetries { + + backoff := on.backoff(resp, err, retryOpts.backoff) + if backoff == nil || attempt > retryOpts.maxRetries { return resp, err } drainAndCloseResponseBody(req.Context(), resp) @@ -153,7 +146,7 @@ func (r *retryRoundTripper) RoundTrip(req *gohttp.Request) (*gohttp.Response, er }(), "method", req.Method, "path", req.URL.Path, - "attempt", fmt.Sprintf("%d/%d", attempt, r.MaxRetries), + "attempt", fmt.Sprintf("%d/%d", attempt, retryOpts.maxRetries), "waitTime", waitTime, )...) timer := time.NewTimer(waitTime) @@ -170,11 +163,10 @@ func makeRequestBodyRetryable(req *gohttp.Request) *gohttp.Request { if req.Body == nil { return req } - // gohttp.NewRequestWithContext sets GetBody for a *bytes.Buffer, *bytes.Reader or - // *strings.Reader body. if req.GetBody != nil { return req } + body := retryableBody{Closer: req.Body} body.Reader = io.TeeReader(req.Body, &body.Buffer) result := req.Clone(req.Context()) @@ -215,13 +207,11 @@ func (w *appendWriter) Write(p []byte) (int, error) { return len(p), nil } -// drainAndCloseResponseBody drains the body so that gohttp.Transport can reuse the connection. -// It reads at most maxBytes, so that a large or slow body cannot block the retry; the connection -// of such a body is then not reused. +const maxDrainBytes = 16 * 1024 + 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) + drainedBytes, err := io.CopyN(io.Discard, resp.Body, maxDrainBytes) if err != nil && !errors.Is(err, io.EOF) { slog.DebugContext(ctx, "failed to drain response body: "+err.Error()) } diff --git a/client/http/retry_test.go b/client/http/retry_test.go new file mode 100644 index 00000000..6e195a1e --- /dev/null +++ b/client/http/retry_test.go @@ -0,0 +1,63 @@ +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) { + 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}, + {"capped at 5 minutes", "600", 5 * time.Minute}, + {"empty header", "", 1 * time.Second}, + {"unparseable header", "not-a-number-or-date", 1 * time.Second}, + {"HTTP-date in the past", bubbleStart.Add(-10 * time.Second).Format(gohttp.TimeFormat), 1 * time.Second}, + {"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/client/http/stall.go b/client/http/stall.go new file mode 100644 index 00000000..a6f06dd8 --- /dev/null +++ b/client/http/stall.go @@ -0,0 +1,41 @@ +package http + +import ( + "context" + "fmt" + "io" + "time" +) + +var stallTimeout = 1 * time.Minute + +// stallGuardedBody bounds the silence of a body, so a long answer that is still arriving is read to the end. +type stallGuardedBody struct { + io.ReadCloser + + timer *time.Timer + cancel context.CancelCauseFunc +} + +func newStallGuardedBody(body io.ReadCloser, cancel context.CancelCauseFunc) io.ReadCloser { + stalled := fmt.Errorf("response body stalled: nothing received for %s", stallTimeout) + return &stallGuardedBody{ + ReadCloser: body, + timer: time.AfterFunc(stallTimeout, func() { cancel(stalled) }), + cancel: cancel, + } +} + +func (b *stallGuardedBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + b.timer.Reset(stallTimeout) + return n, err +} + +func (b *stallGuardedBody) Close() error { + b.timer.Stop() + // Closing the body before cancelling hands a fully read connection back to the pool. + err := b.ReadCloser.Close() + b.cancel(nil) + return err +} diff --git a/internal/http/user_agent.go b/client/http/user_agent.go similarity index 94% rename from internal/http/user_agent.go rename to client/http/user_agent.go index 1c00bb86..ba700199 100644 --- a/internal/http/user_agent.go +++ b/client/http/user_agent.go @@ -13,7 +13,12 @@ type UserAgent struct { Version string } -func (u UserAgent) Validate() error { +func (u UserAgent) String() string { + _, repo, _ := strings.Cut(u.GitHubRepo, "/") + return repo + "/" + u.Version +} + +func (u UserAgent) validate() error { org, repo, ok := strings.Cut(u.GitHubRepo, "/") if !ok || org == "" || repo == "" { return fmt.Errorf("GitHub repo '%s' is not of / format", u.GitHubRepo) @@ -23,8 +28,3 @@ func (u UserAgent) Validate() error { } return nil } - -func (u UserAgent) String() string { - _, repo, _ := strings.Cut(u.GitHubRepo, "/") - return repo + "/" + u.Version -} diff --git a/client/integration.go b/client/integration.go index 582a224f..34875c67 100644 --- a/client/integration.go +++ b/client/integration.go @@ -1,20 +1,15 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" -) +type MeshIntegrationMetadata = MeshObjectByUuidMetadata type MeshIntegration struct { Metadata MeshIntegrationMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshIntegrationSpec `json:"spec" tfsdk:"spec"` - Status *MeshIntegrationStatus `json:"status" tfsdk:"status"` + Status *MeshIntegrationStatus `json:"status,omitzero" tfsdk:"status"` } -type MeshIntegrationMetadata struct { - Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +func (i MeshIntegration) meshObjectId() string { + return i.Metadata.meshObjectId() } type MeshIntegrationSpec struct { @@ -44,38 +39,4 @@ type MeshAwsWifProvider struct { 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")} -} - -func (c meshIntegrationClientImpl) Create(ctx context.Context, integration MeshIntegration) (*MeshIntegration, error) { - 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) { - 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) -} +type MeshIntegrationClient = MeshObjectClient[MeshIntegration, string, NoFilter] diff --git a/client/integration_config.go b/client/integration_config.go index 5b63eb4e..14cebde9 100644 --- a/client/integration_config.go +++ b/client/integration_config.go @@ -1,13 +1,9 @@ package client import ( - "encoding/json/jsontext" - "encoding/json/v2" - "fmt" - "github.com/meshcloud/meshstack-cli/client/types" "github.com/meshcloud/meshstack-cli/client/types/enum" - "github.com/meshcloud/meshstack-cli/client/types/variant" + "github.com/meshcloud/meshstack-cli/client/types/polymorph" ) type MeshIntegrationConfigType string @@ -51,47 +47,10 @@ type MeshIntegrationEntraIdConfig struct { } type MeshIntegrationConfig struct { - Type enum.Entry[MeshIntegrationConfigType] `json:"type" tfsdk:"-"` - 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) InferType() (enum.Entry[MeshIntegrationConfigType], error) { - result, err := variant.InferType( - variant.NewCandidate(MeshIntegrationConfigTypeGithub, m.Github != nil), - variant.NewCandidate(MeshIntegrationConfigTypeGitlab, m.Gitlab != nil), - variant.NewCandidate(MeshIntegrationConfigTypeAzureDevops, m.AzureDevops != nil), - variant.NewCandidate(MeshIntegrationConfigTypeEntraId, m.EntraId != nil), - ) - if err != nil { - return "", fmt.Errorf("cannot infer integration config type: %w", err) - } - return result, nil -} - -func (m MeshIntegrationConfig) MarshalJSONTo(enc *jsontext.Encoder) error { - type wrapped MeshIntegrationConfig - w := wrapped(m) - // Built-in integrations (replicator, metering) come with a type but no variant, so the type is only - // inferred when it is missing. - if len(w.Type) == 0 { - inferred, err := m.InferType() - if err != nil { - return err - } - w.Type = inferred - } - return json.MarshalEncode(enc, w, enc.Options()) -} + polymorph.Polymorph[MeshIntegrationConfig, MeshIntegrationConfigType] -func (m *MeshIntegrationConfig) UnmarshalJSONFrom(dec *jsontext.Decoder) error { - type wrapped MeshIntegrationConfig - var target wrapped - if err := json.UnmarshalDecode(dec, &target, dec.Options()); err != nil { - return err - } - *m = MeshIntegrationConfig(target) - return nil + Github *MeshIntegrationGithubConfig `json:"github,omitzero" polymorph:"github" tfsdk:"github"` + Gitlab *MeshIntegrationGitlabConfig `json:"gitlab,omitzero" polymorph:"gitlab" tfsdk:"gitlab"` + AzureDevops *MeshIntegrationAzureDevopsConfig `json:"azuredevops,omitzero" polymorph:"azuredevops" tfsdk:"azuredevops"` + EntraId *MeshIntegrationEntraIdConfig `json:"entraid,omitzero" polymorph:"entraid" tfsdk:"entraid"` } diff --git a/client/internal/mesh_object.go b/client/internal/mesh_object.go deleted file mode 100644 index 9147f6f0..00000000 --- a/client/internal/mesh_object.go +++ /dev/null @@ -1,209 +0,0 @@ -package internal - -import ( - "context" - "errors" - "fmt" - "iter" - "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 -} - -// MeshObjectApi is the API of one meshObject kind, in one version, with no Go type for its objects: -// the requests and the paging they share, for a caller that decodes the answers itself. -type MeshObjectApi struct { - http.AuthorizedClient - - Kind string - ApiVersion string - ApiUrl *url.URL -} - -// MeshObjectClient decodes the API's objects into M, whose type name gives the kind. -type MeshObjectClient[M any] struct { - MeshObjectApi -} - -// NewMeshObjectClient takes the API path from the kind of M, in plural and lower case, unless -// explicitApiPathElems is given. Only the user and group binding APIs of workspaces and projects -// break that convention. -func NewMeshObjectClient[M any](ctx context.Context, httpClient HttpClient, apiVersion string, explicitApiPathElems ...string) MeshObjectClient[M] { - kind := InferKind[M]() - - if len(explicitApiPathElems) == 0 { - explicitApiPathElems = []string{strings.ToLower(pluralizeKind(kind))} - } - explicitApiPathElems = slices.Insert(explicitApiPathElems, 0, "/api/meshobjects") - 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]{MeshObjectApi{httpClient.AuthorizedClient, kind, apiVersion, apiUrl}} -} - -var versionSuffixRe = regexp.MustCompile(`V\d+$`) - -// InferKind follows the naming of the meshObject API: MeshWorkspace gives meshWorkspace, and -// MeshBuildingBlockV2 gives meshBuildingBlock. -func InferKind[M any]() string { - typeName := reflect.TypeFor[M]().Name() - - runes := []rune(typeName) - runes[0] = unicode.ToLower(runes[0]) - kind := string(runes) - - return versionSuffixRe.ReplaceAllString(kind, "") -} - -var pluralExceptions = map[string]string{} - -func pluralizeKind(kind string) string { - if plural, ok := pluralExceptions[kind]; ok { - return plural - } - return kind + "s" -} - -func (c MeshObjectApi) MeshObjectMimeType() string { - return fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", c.Kind, c.ApiVersion) -} - -func (c MeshObjectClient[M]) Get(ctx context.Context, id string) (resp *M, err error) { - 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 MeshObjectApi) 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())) -} - -func (c MeshObjectClient[M]) Post[P any](ctx context.Context, payload P) (*M, error) { - return c.PostAtPath[*M](ctx, payload) -} - -func (c MeshObjectApi) 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)) -} - -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()) -} - -func (c MeshObjectClient[M]) Delete(ctx context.Context, id string) (err error) { - return c.DeleteAtPath(ctx, id) -} - -func (c MeshObjectApi) 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 -} - -func (c MeshObjectClient[M]) ListSeq(ctx context.Context, options ...http.RequestOption) iter.Seq2[M, error] { - return c.ListSeqAs[M](ctx, ListOptions{}, options...) -} - -func (c MeshObjectApi) ListSeqAs[T any](ctx context.Context, listOptions ListOptions, options ...http.RequestOption) iter.Seq2[T, error] { - return func(yield func(T, error) bool) { - var noItem T - embeddedKey := pluralizeKind(c.Kind) - - for pageNumber := 0; ; pageNumber++ { - response, err := c.getPage[T](ctx, listOptions, pageNumber, options) - if err != nil { - yield(noItem, err) - return - } - items, ok := response.Embedded[embeddedKey] - if !ok { - yield(noItem, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey)) - return - } - if listOptions.OnPage != nil { - listOptions.OnPage(response.Page) - } - for _, item := range items { - if !yield(item, nil) { - return - } - } - if response.Page.Number >= response.Page.TotalPages-1 { - return - } - } - } -} - -type Page struct { - Size int `json:"size"` - TotalElements int `json:"totalElements"` - TotalPages int `json:"totalPages"` - Number int `json:"number"` -} - -type paginatedResponse[T any] struct { - Embedded map[string][]T `json:"_embedded"` - Page Page `json:"page"` -} - -// withMeshObjectPayload takes P rather than an any because the `,embed` tag takes a struct, a -// string-keyed map or a jsontext.Value, never an any. -func (c MeshObjectApi) 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()) -} - -func (c MeshObjectApi) getPage[T any](ctx context.Context, listOptions ListOptions, pageNumber int, options []http.RequestOption) (paginatedResponse[T], error) { - query := map[string]any{"page": pageNumber} - if listOptions.PageSize > 0 { - query["size"] = listOptions.PageSize - } - response, err := c.DoRequest[paginatedResponse[T]](ctx, http.MethodGet, c.ApiUrl, append(options, - http.WithAccept(c.MeshObjectMimeType()), - http.WithUrlQuery(query), - )...) - if err != nil && pageNumber > 0 { - // The first page fails for what fails the whole list, such as a missing role, and a page - // number would only distract from it. - return response, fmt.Errorf("cannot read page %d of the list: %w", pageNumber, err) - } - if err != nil { - return response, err - } - return response, nil -} - -type ListOptions struct { - PageSize int - OnPage func(Page) -} - -func (c MeshObjectClient[M]) List(ctx context.Context, options ...http.RequestOption) ([]M, error) { - var result []M - for item, err := range c.ListSeq(ctx, options...) { - if err != nil { - return result, err - } - result = append(result, item) - } - return result, nil -} diff --git a/client/landingzone.go b/client/landingzone.go index 80fd4cc3..5a813d90 100644 --- a/client/landingzone.go +++ b/client/landingzone.go @@ -1,16 +1,9 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) - type MeshLandingZone struct { Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` - Status MeshLandingZoneStatus `json:"status" tfsdk:"status"` + Status MeshLandingZoneStatus `json:"status,omitzero" tfsdk:"status"` } type MeshLandingZoneMetadata struct { @@ -58,12 +51,7 @@ type MeshLandingZoneQuota struct { Value int64 `json:"value" tfsdk:"value"` } -type MeshLandingZoneCreate struct { - Metadata MeshLandingZoneMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshLandingZoneSpec `json:"spec" tfsdk:"spec"` -} - -type MeshLandingZoneListQuery struct { +type MeshLandingZoneFilter struct { PlatformUuid *string `json:"platformUuid"` Identifier *string `json:"identifier"` DisplayName *string `json:"displayName"` @@ -71,38 +59,8 @@ type MeshLandingZoneListQuery struct { 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 -} - -type meshLandingZoneClient struct { - meshObject internal.MeshObjectClient[MeshLandingZone] -} - -func newLandingZoneClient(ctx context.Context, httpClient internal.HttpClient) MeshLandingZoneClient { - return meshLandingZoneClient{internal.NewMeshObjectClient[MeshLandingZone](ctx, httpClient, "v1")} -} - -func (c meshLandingZoneClient) Read(ctx context.Context, name string) (*MeshLandingZone, error) { - return c.meshObject.Get(ctx, name) -} - -func (c meshLandingZoneClient) List(ctx context.Context, query MeshLandingZoneListQuery) ([]MeshLandingZone, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(query)) -} - -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) { - return c.meshObject.Put(ctx, name, landingZone) -} +type MeshLandingZoneClient = MeshObjectClient[MeshLandingZone, MeshObjectName, MeshLandingZoneFilter] -func (c meshLandingZoneClient) Delete(ctx context.Context, name string) error { - return c.meshObject.Delete(ctx, name) +func (lz MeshLandingZone) meshObjectId() MeshObjectName { + return MeshObjectName(lz.Metadata.Name) } diff --git a/client/lifecycle.go b/client/lifecycle.go new file mode 100644 index 00000000..2e9bf493 --- /dev/null +++ b/client/lifecycle.go @@ -0,0 +1,41 @@ +package client + +import ( + "time" + + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +type MeshLifecycleState string + +var ( + MeshLifecycleStates = enum.Enum[MeshLifecycleState]{} + MeshLifecycleStateActive = MeshLifecycleStates.Entry("ACTIVE") + MeshLifecycleStateDeactivated = MeshLifecycleStates.Entry("DEACTIVATED") + MeshLifecycleStateMarkedForDeletion = MeshLifecycleStates.Entry("MARKED_FOR_DELETION") + MeshLifecycleStateDeleted = MeshLifecycleStates.Entry("DELETED") +) + +type MeshLifecycle struct { + State enum.Entry[MeshLifecycleState] `json:"state"` + Created MeshLifecycleTransition `json:"created"` + Deactivated *MeshLifecycleTransition `json:"deactivated,omitzero"` + MarkedForDeletion *MeshLifecycleTransition `json:"markedForDeletion,omitzero"` + Deleted *MeshLifecycleTransition `json:"deleted,omitzero"` +} + +type MeshLifecycleTransition struct { + Timestamp time.Time `json:"timestamp"` + Author *MeshObjectAuthor `json:"author,omitzero"` +} + +type MeshObjectAuthor struct { + // Type is one of ApiKey, ApiUser, System and User, and decides what Identifier is: the uuid of + // the meshApiKey, the name of the API user, "system", or the uuid of the meshUser. + Type string `json:"type"` + Identifier string `json:"identifier"` + DisplayName *string `json:"displayName,omitzero"` + Username *string `json:"username,omitzero"` + Email *string `json:"email,omitzero"` + Euid *string `json:"euid,omitzero"` +} diff --git a/client/location.go b/client/location.go index c2935caf..8644d462 100644 --- a/client/location.go +++ b/client/location.go @@ -1,21 +1,15 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" -) - type MeshLocation struct { Metadata MeshLocationMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` - Status MeshLocationStatus `json:"status" tfsdk:"status"` + Status MeshLocationStatus `json:"status,omitzero" tfsdk:"status"` } type MeshLocationMetadata 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,omitzero" tfsdk:"uuid"` } type MeshLocationSpec struct { @@ -27,43 +21,8 @@ type MeshLocationStatus struct { IsPublic bool `json:"isPublic" tfsdk:"is_public"` } -type MeshLocationCreate struct { - Metadata MeshLocationCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshLocationSpec `json:"spec" tfsdk:"spec"` -} - -type MeshLocationCreateMetadata struct { - Name string `json:"name" tfsdk:"name"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` -} - -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")} -} - -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) { - return c.meshObject.Post(ctx, location) -} - -func (c meshLocationClient) Update(ctx context.Context, name string, location *MeshLocationCreate) (*MeshLocation, error) { - return c.meshObject.Put(ctx, name, location) -} +type MeshLocationClient = MeshObjectClient[MeshLocation, MeshObjectName, NoFilter] -func (c meshLocationClient) Delete(ctx context.Context, name string) error { - return c.meshObject.Delete(ctx, name) +func (location MeshLocation) meshObjectId() MeshObjectName { + return MeshObjectName(location.Metadata.Name) } diff --git a/client/mesh_info.go b/client/mesh_info.go index 6c7d8306..8c0846cb 100644 --- a/client/mesh_info.go +++ b/client/mesh_info.go @@ -4,10 +4,10 @@ import ( "context" "fmt" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/client/types/version" "github.com/meshcloud/meshstack-cli/client/types/xurl" - "github.com/meshcloud/meshstack-cli/internal/http" - "github.com/meshcloud/meshstack-cli/internal/version" ) // MeshFeatureFlag names an optional meshStack capability. /mesh/info reports each one as a bool of @@ -45,7 +45,7 @@ func NewMeshInfoClient(client http.Client, endpoint xurl.URL) MeshInfoClient { } func (c meshInfoClient) Read(ctx context.Context) (MeshInfo, error) { - return c.DoRequest[MeshInfo](ctx, "GET", c.Endpoint.JoinPath("/mesh/info"), http.WithAccept("application/json")) + return http.UnmarshalJsonBody[MeshInfo](c.DoRequest(ctx, http.MethodGet, c.Endpoint.JoinPath("/mesh/info"), http.WithHeader("Accept", "application/json"))) } func (info MeshInfo) CheckVersion() error { diff --git a/client/meshobject.go b/client/meshobject.go new file mode 100644 index 00000000..836f2f51 --- /dev/null +++ b/client/meshobject.go @@ -0,0 +1,270 @@ +package client + +import ( + "context" + "errors" + "fmt" + "io" + "iter" + "log/slog" + "net/url" + "reflect" + "regexp" + "slices" + "strings" + "time" + "unicode" + + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/hal" + "github.com/meshcloud/meshstack-cli/client/types/xurl" +) + +type MeshObjectClient[M, Id, Filter any] interface { + // Read returns nil and no error where meshStack has no object with the id. + Read(ctx context.Context, id Id) (*M, error) + List(ctx context.Context, filter Filter, options ...ListOption) iter.Seq2[M, error] + Create(ctx context.Context, object M) (M, error) + Update(ctx context.Context, object M) (M, error) + // Delete takes a 404 as success, which a retried delete gets. + Delete(ctx context.Context, id Id) error +} + +type NoFilter struct{} + +type MeshObjectName string + +func (n MeshObjectName) String() string { + return string(n) +} + +// SortCriteria holds one `property,(asc|desc)` criterion per element, such as createdAt,desc. +type SortCriteria []string + +type ListOption func(request *listOptions) + +type listOptions struct { + pageSize int + until time.Time + page *types.Page +} + +func WithPageSize(size int) ListOption { + return func(request *listOptions) { + request.pageSize = size + } +} + +// WithNewestFirst yields to a filter's own sort and until. +func WithNewestFirst(until time.Time) ListOption { + return func(request *listOptions) { + request.until = until + } +} + +func WithCapturedPage(page *types.Page) ListOption { + return func(request *listOptions) { + request.page = page + } +} + +type meshObject[Id any] interface { + meshObjectId() Id +} + +type meshObjectId interface { + ~string +} + +type meshObjectAtPath interface { + meshObjectPath() string +} + +// meshObjectInOwnOrder marks a kind whose listing answers 400 to any sort key. +type meshObjectInOwnOrder interface { + meshObjectInOwnOrder() +} + +type meshObjectClient[M meshObject[Id], Id meshObjectId, Filter any] struct { + client http.AuthorizedClient + collection *url.URL + mediaType string + kind string + apiVersion string +} + +func newMeshObjectClient[M meshObject[Id], Id meshObjectId, Filter any](ctx context.Context, client http.AuthorizedClient, endpoint xurl.URL, apiVersion string) meshObjectClient[M, Id, Filter] { + kind := inferKind[M]() + path := strings.ToLower(pluralizeKind(kind)) + if atPath, ok := any(*new(M)).(meshObjectAtPath); ok { + path = atPath.meshObjectPath() + } + c := meshObjectClient[M, Id, Filter]{ + client: client, + collection: endpoint.JoinPath("api", "meshobjects", path), + mediaType: fmt.Sprintf("application/vnd.meshcloud.api.%s.%s.hal+json", kind, apiVersion), + kind: kind, + apiVersion: apiVersion, + } + slog.DebugContext(ctx, fmt.Sprintf("initialized %s client", reflect.TypeFor[M]().Name()), "url", c.collection.String(), "kind", kind, "version", apiVersion) + return c +} + +func WithKindAndApiVersion[M any, Id meshObjectId, Filter any](c MeshObjectClient[M, Id, Filter], object M) any { + enveloper, ok := c.(interface{ envelope(object M) any }) + if !ok { + panic(fmt.Sprintf("%T is no client that client.New built", c)) + } + return enveloper.envelope(object) +} + +var versionSuffixRe = regexp.MustCompile(`V\d+$`) + +func inferKind[M any]() string { + runes := []rune(reflect.TypeFor[M]().Name()) + runes[0] = unicode.ToLower(runes[0]) + return versionSuffixRe.ReplaceAllString(string(runes), "") +} + +func pluralizeKind(kind string) string { + return kind + "s" +} + +func (c meshObjectClient[M, Id, Filter]) do(ctx context.Context, method string, target *url.URL, options ...http.RequestOption) (io.ReadCloser, error) { + return c.client.DoRequest(ctx, method, target, slices.Insert(options, 0, http.WithHeader("Accept", c.mediaType))...) +} + +// followLink sends the bearer token only to the origin of the endpoint. +func (c meshObjectClient[M, Id, Filter]) followLink(ctx context.Context, method, href string, options ...http.RequestOption) (io.ReadCloser, error) { + target, err := c.collection.Parse(href) + if err != nil { + return nil, fmt.Errorf("meshStack sent the link %q, which is no URL: %w", href, err) + } + if target.Scheme != c.collection.Scheme || target.Host != c.collection.Host { + return nil, fmt.Errorf("meshStack sent the link %s, which leaves the endpoint %s://%s", target.Redacted(), c.collection.Scheme, c.collection.Host) + } + return c.do(ctx, method, target, slices.Insert(options, 0, http.WithContentType(c.mediaType))...) +} + +func (c meshObjectClient[M, Id, Filter]) Read(ctx context.Context, id Id) (*M, error) { + object, err := c.unmarshalJsonBody[*M](c.do(ctx, http.MethodGet, c.collection.JoinPath(string(id)))) + if isNotFound(err) { + //nolint:nilnil // a meshObject client returns nil on 404, which is how Terraform handles a resource that does not exist + return nil, nil + } + return object, err +} + +func (c meshObjectClient[M, Id, Filter]) Create(ctx context.Context, object M) (M, error) { + return c.unmarshalJsonBody[M](c.do(ctx, http.MethodPost, c.collection, http.WithJsonRequest(c.envelope(object), c.mediaType))) +} + +func (c meshObjectClient[M, Id, Filter]) Update(ctx context.Context, object M) (M, error) { + return c.unmarshalJsonBody[M](c.do(ctx, http.MethodPut, c.collection.JoinPath(string(object.meshObjectId())), + http.WithJsonRequest(c.envelope(object), c.mediaType), http.Retryable(http.RetryOnTransient))) +} + +func (c meshObjectClient[M, Id, Filter]) Delete(ctx context.Context, id Id) error { + err := http.IgnoreResponse(c.do(ctx, http.MethodDelete, c.collection.JoinPath(string(id)))) + if isNotFound(err) { + return nil + } + return err +} + +func (c meshObjectClient[M, Id, Filter]) envelope(object M) any { + return struct { + ApiVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Object M `json:",embed"` + }{c.apiVersion, c.kind, object} +} + +func (c meshObjectClient[M, Id, Filter]) List(ctx context.Context, filter Filter, options ...ListOption) iter.Seq2[M, error] { + var request listOptions + for _, option := range options { + option(&request) + } + query := []http.RequestOption{http.WithUrlQuery(filter)} + if !request.until.IsZero() { + // Without a fixed until, an object created during the listing pushes a read one onto the next page. + newestFirst := url.Values{"until": {request.until.UTC().Format(time.RFC3339Nano)}} + if _, inOwnOrder := any(*new(M)).(meshObjectInOwnOrder); !inOwnOrder { + newestFirst.Set("sort", "createdAt,desc") + } + query = []http.RequestOption{http.WithUrlQuery(newestFirst), http.WithUrlQuery(filter)} + } + return func(yield func(M, error) bool) { + var noObject M + embeddedKey := pluralizeKind(c.kind) + for pageNumber := 0; ; pageNumber++ { + page, err := c.page(ctx, pageNumber, request.pageSize, query) + if err != nil { + yield(noObject, err) + return + } + objects, ok := page.Embedded[embeddedKey] + if !ok { + yield(noObject, fmt.Errorf("embedded key %s not found in paginated response", embeddedKey)) + return + } + if request.page != nil { + *request.page = page.Page + } + for _, object := range objects { + if !yield(object, nil) { + return + } + } + if page.Page.Number >= page.Page.TotalPages-1 { + return + } + } + } +} + +type paginatedResponse[M any] struct { + Embedded map[string][]M `json:"_embedded"` + Page types.Page `json:"page"` +} + +func (c meshObjectClient[M, Id, Filter]) page(ctx context.Context, pageNumber, pageSize int, query []http.RequestOption) (paginatedResponse[M], error) { + pageQuery := map[string]any{"page": pageNumber} + if pageSize > 0 { + pageQuery["size"] = pageSize + } + page, err := c.unmarshalJsonBody[paginatedResponse[M]](c.do(ctx, http.MethodGet, c.collection, append(query, http.WithUrlQuery(pageQuery))...)) + if err != nil && pageNumber > 0 { + // The first page fails for what fails the whole list, such as a missing role, and a page + // number would only distract from it. + return page, fmt.Errorf("cannot read page %d of the list: %w", pageNumber, err) + } + return page, err +} + +func (c meshObjectClient[M, Id, Filter]) unmarshalJsonBody[R any](body io.ReadCloser, err error) (R, error) { + return http.UnmarshalJsonBody[R](body, err, hal.LinkUnmarshalers(c.followLink)) +} + +func isNotFound(err error) bool { + httpErr, ok := errors.AsType[http.Error](err) + return ok && httpErr.IsNotFound() +} + +type MeshObjectByUuidMetadata struct { + Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` +} + +func (m MeshObjectByUuidMetadata) meshObjectId() string { + return *m.Uuid +} + +type MeshObjectByNameMetadata struct { + Name string `json:"name" tfsdk:"name"` +} + +func (m MeshObjectByNameMetadata) meshObjectId() MeshObjectName { + return MeshObjectName(m.Name) +} diff --git a/client/meshobject_test.go b/client/meshobject_test.go new file mode 100644 index 00000000..21506481 --- /dev/null +++ b/client/meshobject_test.go @@ -0,0 +1,20 @@ +package client + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/http" +) + +func TestALinkToAnotherOriginIsNotFollowed(t *testing.T) { + c := meshObjectClient[MeshWorkspace, MeshObjectName, MeshWorkspaceFilter]{ + collection: &url.URL{Scheme: "https", Host: "meshstack.example", Path: "/api/meshobjects/meshworkspaces"}, + } + for _, href := range []string{"https://other.host/api/x", "//other.host/x", "http://meshstack.example/x"} { + _, err := c.followLink(t.Context(), http.MethodGet, href) + require.ErrorContains(t, err, "which leaves the endpoint", href) + } +} diff --git a/client/openapi/doc.go b/client/openapi/doc.go index a610df5f..5b4e64e9 100644 --- a/client/openapi/doc.go +++ b/client/openapi/doc.go @@ -3,10 +3,11 @@ package openapi import ( "cmp" "encoding/json/jsontext" - "encoding/json/v2" "fmt" "slices" "strings" + + "github.com/meshcloud/meshstack-cli/internal/json" ) // OperationDoc is what the API docs say about an operation, for a person to read. @@ -41,7 +42,7 @@ type Body struct { // Field is a property anywhere in a body. Its Path joins the property names from the top with dots, // and writes the items of an array as [], as in spec.parentBuildingBlockRefs[].uuid, and the values -// of a map as the name *, as the API docs do. +// of a map as {}, as in spec.inputs{}.value. type Field struct { Path string Type string @@ -58,14 +59,18 @@ func (f Field) Depth() int { } type schema struct { - Ref string `json:"$ref"` - Type string `json:"type"` - Format string `json:"format"` - Description string `json:"description"` - Required []string `json:"required"` - Properties object `json:"properties"` - Items jsontext.Value `json:"items"` - resolvedRef string + Ref string `json:"$ref"` + Type string `json:"type"` + Format string `json:"format"` + Description string `json:"description"` + Required []string `json:"required"` + Properties object `json:"properties"` + Items jsontext.Value `json:"items"` + AdditionalProperties jsontext.Value `json:"additionalProperties"` + AllOf []jsontext.Value `json:"allOf"` + OneOf []jsontext.Value `json:"oneOf"` + AnyOf []jsontext.Value `json:"anyOf"` + resolvedRef string } // Doc reads the operation of a Spec, or of a selection of it, which keeps every component. @@ -74,12 +79,13 @@ func (s Spec) Doc(operation Operation) (OperationDoc, error) { Summary string `json:"summary"` Description string `json:"description"` Parameters []struct { - Name string `json:"name"` - In string `json:"in"` - Required bool `json:"required"` - Description string `json:"description"` - Schema schema `json:"schema"` - PathKey string `json:"x-meshstack-path-key"` + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Description string `json:"description"` + Schema jsontext.Value `json:"schema"` + PathKey string `json:"x-meshstack-path-key"` + PathKeyByVersion map[string]string `json:"x-meshstack-path-key-by-version"` } `json:"parameters"` RequestBody struct { Content object `json:"content"` @@ -91,13 +97,17 @@ func (s Spec) Doc(operation Operation) (OperationDoc, error) { } doc := OperationDoc{Summary: parsed.Summary, Description: parsed.Description} for _, parameter := range parsed.Parameters { + typeName, err := s.typeName(parameter.Schema, nil) + if err != nil { + return doc, fmt.Errorf("parameter %s: %w", parameter.Name, err) + } doc.Parameters = append(doc.Parameters, Parameter{ Name: parameter.Name, In: parameter.In, Required: parameter.Required, - Type: parameter.Schema.typeName(nil), + Type: typeName, Description: parameter.Description, - PathKey: parameter.PathKey, + PathKey: cmp.Or(parameter.PathKeyByVersion[operation.LatestApiVersion().String()], parameter.PathKey), }) } var err error @@ -151,15 +161,22 @@ func (s Spec) fields(path string, raw jsontext.Value, refs []string) ([]Field, e if resolved.Items != nil { return s.fields(path+"[]", resolved.Items, refs) } + if values := resolved.mapValues(); values != nil { + return s.fields(path+"{}", values, refs) + } var fields []Field for _, property := range resolved.Properties { child, _, err := s.resolve(property.value, refs) if err != nil { return nil, err } + typeName, err := s.typeName(property.value, refs) + if err != nil { + return nil, err + } field := Field{ Path: strings.TrimPrefix(path+"."+property.name, "."), - Type: child.typeName(s.itemsOf(child, refs)), + Type: typeName, Required: slices.Contains(resolved.Required, property.name), Description: child.Description, } @@ -172,51 +189,86 @@ func (s Spec) fields(path string, raw jsontext.Value, refs []string) ([]Field, e return fields, nil } -// resolve follows $ref into the components. A schema it cannot follow keeps its Ref. +// resolve also follows an allOf of one schema, in which the API docs wrap a $ref to describe it. func (s Spec) resolve(raw jsontext.Value, refs []string) (schema, []string, error) { var resolved schema if err := json.Unmarshal(raw, &resolved); err != nil { return resolved, refs, err } - resolvedRef := resolved.Ref - for resolved.Ref != "" && !slices.Contains(refs, resolved.Ref) { - i := slices.IndexFunc(s.components, func(c component) bool { return c.ref() == resolved.Ref }) - if i < 0 { + var resolvedRef string + for { + var next jsontext.Value + switch { + case resolved.Ref != "" && !slices.Contains(refs, resolved.Ref): + i := slices.IndexFunc(s.components, func(c component) bool { return c.ref() == resolved.Ref }) + if i < 0 { + return resolved, refs, nil + } + refs = append(slices.Clip(refs), resolved.Ref) + resolvedRef = resolved.Ref + next = s.components[i].value + case resolved.Ref == "" && len(resolved.AllOf) == 1: + next = resolved.AllOf[0] + default: + resolved.resolvedRef = resolvedRef return resolved, refs, nil } - refs = append(slices.Clip(refs), resolved.Ref) description := resolved.Description resolved = schema{} - if err := json.Unmarshal(s.components[i].value, &resolved); err != nil { + if err := json.Unmarshal(next, &resolved); err != nil { return resolved, refs, err } resolved.Description = cmp.Or(description, resolved.Description) } - resolved.resolvedRef = resolvedRef - return resolved, refs, nil } -func (s Spec) itemsOf(array schema, refs []string) *schema { - if array.Items == nil { - return nil +func (s Spec) typeName(raw jsontext.Value, refs []string) (string, error) { + if raw == nil { + return "", nil } - items, _, err := s.resolve(array.Items, refs) + resolved, refs, err := s.resolve(raw, refs) if err != nil { - return nil + return "", err + } + if variants := slices.Concat(resolved.OneOf, resolved.AnyOf); len(variants) > 0 && resolved.name() == "" { + names := make([]string, len(variants)) + for i, variant := range variants { + if names[i], err = s.typeName(variant, refs); err != nil { + return "", err + } + } + return "one of " + strings.Join(names, ", "), nil + } + if values := resolved.mapValues(); values != nil { + var valueType string + valueType, err = s.typeName(values, refs) + return "map of " + cmp.Or(valueType, "any"), err + } + if resolved.Items == nil { + return resolved.name(), nil } - return &items + items, err := s.typeName(resolved.Items, refs) + return strings.TrimSpace(resolved.name() + " of " + items), err } -func (s schema) typeName(items *schema) string { +func (s schema) name() string { name := s.Type - if _, component, isRef := strings.CutLast(cmp.Or(s.Ref, s.resolvedRef), "/"); isRef { + if _, component, isRef := strings.CutLast(cmp.Or(s.Ref, s.resolvedRef), "/"); isRef && (s.Ref != "" || s.Type == "" || s.Type == "object") { name = component } if s.Format != "" { name += " (" + s.Format + ")" } - if items != nil { - return strings.TrimSpace(name + " of " + items.typeName(nil)) - } return name } + +func (s schema) mapValues() jsontext.Value { + switch s.AdditionalProperties.Kind() { + case jsontext.KindTrue: + return jsontext.Value("{}") + case jsontext.KindBeginObject: + return s.AdditionalProperties + default: + return nil + } +} diff --git a/client/openapi/media_type.go b/client/openapi/media_type.go index 6845798b..0e5d3b16 100644 --- a/client/openapi/media_type.go +++ b/client/openapi/media_type.go @@ -4,12 +4,13 @@ import ( "bytes" "cmp" "encoding/json/jsontext" - "encoding/json/v2" "fmt" "regexp" "slices" "strconv" "strings" + + "github.com/meshcloud/meshstack-cli/internal/json" ) // Only a hal+json type carries a meshObject: application/vnd.meshcloud.api.meshobjects.v1+json is @@ -50,7 +51,7 @@ func ParseMediaType(name string) MediaType { // WithKindAndApiVersion adds the kind and apiVersion a meshObject body lacks, in front, as the // meshObject API writes them. func (m MediaType) WithKindAndApiVersion(body jsontext.Value) (jsontext.Value, error) { - if m.Kind == "" || body.Kind() != '{' { + if m.Kind == "" || body.Kind() != jsontext.KindBeginObject { return body, nil } var members object diff --git a/client/openapi/object.go b/client/openapi/object.go index a6732ff2..d5f199da 100644 --- a/client/openapi/object.go +++ b/client/openapi/object.go @@ -18,11 +18,11 @@ type member struct { func (o *object) UnmarshalJSONFrom(dec *jsontext.Decoder) error { if token, err := dec.ReadToken(); err != nil { return err - } else if token.Kind() != '{' { + } else if token.Kind() != jsontext.KindBeginObject { return fmt.Errorf("expected an object, got %s", token.Kind()) } *o = (*o)[:0] - for dec.PeekKind() != '}' { + for dec.PeekKind() != jsontext.KindEndObject { token, err := dec.ReadToken() if err != nil { return err diff --git a/client/openapi/spec.go b/client/openapi/spec.go index bb1bcd1c..e64c1247 100644 --- a/client/openapi/spec.go +++ b/client/openapi/spec.go @@ -4,13 +4,14 @@ package openapi import ( "encoding/json/jsontext" - "encoding/json/v2" "fmt" "io" "maps" "regexp" "slices" "strings" + + "github.com/meshcloud/meshstack-cli/internal/json" ) // Spec is an OpenAPI document. After Select, it marshals to the selected operations and the @@ -31,7 +32,7 @@ type Operation struct { // Action names the operation within its kind, as a command of the CLI would: list, show, // create, update, delete, or the path below the kind's object, such as trigger-run. Action string - // MediaTypes are those of the request body and of every response, each once. + // An error response carries application/json whatever the operation serves. MediaTypes []MediaType raw jsontext.Value @@ -107,8 +108,10 @@ func parseOperation(method, pathTemplate string, raw jsontext.Value) (Operation, return Operation{}, err } names := slices.Collect(maps.Keys(parsed.RequestBody.Content)) - for _, response := range parsed.Responses { - names = slices.AppendSeq(names, maps.Keys(response.Content)) + for status, response := range parsed.Responses { + if strings.HasPrefix(status, "2") { + names = slices.AppendSeq(names, maps.Keys(response.Content)) + } } slices.Sort(names) operation := Operation{Method: method, PathTemplate: PathTemplate(pathTemplate), raw: raw} @@ -138,8 +141,6 @@ func (o Operation) LatestApiVersion() ApiVersion { return versions[len(versions)-1] } -// LatestMediaType is the one of the latest version, or for an operation of no version the first, -// such as application/json. It is the one to accept, and to send a body in. func (o Operation) LatestMediaType() (MediaType, bool) { if mediaType, ok := o.MediaType(o.LatestApiVersion()); ok { return mediaType, true @@ -187,31 +188,12 @@ func (s Spec) Select(selector Selector) (Spec, error) { if selector.IsZero() { return s, nil } - matched := slices.DeleteFunc(slices.Clone(s.Operations), func(operation Operation) bool { - return !selector.matches(operation) - }) - // The templates of one method that match a path, or of one action of a kind, are versions of one - // operation, because meshStack names a path parameter differently in different versions: - // meshtenants/{tenantIdentifier} in v3 and meshtenants/{uuid} in v4. - operationKey := func(operation Operation) string { - switch { - case selector.Path != "": - return operation.Method - case operation.Kind != "": - return operation.Kind + " " + operation.Method + " " + operation.Action - default: - return operation.Method + " " + string(operation.PathTemplate) - } - } - selected := Spec{components: s.components} - for _, operations := range groupBy(matched, operationKey) { - operations = mostLiteral(operations) - var offered ApiVersions - for _, operation := range operations { - offered = append(offered, operation.ApiVersions()...) + for _, operation := range s.Operations { + if !selector.matches(operation) { + continue } - offered = offered.SortedUnique() + offered := operation.ApiVersions() version := selector.ApiVersion if len(offered) > 0 { if version.IsZero() { @@ -221,45 +203,18 @@ func (s Spec) Select(selector Selector) (Spec, error) { if selector.Path == "" { continue } - return Spec{}, fmt.Errorf("%s %s offers %s, not %s", operations[0].Method, selector.Path, offered, version) + return Spec{}, fmt.Errorf("%s %s offers %s, not %s", operation.Method, selector.Path, offered, version) } - operations = slices.DeleteFunc(operations, func(operation Operation) bool { - return !slices.Contains(operation.ApiVersions(), version) - }) } - for _, operation := range operations { - narrowed, err := operation.withVersion(version) - if err != nil { - return Spec{}, err - } - selected.Operations = append(selected.Operations, narrowed) + narrowed, err := operation.withVersion(version) + if err != nil { + return Spec{}, err } + selected.Operations = append(selected.Operations, narrowed) } return selected, nil } -// mostLiteral keeps the templates that have a literal segment where the others have a parameter. -func mostLiteral(operations []Operation) []Operation { - // The templates all match one path, so they agree on every literal segment, and a parameter - // written as "" sorts below it. - literals := func(operation Operation) []segment { - literal := operation.PathTemplate.segments() - for i, segment := range literal { - if segment.isParameter() { - literal[i] = "" - } - } - return literal - } - compare := func(a, b Operation) int { - return slices.Compare(literals(a), literals(b)) - } - most := slices.MaxFunc(operations, compare) - return slices.DeleteFunc(operations, func(operation Operation) bool { - return compare(operation, most) < 0 - }) -} - func (o Operation) withVersion(version ApiVersion) (Operation, error) { if version.IsZero() { return o, nil @@ -412,7 +367,7 @@ func (s Spec) inline(value jsontext.Value) (jsontext.Value, error) { var ref struct { Ref string `json:"$ref"` } - if value.Kind() != '{' { + if value.Kind() != jsontext.KindBeginObject { return value, nil } if err := json.Unmarshal(value, &ref); err != nil { diff --git a/client/openapi/spec_test.go b/client/openapi/spec_test.go index 9203a66f..bc4a200e 100644 --- a/client/openapi/spec_test.go +++ b/client/openapi/spec_test.go @@ -3,7 +3,6 @@ package openapi_test import ( "bytes" "encoding/json/jsontext" - "encoding/json/v2" "os" "testing" @@ -11,24 +10,20 @@ import ( "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/internal/json" +) + +const ( + runV1 = "application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json" + tenantV3 = "application/vnd.meshcloud.api.meshtenant.v3.hal+json" + tenantV4 = "application/vnd.meshcloud.api.meshtenant.v4.hal+json" ) func TestSpec(t *testing.T) { - document, spec := parseTestSpec(t) - selectOperations := func(t *testing.T, selector openapi.Selector) []string { - t.Helper() - selected, err := spec.Select(selector) - require.NoError(t, err) - var operations []string - for _, operation := range selected.Operations { - var mediaTypes []string - for _, mediaType := range operation.MediaTypes { - mediaTypes = append(mediaTypes, mediaType.Name) - } - operations = append(operations, operation.Method+" "+string(operation.PathTemplate)+" "+jsonString(t, mediaTypes)) - } - return operations - } + document, err := os.ReadFile("testdata/spec.json") + require.NoError(t, err) + spec := parseSpec(t, document) + selectOperations := selectOperationsOf(spec) t.Run("marshals to the document as it came without a selector", func(t *testing.T) { selected, err := spec.Select(openapi.Selector{}) @@ -42,45 +37,34 @@ func TestSpec(t *testing.T) { t.Run("selects the latest version of a path, preview included, unless a version is named", func(t *testing.T) { assert.Equal(t, []string{ - `POST /api/meshobjects/meshbuildingblocks ["application/json","application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json"]`, + `POST /api/meshobjects/meshbuildingblocks ["application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json"]`, }, selectOperations(t, openapi.Selector{Method: "post", Path: "/api/meshobjects/meshbuildingblocks"})) assert.Equal(t, []string{ - `POST /api/meshobjects/meshbuildingblocks ["application/json","application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json"]`, + `POST /api/meshobjects/meshbuildingblocks ["application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json"]`, }, selectOperations(t, openapi.Selector{Method: "POST", Path: "/api/meshobjects/meshbuildingblocks", ApiVersion: openapi.MustParseApiVersion("v1")})) }) - t.Run("treats two templates of one path as versions of one operation", func(t *testing.T) { + t.Run("keeps the media types of one version of an operation that offers several, and fails for another version", func(t *testing.T) { assert.Equal(t, []string{ - `GET /api/meshobjects/meshtenants/{uuid} ["application/vnd.meshcloud.api.meshtenant.v4.hal+json"]`, + `GET /api/meshobjects/meshtenants/{tenant} ["` + tenantV4 + `"]`, }, selectOperations(t, openapi.Selector{Method: "GET", Path: "/api/meshobjects/meshtenants/abc"})) assert.Equal(t, []string{ - `GET /api/meshobjects/meshtenants/{tenantIdentifier} ["application/vnd.meshcloud.api.meshtenant.v3.hal+json"]`, + `GET /api/meshobjects/meshtenants/{tenant} ["` + tenantV3 + `"]`, }, selectOperations(t, openapi.Selector{Method: "GET", Path: "/api/meshobjects/meshtenants/abc", ApiVersion: openapi.MustParseApiVersion("v3")})) _, err := spec.Select(openapi.Selector{Method: "GET", Path: "/api/meshobjects/meshtenants/abc", ApiVersion: openapi.MustParseApiVersion("v5")}) assert.EqualError(t, err, "GET /api/meshobjects/meshtenants/abc offers v3, v4, not v5") }) - t.Run("prefers a literal segment over a parameter of the same method", func(t *testing.T) { - assert.Equal(t, []string{ - `POST /api/meshobjects/meshbuildingblockruns/{blockRunUuid}/status ["application/json"]`, - }, selectOperations(t, openapi.Selector{Method: "POST", Path: "/api/meshobjects/meshbuildingblockruns/abc/status"})) - assert.Equal(t, []string{ - `POST /api/meshobjects/meshbuildingblockruns/create ["application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json"]`, - `GET /api/meshobjects/meshbuildingblockruns/{blockRunUuid} ["application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json"]`, - }, selectOperations(t, openapi.Selector{Path: "/api/meshobjects/meshbuildingblockruns/create"}), - "a GET of .../create reaches the run with the uuid create") - }) - t.Run("selects the latest version of each action of a kind for a method", func(t *testing.T) { assert.Equal(t, []string{ - `GET /api/meshobjects/meshbuildingblockruns/{blockRunUuid} ["application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json"]`, - `GET /api/meshobjects/meshbuildingblockruns/{blockRunUuid}/plan-artifact ["application/octet-stream"]`, - `GET /api/meshobjects/meshtenants/{uuid} ["application/vnd.meshcloud.api.meshtenant.v4.hal+json"]`, + `GET /api/meshobjects/meshbuildingblockruns/{buildingBlockRun} ["` + runV1 + `"]`, + `GET /api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/plan-artifact ["application/octet-stream"]`, + `GET /api/meshobjects/meshtenants/{tenant} ["` + tenantV4 + `"]`, }, selectOperations(t, openapi.Selector{Method: "GET"})) }) - t.Run("accepts the media type of the latest version, or the first of no version", func(t *testing.T) { + t.Run("accepts the media type of the latest version, or the first of no version, and never that of an error response", func(t *testing.T) { selected, err := spec.Select(openapi.Selector{Path: "/api/meshobjects/meshbuildingblocks"}) require.NoError(t, err) latest, ok := selected.Operations[0].LatestMediaType() @@ -88,94 +72,192 @@ func TestSpec(t *testing.T) { assert.Equal(t, "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json", latest.Name) assert.Equal(t, "v2-preview", selected.Operations[0].LatestApiVersion().String()) - selected, err = spec.Select(openapi.Selector{Path: "/api/meshobjects/meshbuildingblockruns/abc/status"}) + selected, err = spec.Select(openapi.Selector{Method: "GET", Path: "/api/meshobjects/meshbuildingblockruns/abc/plan-artifact"}) require.NoError(t, err) latest, ok = selected.Operations[0].LatestMediaType() require.True(t, ok) - assert.Equal(t, "application/json", latest.Name) + assert.Equal(t, "application/octet-stream", latest.Name) assert.Empty(t, selected.Operations[0].LatestApiVersion().String()) + + selected, err = spec.Select(openapi.Selector{Method: "DELETE", Path: "/api/meshobjects/meshtenants/abc"}) + require.NoError(t, err) + _, ok = selected.Operations[0].LatestMediaType() + assert.False(t, ok) }) - t.Run("selects every method of a path, and nothing of an unknown path", func(t *testing.T) { + t.Run("selects every operation whose template matches a path, of any method, and nothing of an unknown path", func(t *testing.T) { assert.Equal(t, []string{ - `GET /api/meshobjects/meshtenants/{uuid} ["application/vnd.meshcloud.api.meshtenant.v4.hal+json"]`, - `DELETE /api/meshobjects/meshtenants/{uuid} []`, + `GET /api/meshobjects/meshtenants/{tenant} ["` + tenantV4 + `"]`, + `DELETE /api/meshobjects/meshtenants/{tenant} []`, }, selectOperations(t, openapi.Selector{Path: "/api/meshobjects/meshtenants/abc/"})) + assert.Equal(t, []string{ + `POST /api/meshobjects/meshbuildingblockruns/create ["` + runV1 + `"]`, + `GET /api/meshobjects/meshbuildingblockruns/{buildingBlockRun} ["` + runV1 + `"]`, + }, selectOperations(t, openapi.Selector{Path: "/api/meshobjects/meshbuildingblockruns/create"}), + "a GET of .../create reaches the run with the uuid create") assert.Empty(t, selectOperations(t, openapi.Selector{Path: "/api/meshobjects/meshunknowns"})) }) t.Run("names the operations of a kind below its path as the commands of the CLI do", func(t *testing.T) { assert.Equal(t, []string{"meshBuildingBlock", "meshBuildingBlockRun", "meshTenant"}, spec.Kinds()) - assert.Equal(t, []string{"create", "show", "status", "plan-artifact"}, spec.Actions("meshBuildingBlockRun"), - "POST .../create is below the path of the kind, which GET .../{blockRunUuid} shows") + assert.Equal(t, []string{"create", "show", "abort", "plan-artifact", "plan-artifact-update"}, spec.Actions("meshBuildingBlockRun"), + "POST .../create is below the path of the kind, which GET .../{buildingBlockRun} shows") assert.Equal(t, []string{ - `GET /api/meshobjects/meshtenants/{uuid} ["application/vnd.meshcloud.api.meshtenant.v4.hal+json"]`, - `DELETE /api/meshobjects/meshtenants/{uuid} []`, + `GET /api/meshobjects/meshtenants/{tenant} ["` + tenantV4 + `"]`, + `DELETE /api/meshobjects/meshtenants/{tenant} []`, }, selectOperations(t, openapi.Selector{Kind: "meshtenant"}), "the latest version of each action") assert.Equal(t, []string{ - `POST /api/meshobjects/meshbuildingblockruns/{blockRunUuid}/status ["application/json"]`, - }, selectOperations(t, openapi.Selector{Kind: "meshBuildingBlockRun", Action: "status"})) + `POST /api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/abort ["` + runV1 + `"]`, + }, selectOperations(t, openapi.Selector{Kind: "meshBuildingBlockRun", Action: "abort"})) + assert.Equal(t, []string{ + `PUT /api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/plan-artifact ["application/octet-stream"]`, + }, selectOperations(t, openapi.Selector{Kind: "meshBuildingBlockRun", Action: "plan-artifact-update"})) + }) + + t.Run("reads the parameters of an operation and of its path, following a $ref, with the path key of the selected version", func(t *testing.T) { + parameters := func(selector openapi.Selector) []openapi.Parameter { + selected, err := spec.Select(selector) + require.NoError(t, err) + require.Len(t, selected.Operations, 1) + doc, err := selected.Doc(selected.Operations[0]) + require.NoError(t, err) + return doc.Parameters + } + assert.Equal(t, []openapi.Parameter{ + {Name: "buildingBlockRun", In: "path", Required: true, Type: "string (uuid)", Description: "The UUID of the meshBuildingBlockRun.", PathKey: "metadata.uuid"}, + }, parameters(openapi.Selector{Method: "GET", Path: "/api/meshobjects/meshbuildingblockruns/abc/plan-artifact"})) + assert.Equal(t, []openapi.Parameter{ + {Name: "tenant", In: "path", Required: true, Type: "string", Description: "DELETE takes the UUID or the full tenant identifier.", PathKey: "metadata.uuid"}, + {Name: "purge", In: "query", Type: "boolean", Description: "Removes the meshTenant from meshStack at once."}, + }, parameters(openapi.Selector{Method: "DELETE", Path: "/api/meshobjects/meshtenants/abc"})) + assert.Equal(t, "metadata.uuid", parameters(openapi.Selector{Method: "GET", Path: "/api/meshobjects/meshtenants/abc"})[0].PathKey) + assert.Equal(t, "metadata.tenantIdentifier", + parameters(openapi.Selector{Method: "GET", Path: "/api/meshobjects/meshtenants/abc", ApiVersion: openapi.MustParseApiVersion("v3")})[0].PathKey) }) selected, selectErr := spec.Select(openapi.Selector{Method: "POST", Path: "/api/meshobjects/meshbuildingblocks", ApiVersion: openapi.MustParseApiVersion("v1")}) require.NoError(t, selectErr) - t.Run("marshals a selection to its operations and the components they reference", func(t *testing.T) { + t.Run("marshals a selection to its operations, with each $ref response in place, and the components they reference", func(t *testing.T) { out, err := json.Marshal(selected) require.NoError(t, err) assert.JSONEq(t, `{ "paths": { "/api/meshobjects/meshbuildingblocks": { "post": { - "summary": "Create a building block", - "description": "Creates a building block.", + "tags": ["Building Blocks"], + "summary": "Create building block", + "description": "Create a new meshBuildingBlock with inputs and parent building block relationships", "operationId": "meshBuildingBlockPost", - "parameters": [{"name": "Accept", "in": "header", "required": true, "schema": {"type": "string"}}], - "requestBody": {"content": {"application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json": {"schema": {"$ref": "#/components/schemas/blockV1"}}}}, + "requestBody": { + "content": { + "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json": { + "schema": {"$ref": "#/components/schemas/MeshBuildingBlockV1Request"}, + "x-meshstack-lifecycle": {"meshObject": "meshBuildingBlock.v1", "state": "GA", "notice": "v2 also supports building blocks on workspace level."} + } + }, + "required": true + }, "responses": { - "201": {"description": "201", "content": {"application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json": {"schema": {"$ref": "#/components/schemas/blockV1"}}}}, - "400": {"description": "400", "content": {"application/json": {"schema": {"type": "object"}}}} - } + "201": { + "description": "Building block created", + "content": {"application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json": {"x-meshstack-lifecycle": {"meshObject": "meshBuildingBlock.v1", "state": "GA"}}} + }, + "400": { + "description": "The request is invalid. The message names the invalid part.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CustomErrorMessage"}}} + } + }, + "x-position": 8 } } }, "components": { "schemas": { - "blockV1": {"type": "object", "properties": {"spec": {"$ref": "#/components/schemas/specV1"}}}, - "specV1": { + "CustomErrorMessage": { + "required": ["errorCode", "message"], + "type": "object", + "properties": {"errorCode": {"type": "string"}, "message": {"type": "string", "description": "A message that describes the error."}}, + "description": "The body of every error response." + }, + "MeshBuildingBlockIOV1": { + "required": ["key"], "type": "object", - "required": ["name"], "properties": { - "name": {"type": "string", "description": "The name."}, - "tags": {"type": "array", "items": {"$ref": "#/components/schemas/tagV1"}}, - "parent": {"$ref": "#/components/schemas/specV1"} + "key": {"type": "string", "description": "The key of the input as set on the related Building Block Definition."}, + "value": {"description": "The value, which is set on this input.", "nullable": true, "oneOf": [{"type": "string"}, {"$ref": "#/components/schemas/integer_int64"}, {"type": "boolean"}, {"$ref": "#/components/schemas/array_string_0"}]} } }, - "tagV1": {"type": "object", "properties": {"key": {"type": "string", "format": "uuid"}}} + "MeshBuildingBlockSpecV1": { + "required": ["displayName", "parentBuildingBlocks"], + "type": "object", + "properties": { + "displayName": {"type": "string", "description": "The display name of the Building Block."}, + "parentBuildingBlocks": {"description": "The parent building blocks.", "allOf": [{"$ref": "#/components/schemas/array_MeshParentBuildingBlock"}]}, + "inputs": {"description": "List of inputs, which are set on the Building Block.", "allOf": [{"$ref": "#/components/schemas/array_MeshBuildingBlockIOV1"}]} + } + }, + "MeshBuildingBlockV1Request": { + "required": ["kind", "spec"], + "type": "object", + "properties": { + "kind": {"type": "string", "enum": ["meshBuildingBlock"]}, + "apiVersion": {"type": "string", "enum": ["v1"]}, + "spec": {"description": "All fields in this section describe the Building Block.", "allOf": [{"$ref": "#/components/schemas/MeshBuildingBlockSpecV1"}]} + } + }, + "MeshParentBuildingBlock": { + "required": ["buildingBlockUuid"], + "type": "object", + "properties": {"buildingBlockUuid": {"description": "The UUID of the parent Building Block.", "allOf": [{"$ref": "#/components/schemas/string_uuid"}]}} + }, + "array_MeshBuildingBlockIOV1": {"type": "array", "items": {"$ref": "#/components/schemas/MeshBuildingBlockIOV1"}}, + "array_MeshParentBuildingBlock": {"type": "array", "items": {"$ref": "#/components/schemas/MeshParentBuildingBlock"}}, + "array_string_0": {"type": "array", "items": {"type": "string"}}, + "integer_int64": {"type": "integer", "format": "int64"}, + "string_uuid": {"type": "string", "format": "uuid"} } } }`, string(out)) }) - t.Run("reads an operation's parameters and the fields of its bodies, naming a referenced schema, up to a schema that contains itself", func(t *testing.T) { + t.Run("reads the fields of an operation's bodies, naming a referenced schema and each variant of a oneOf", func(t *testing.T) { doc, err := selected.Doc(selected.Operations[0]) require.NoError(t, err) - assert.Equal(t, "Create a building block", doc.Summary) - assert.Equal(t, []openapi.Parameter{{Name: "Accept", In: "header", Required: true, Type: "string"}}, doc.Parameters) - fields := []openapi.Field{ - {Path: "spec", Type: "specV1"}, - {Path: "spec.name", Type: "string", Required: true, Description: "The name."}, - {Path: "spec.tags", Type: "array of tagV1"}, - {Path: "spec.tags[].key", Type: "string (uuid)"}, - {Path: "spec.parent", Type: "specV1"}, - } - assert.Equal(t, []openapi.Body{{MediaType: "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json", Fields: fields}}, doc.RequestBodies) + assert.Equal(t, "Create building block", doc.Summary) + assert.Empty(t, doc.Parameters) + assert.Equal(t, []openapi.Body{{MediaType: "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json", Fields: []openapi.Field{ + {Path: "kind", Type: "string", Required: true}, + {Path: "apiVersion", Type: "string"}, + {Path: "spec", Type: "MeshBuildingBlockSpecV1", Required: true, Description: "All fields in this section describe the Building Block."}, + {Path: "spec.displayName", Type: "string", Required: true, Description: "The display name of the Building Block."}, + {Path: "spec.parentBuildingBlocks", Type: "array of MeshParentBuildingBlock", Required: true, Description: "The parent building blocks."}, + {Path: "spec.parentBuildingBlocks[].buildingBlockUuid", Type: "string (uuid)", Required: true, Description: "The UUID of the parent Building Block."}, + {Path: "spec.inputs", Type: "array of MeshBuildingBlockIOV1", Description: "List of inputs, which are set on the Building Block."}, + {Path: "spec.inputs[].key", Type: "string", Required: true, Description: "The key of the input as set on the related Building Block Definition."}, + {Path: "spec.inputs[].value", Type: "one of string, integer (int64), boolean, array of string", Description: "The value, which is set on this input."}, + }}}, doc.RequestBodies) assert.Equal(t, []openapi.Response{ - {Status: "201", Description: "201", Bodies: []openapi.Body{{MediaType: "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json", Fields: fields}}}, - {Status: "400", Description: "400", Bodies: []openapi.Body{{MediaType: "application/json"}}}, + {Status: "201", Description: "Building block created", Bodies: []openapi.Body{{MediaType: "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json"}}}, + {Status: "400", Description: "The request is invalid. The message names the invalid part.", Bodies: []openapi.Body{{MediaType: "application/json", Fields: []openapi.Field{ + {Path: "errorCode", Type: "string", Required: true}, + {Path: "message", Type: "string", Required: true, Description: "A message that describes the error."}, + }}}}, }, doc.Responses) }) + t.Run("reads the fields of a map's values below the map", func(t *testing.T) { + v2Preview, err := spec.Select(openapi.Selector{Method: "POST", Path: "/api/meshobjects/meshbuildingblocks"}) + require.NoError(t, err) + doc, err := v2Preview.Doc(v2Preview.Operations[0]) + require.NoError(t, err) + assert.Equal(t, []openapi.Field{ + {Path: "spec.inputs", Type: "map of MeshBuildingBlockInput", Required: true, Description: "Map of input keys to their input data."}, + {Path: "spec.inputs{}.value", Type: "one of string, integer (int64), boolean, array of string", Description: "The value of this input."}, + {Path: "spec.inputs{}.isOptional", Type: "boolean", Required: true, Description: "Whether the Building Block Definition allows this input to be left unset."}, + }, doc.RequestBodies[0].Fields[3:]) + }) + t.Run("adds the kind and apiVersion a body lacks, with the kind in camel case", func(t *testing.T) { mediaType, ok := selected.Operations[0].MediaType(openapi.MustParseApiVersion("v1")) require.True(t, ok) @@ -189,6 +271,29 @@ func TestSpec(t *testing.T) { }) } +func TestDocOfASchemaThatContainsItself(t *testing.T) { + spec := parseSpec(t, []byte(`{ + "openapi": "3.0.1", + "paths": { + "/api/nodes": { + "get": {"responses": {"200": {"description": "200", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/node"}}}}}} + } + }, + "components": { + "schemas": { + "node": {"type": "object", "properties": {"name": {"type": "string"}, "parent": {"allOf": [{"$ref": "#/components/schemas/node"}]}, "children": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/node"}}}} + } + } + }`)) + doc, err := spec.Doc(spec.Operations[0]) + require.NoError(t, err) + assert.Equal(t, []openapi.Response{{Status: "200", Description: "200", Bodies: []openapi.Body{{MediaType: "application/json", Fields: []openapi.Field{ + {Path: "name", Type: "string"}, + {Path: "parent", Type: "node"}, + {Path: "children", Type: "map of node"}, + }}}}}, doc.Responses) +} + func TestApiVersion(t *testing.T) { versions := []string{"v1", "v2-preview", "v2", "v10"} for i := range len(versions) - 1 { @@ -200,13 +305,28 @@ func TestApiVersion(t *testing.T) { } } -func parseTestSpec(t *testing.T) ([]byte, openapi.Spec) { +func parseSpec(t *testing.T, document []byte) openapi.Spec { t.Helper() - document, err := os.ReadFile("testdata/spec.json") - require.NoError(t, err) spec, err := openapi.Parse(bytes.NewReader(document)) require.NoError(t, err) - return document, spec + return spec +} + +func selectOperationsOf(spec openapi.Spec) func(t *testing.T, selector openapi.Selector) []string { + return func(t *testing.T, selector openapi.Selector) []string { + t.Helper() + selected, err := spec.Select(selector) + require.NoError(t, err) + var operations []string + for _, operation := range selected.Operations { + var mediaTypes []string + for _, mediaType := range operation.MediaTypes { + mediaTypes = append(mediaTypes, mediaType.Name) + } + operations = append(operations, operation.Method+" "+string(operation.PathTemplate)+" "+jsonString(t, mediaTypes)) + } + return operations + } } func jsonString(t *testing.T, value any) string { diff --git a/client/openapi/testdata/spec.json b/client/openapi/testdata/spec.json index ff85c116..2e7ebc5f 100644 --- a/client/openapi/testdata/spec.json +++ b/client/openapi/testdata/spec.json @@ -1,91 +1,230 @@ { "openapi": "3.0.1", + "info": {"title": "meshStack API documentation", "version": "1.0.0"}, + "x-meshstack-retired-endpoints": [ + {"method": "GET", "path": "/api/meshobjects/meshcustomers", "mediaType": "application/vnd.meshcloud.api.meshcustomer.v1.hal+json", "meshObject": "meshCustomer.v1", "state": "Retired", "retiredAfter": "2025-03-01", "notice": "Use `meshWorkspace`."} + ], + "x-meshstack-retired-meshobjects": [ + {"meshObject": "meshCustomer.v1", "state": "Retired", "retiredAfter": "2025-03-01", "notice": "Use `meshWorkspace`."} + ], "paths": { - "/api/meshobjects/meshbuildingblocks": { - "post": { - "summary": "Create a building block", - "description": "Creates a building block.", - "operationId": "meshBuildingBlockPost", - "parameters": [{"name": "Accept", "in": "header", "required": true, "schema": {"type": "string"}}], - "requestBody": { - "content": { - "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json": {"schema": {"$ref": "#/components/schemas/blockV1"}}, - "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json": {"schema": {"$ref": "#/components/schemas/blockV2"}} - } - }, - "responses": { - "201": { - "description": "201", - "content": { - "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json": {"schema": {"$ref": "#/components/schemas/blockV1"}}, - "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json": {"schema": {"$ref": "#/components/schemas/blockV2"}} - } - }, - "400": {"description": "400", "content": {"application/json": {"schema": {"type": "object"}}}} - } - } - }, "/api/meshobjects/meshbuildingblockruns/create": { "post": { + "tags": ["Building Block Runs"], + "summary": "Fetch a pending building block run", "operationId": "meshBuildingBlockRunCreate", - "responses": {"201": {"description": "201", "content": {"application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json": {}}}} + "parameters": [{"name": "forRunnerUuid", "in": "query", "required": true, "schema": {"$ref": "#/components/schemas/string_uuid"}}], + "responses": { + "201": { + "description": "The pending building block run, now assigned to the runner", + "content": {"application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json": {"x-meshstack-lifecycle": {"meshObject": "meshBuildingBlockRun.v1", "state": "GA"}}} + } + }, + "x-position": 25 } }, - "/api/meshobjects/meshbuildingblockruns/{blockRunUuid}": { + "/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}": { "get": { - "operationId": "meshBuildingBlockRun", - "responses": {"200": {"description": "200", "content": {"application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json": {}}}} + "tags": ["Building Block Runs"], + "summary": "Get building block run", + "operationId": "meshBuildingBlockRunGet", + "parameters": [{"$ref": "#/components/parameters/buildingBlockRun_0"}], + "responses": { + "200": { + "description": "The building block run", + "content": {"application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json": {"x-meshstack-lifecycle": {"meshObject": "meshBuildingBlockRun.v1", "state": "GA"}}} + } + }, + "x-position": 24 } }, - "/api/meshobjects/meshbuildingblockruns/{blockRunUuid}/status": { + "/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/abort": { "post": { - "operationId": "meshBuildingBlockRunStatus", - "responses": {"200": {"description": "200", "content": {"application/json": {}}}} + "tags": ["Building Block Runs"], + "summary": "Abort building block run", + "operationId": "meshBuildingBlockRunAbort", + "parameters": [{"$ref": "#/components/parameters/buildingBlockRun_0"}], + "responses": { + "202": { + "description": "Abort accepted", + "content": {"application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json": {"x-meshstack-lifecycle": {"meshObject": "meshBuildingBlockRun.v1", "state": "GA"}}} + } + }, + "x-position": 26 } }, - "/api/meshobjects/meshbuildingblockruns/{blockRunUuid}/plan-artifact": { + "/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/plan-artifact": { + "parameters": [{"$ref": "#/components/parameters/buildingBlockRun_0"}], "get": { - "operationId": "meshBuildingBlockRunPlanArtifact", - "responses": {"200": {"description": "200", "content": {"application/octet-stream": {}}}} + "tags": ["Building Block Runs"], + "summary": "Download plan artifact of building block run (deprecated)", + "operationId": "meshBuildingBlockRunDownloadPlanArtifact", + "responses": { + "200": { + "description": "The predecessor artifact of the building block run", + "content": {"application/octet-stream": {"schema": {"$ref": "#/components/schemas/string_binary"}, "x-meshstack-lifecycle": {"state": "Deprecated", "retiredAfter": "2027-01-01"}}} + }, + "400": {"$ref": "#/components/responses/BadRequest"} + }, + "deprecated": true, + "x-position": 31 + }, + "put": { + "tags": ["Building Block Runs"], + "summary": "Upload plan artifact of building block run (deprecated)", + "operationId": "meshBuildingBlockRunUploadPlanArtifact", + "requestBody": {"content": {"application/octet-stream": {"schema": {"$ref": "#/components/schemas/string_binary"}}}, "required": true}, + "responses": {"204": {"description": "No Content"}}, + "deprecated": true, + "x-position": 32 } }, - "/api/meshobjects/meshbuildingblockruns/{blockRunUuid}/{part}": { + "/api/meshobjects/meshbuildingblocks": { "post": { - "operationId": "meshBuildingBlockRunPart", - "responses": {"200": {"description": "200"}} + "tags": ["Building Blocks"], + "summary": "Create building block", + "description": "Create a new meshBuildingBlock with inputs and parent building block relationships", + "operationId": "meshBuildingBlockPost", + "requestBody": { + "content": { + "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json": { + "schema": {"$ref": "#/components/schemas/MeshBuildingBlockV2Request"}, + "examples": {"meshWorkspace": {"$ref": "#/components/examples/MeshBuildingBlockV2Request-meshWorkspace"}}, + "x-meshstack-lifecycle": {"meshObject": "meshBuildingBlock.v2-preview", "state": "Preview"} + }, + "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json": { + "schema": {"$ref": "#/components/schemas/MeshBuildingBlockV1Request"}, + "x-meshstack-lifecycle": {"meshObject": "meshBuildingBlock.v1", "state": "GA", "notice": "v2 also supports building blocks on workspace level."} + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Building block created", + "content": { + "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json": {"x-meshstack-lifecycle": {"meshObject": "meshBuildingBlock.v2-preview", "state": "Preview"}}, + "application/vnd.meshcloud.api.meshbuildingblock.v1.hal+json": {"x-meshstack-lifecycle": {"meshObject": "meshBuildingBlock.v1", "state": "GA"}} + } + }, + "400": {"$ref": "#/components/responses/BadRequest"} + }, + "x-position": 8 } }, - "/api/meshobjects/meshtenants/{tenantIdentifier}": { + "/api/meshobjects/meshtenants/{tenant}": { "get": { - "operationId": "meshTenantV3", - "responses": {"200": {"description": "200", "content": {"application/vnd.meshcloud.api.meshtenant.v3.hal+json": {}}}} - } - }, - "/api/meshobjects/meshtenants/{uuid}": { - "delete": { - "operationId": "meshTenantDeleteV4", - "responses": {"202": {"description": "202"}} + "tags": ["Tenants"], + "summary": "Get tenant", + "operationId": "meshTenantGet", + "parameters": [{"name": "tenant", "in": "path", "required": true, "schema": {"type": "string"}, "x-meshstack-path-key": "metadata.uuid", "x-meshstack-path-key-by-version": {"v3": "metadata.tenantIdentifier"}}], + "responses": { + "200": { + "description": "The tenant", + "content": { + "application/vnd.meshcloud.api.meshtenant.v4.hal+json": {"x-meshstack-lifecycle": {"meshObject": "meshTenant.v4", "state": "GA"}}, + "application/vnd.meshcloud.api.meshtenant.v3.hal+json": {"x-meshstack-lifecycle": {"meshObject": "meshTenant.v3", "state": "Outdated", "latest": "meshTenant.v4"}} + } + } + }, + "x-position": 110 }, - "get": { - "operationId": "meshTenantV4", - "responses": {"200": {"description": "200", "content": {"application/vnd.meshcloud.api.meshtenant.v4.hal+json": {}}}} + "delete": { + "tags": ["Tenants"], + "summary": "Delete tenant", + "operationId": "meshTenantDelete", + "parameters": [ + {"name": "tenant", "in": "path", "description": "DELETE takes the UUID or the full tenant identifier.", "required": true, "schema": {"type": "string"}, "x-meshstack-path-key": "metadata.uuid"}, + {"name": "purge", "in": "query", "description": "Removes the meshTenant from meshStack at once.", "required": false, "schema": {"type": "boolean"}} + ], + "responses": { + "202": {"description": "Deletion of tenant started"}, + "400": {"$ref": "#/components/responses/BadRequest"} + }, + "x-position": 112 } } }, "components": { "schemas": { - "blockV1": {"type": "object", "properties": {"spec": {"$ref": "#/components/schemas/specV1"}}}, - "specV1": { + "CustomErrorMessage": { + "required": ["errorCode", "message"], + "type": "object", + "properties": {"errorCode": {"type": "string"}, "message": {"type": "string", "description": "A message that describes the error."}}, + "description": "The body of every error response." + }, + "MeshBuildingBlockIOV1": { + "required": ["key"], + "type": "object", + "properties": { + "key": {"type": "string", "description": "The key of the input as set on the related Building Block Definition."}, + "value": {"description": "The value, which is set on this input.", "nullable": true, "oneOf": [{"type": "string"}, {"$ref": "#/components/schemas/integer_int64"}, {"type": "boolean"}, {"$ref": "#/components/schemas/array_string_0"}]} + } + }, + "MeshBuildingBlockSpecV1": { + "required": ["displayName", "parentBuildingBlocks"], "type": "object", - "required": ["name"], "properties": { - "name": {"type": "string", "description": "The name."}, - "tags": {"type": "array", "items": {"$ref": "#/components/schemas/tagV1"}}, - "parent": {"$ref": "#/components/schemas/specV1"} + "displayName": {"type": "string", "description": "The display name of the Building Block."}, + "parentBuildingBlocks": {"description": "The parent building blocks.", "allOf": [{"$ref": "#/components/schemas/array_MeshParentBuildingBlock"}]}, + "inputs": {"description": "List of inputs, which are set on the Building Block.", "allOf": [{"$ref": "#/components/schemas/array_MeshBuildingBlockIOV1"}]} } }, - "tagV1": {"type": "object", "properties": {"key": {"type": "string", "format": "uuid"}}}, - "blockV2": {"type": "object"} + "MeshBuildingBlockV1Request": { + "required": ["kind", "spec"], + "type": "object", + "properties": { + "kind": {"type": "string", "enum": ["meshBuildingBlock"]}, + "apiVersion": {"type": "string", "enum": ["v1"]}, + "spec": {"description": "All fields in this section describe the Building Block.", "allOf": [{"$ref": "#/components/schemas/MeshBuildingBlockSpecV1"}]} + } + }, + "MeshBuildingBlockV2Request": { + "required": ["kind", "spec"], + "type": "object", + "properties": { + "kind": {"type": "string", "enum": ["meshBuildingBlock"]}, + "apiVersion": {"type": "string", "enum": ["v2-preview"]}, + "spec": {"description": "All fields in this section describe the Building Block.", "allOf": [{"$ref": "#/components/schemas/MeshBuildingBlockSpecV2"}]} + } + }, + "MeshBuildingBlockSpecV2": { + "required": ["inputs"], + "type": "object", + "properties": {"inputs": {"description": "Map of input keys to their input data.", "allOf": [{"$ref": "#/components/schemas/MapOfMeshBuildingBlockInput"}]}} + }, + "MapOfMeshBuildingBlockInput": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/MeshBuildingBlockInput"}}, + "MeshBuildingBlockInput": { + "required": ["isOptional"], + "type": "object", + "properties": { + "value": {"description": "The value of this input.", "nullable": true, "oneOf": [{"type": "string"}, {"$ref": "#/components/schemas/integer_int64"}, {"type": "boolean"}, {"$ref": "#/components/schemas/array_string_0"}]}, + "isOptional": {"type": "boolean", "description": "Whether the Building Block Definition allows this input to be left unset.", "readOnly": true} + } + }, + "MeshParentBuildingBlock": { + "required": ["buildingBlockUuid"], + "type": "object", + "properties": {"buildingBlockUuid": {"description": "The UUID of the parent Building Block.", "allOf": [{"$ref": "#/components/schemas/string_uuid"}]}} + }, + "array_MeshBuildingBlockIOV1": {"type": "array", "items": {"$ref": "#/components/schemas/MeshBuildingBlockIOV1"}}, + "array_MeshParentBuildingBlock": {"type": "array", "items": {"$ref": "#/components/schemas/MeshParentBuildingBlock"}}, + "array_string_0": {"type": "array", "items": {"type": "string"}}, + "integer_int64": {"type": "integer", "format": "int64"}, + "string_binary": {"type": "string", "format": "binary"}, + "string_uuid": {"type": "string", "format": "uuid"} + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. The message names the invalid part.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CustomErrorMessage"}}} + } + }, + "parameters": { + "buildingBlockRun_0": {"name": "buildingBlockRun", "in": "path", "description": "The UUID of the meshBuildingBlockRun.", "required": true, "schema": {"type": "string", "format": "uuid"}, "x-meshstack-path-key": "metadata.uuid"} + }, + "examples": { + "MeshBuildingBlockV2Request-meshWorkspace": {"summary": "`spec.targetRef.kind` is `meshWorkspace`.", "value": {"kind": "meshBuildingBlock", "apiVersion": "v2-preview"}} } } } diff --git a/client/payment_method.go b/client/payment_method.go index 05f9f3ab..3daa731e 100644 --- a/client/payment_method.go +++ b/client/payment_method.go @@ -1,11 +1,5 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" -) - type MeshPaymentMethod struct { Metadata MeshPaymentMethodMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshPaymentMethodSpec `json:"spec" tfsdk:"spec"` @@ -14,8 +8,8 @@ type MeshPaymentMethod struct { 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"` + CreatedOn string `json:"createdOn,omitzero" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn,omitzero" tfsdk:"deleted_on"` } type MeshPaymentMethodSpec struct { @@ -25,43 +19,8 @@ type MeshPaymentMethodSpec struct { Tags map[string][]string `json:"tags,omitempty" tfsdk:"tags"` } -type MeshPaymentMethodCreate struct { - 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"` -} - -type MeshPaymentMethodClient interface { - 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 -} - -type meshPaymentMethodClient struct { - meshObject internal.MeshObjectClient[MeshPaymentMethod] -} - -func newPaymentMethodClient(ctx context.Context, httpClient internal.HttpClient) MeshPaymentMethodClient { - return meshPaymentMethodClient{internal.NewMeshObjectClient[MeshPaymentMethod](ctx, httpClient, "v2")} -} - -func (c meshPaymentMethodClient) Read(ctx context.Context, identifier string) (*MeshPaymentMethod, error) { - return c.meshObject.Get(ctx, identifier) -} - -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) { - return c.meshObject.Put(ctx, identifier, paymentMethod) -} +type MeshPaymentMethodClient = MeshObjectClient[MeshPaymentMethod, MeshObjectName, NoFilter] -func (c meshPaymentMethodClient) Delete(ctx context.Context, identifier string) error { - return c.meshObject.Delete(ctx, identifier) +func (paymentMethod MeshPaymentMethod) meshObjectId() MeshObjectName { + return MeshObjectName(paymentMethod.Metadata.Name) } diff --git a/client/platform.go b/client/platform.go index 3cfd38c5..969c7f54 100644 --- a/client/platform.go +++ b/client/platform.go @@ -1,11 +1,7 @@ package client import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" "github.com/meshcloud/meshstack-cli/client/types" - "github.com/meshcloud/meshstack-cli/internal/http" ) type MeshPlatform struct { @@ -77,7 +73,7 @@ type TagMapper struct { ValuePattern string `json:"valuePattern" tfsdk:"value_pattern"` } -type MeshPlatformListQuery struct { +type MeshPlatformFilter struct { OwnedByWorkspace *string `json:"ownedByWorkspace"` Identifier *string `json:"identifier"` LocationIdentifier *string `json:"locationIdentifier"` @@ -90,38 +86,8 @@ type MeshPlatformListQuery struct { 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 -} - -type meshPlatformClient struct { - meshObject internal.MeshObjectClient[MeshPlatform] -} - -func newPlatformClient(ctx context.Context, httpClient internal.HttpClient) MeshPlatformClient { - return meshPlatformClient{internal.NewMeshObjectClient[MeshPlatform](ctx, httpClient, "v2")} -} - -func (c meshPlatformClient) Read(ctx context.Context, uuid string) (*MeshPlatform, error) { - return c.meshObject.Get(ctx, uuid) -} - -func (c meshPlatformClient) List(ctx context.Context, query MeshPlatformListQuery) ([]MeshPlatform, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(query)) -} - -func (c meshPlatformClient) Create(ctx context.Context, platform MeshPlatform) (*MeshPlatform, error) { - return c.meshObject.Post(ctx, platform) -} - -func (c meshPlatformClient) Update(ctx context.Context, uuid string, platform MeshPlatform) (*MeshPlatform, error) { - return c.meshObject.Put(ctx, uuid, platform) -} +type MeshPlatformClient = MeshObjectClient[MeshPlatform, string, MeshPlatformFilter] -func (c meshPlatformClient) Delete(ctx context.Context, uuid string) error { - return c.meshObject.Delete(ctx, uuid) +func (platform MeshPlatform) meshObjectId() string { + return *platform.Metadata.Uuid } diff --git a/client/platform_type.go b/client/platform_type.go index 69cb3bfe..fa1c98f3 100644 --- a/client/platform_type.go +++ b/client/platform_type.go @@ -1,16 +1,9 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) - type MeshPlatformType struct { Metadata MeshPlatformTypeMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` - Status MeshPlatformTypeStatus `json:"status" tfsdk:"status"` + Status MeshPlatformTypeStatus `json:"status,omitzero" tfsdk:"status"` } type MeshPlatformTypeStatus struct { @@ -34,56 +27,13 @@ type MeshPlatformTypeSpec struct { Icon string `json:"icon" tfsdk:"icon"` } -type MeshPlatformTypeCreate struct { - Metadata MeshPlatformTypeCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshPlatformTypeSpec `json:"spec" tfsdk:"spec"` -} - -type MeshPlatformTypeCreateMetadata struct { - Name string `json:"name" tfsdk:"name"` - OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` -} - -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")} -} - -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) { - return c.meshObject.Put(ctx, name, platformType) -} - -func (c meshPlatformTypeClient) Delete(ctx context.Context, name string) error { - return c.meshObject.Delete(ctx, name) -} - -type meshPlatformTypeListQuery struct { +type MeshPlatformTypeFilter struct { Category *string `json:"category"` LifecycleStatus *string `json:"lifecycleStatus"` } -func (c meshPlatformTypeClient) List(ctx context.Context, category *string, lifecycleStatus *string) ([]MeshPlatformType, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(meshPlatformTypeListQuery{ - Category: category, - LifecycleStatus: lifecycleStatus, - })) +type MeshPlatformTypeClient = MeshObjectClient[MeshPlatformType, MeshObjectName, MeshPlatformTypeFilter] + +func (platformType MeshPlatformType) meshObjectId() MeshObjectName { + return MeshObjectName(platformType.Metadata.Name) } diff --git a/client/project.go b/client/project.go index 5da6d19b..2d6eea57 100644 --- a/client/project.go +++ b/client/project.go @@ -1,12 +1,5 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) - type MeshProject struct { Metadata MeshProjectMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshProjectSpec `json:"spec" tfsdk:"spec"` @@ -15,8 +8,8 @@ type MeshProject struct { 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"` + CreatedOn string `json:"createdOn,omitzero" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn,omitzero" tfsdk:"deleted_on"` } type MeshProjectSpec struct { @@ -26,60 +19,24 @@ type MeshProjectSpec struct { 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"` -} - -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] -} +// MeshProjectId is the workspace and the name of a project, which the API joins with a dot. +type MeshProjectId string -func newProjectClient(ctx context.Context, httpClient internal.HttpClient) MeshProjectClient { - return meshProjectClient{internal.NewMeshObjectClient[MeshProject](ctx, httpClient, "v2")} +func NewMeshProjectId(workspace, name string) MeshProjectId { + return MeshProjectId(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 (id MeshProjectId) String() string { + return string(id) } -type meshProjectListQuery struct { +type MeshProjectFilter struct { WorkspaceIdentifier string `json:"workspaceIdentifier"` PaymentIdentifier *string `json:"paymentIdentifier"` } -func (c meshProjectClient) List(ctx context.Context, workspaceIdentifier string, paymentMethodIdentifier *string) ([]MeshProject, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(meshProjectListQuery{ - WorkspaceIdentifier: workspaceIdentifier, - PaymentIdentifier: paymentMethodIdentifier, - })) -} - -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) { - 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 { - return c.meshObject.Delete(ctx, c.projectId(workspace, name)) -} +type MeshProjectClient = MeshObjectClient[MeshProject, MeshProjectId, MeshProjectFilter] -func (c meshProjectClient) projectId(workspace string, name string) string { - return workspace + "." + name +func (project MeshProject) meshObjectId() MeshProjectId { + return NewMeshProjectId(project.Metadata.OwnedByWorkspace, project.Metadata.Name) } diff --git a/client/project_binding.go b/client/project_binding.go index 57f91d39..306b31ce 100644 --- a/client/project_binding.go +++ b/client/project_binding.go @@ -1,15 +1,11 @@ package client type MeshProjectBinding struct { - 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"` - ExpiryDate *string `json:"expiryDate,omitzero" tfsdk:"expiry_date"` -} - -type MeshProjectBindingMetadata struct { - Name string `json:"name" tfsdk:"name"` + Metadata MeshObjectByNameMetadata `json:"metadata" tfsdk:"metadata"` + RoleRef MeshProjectRoleRef `json:"roleRef" tfsdk:"role_ref"` + TargetRef MeshProjectTargetRef `json:"targetRef" tfsdk:"target_ref"` + Subject MeshSubject `json:"subject" tfsdk:"subject"` + ExpiryDate *string `json:"expiryDate,omitzero" tfsdk:"expiry_date"` } // MeshProjectRoleRef names a role by its name alone. @@ -29,7 +25,11 @@ type MeshSubject struct { Name string `json:"name" tfsdk:"name"` } -type MeshProjectBindingListFilter struct { +type MeshProjectBindingFilter struct { WorkspaceIdentifier *string `json:"workspaceIdentifier"` ProjectIdentifier *string `json:"projectIdentifier"` } + +func (binding MeshProjectBinding) meshObjectId() MeshObjectName { + return binding.Metadata.meshObjectId() +} diff --git a/client/project_group_binding.go b/client/project_group_binding.go index f0f2d274..caa396d3 100644 --- a/client/project_group_binding.go +++ b/client/project_group_binding.go @@ -1,43 +1,11 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) - type MeshProjectGroupBinding struct { MeshProjectBinding } -type MeshProjectGroupBindingClient interface { - Read(ctx context.Context, name string) (*MeshProjectGroupBinding, error) - List(ctx context.Context, filter MeshProjectBindingListFilter) ([]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")} -} - -func (c meshProjectGroupBindingClient) Read(ctx context.Context, name string) (*MeshProjectGroupBinding, error) { - return c.meshObject.Get(ctx, name) -} - -func (c meshProjectGroupBindingClient) List(ctx context.Context, filter MeshProjectBindingListFilter) ([]MeshProjectGroupBinding, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(filter)) -} - -func (c meshProjectGroupBindingClient) Create(ctx context.Context, binding *MeshProjectGroupBinding) (*MeshProjectGroupBinding, error) { - return c.meshObject.Post(ctx, binding) -} +type MeshProjectGroupBindingClient = MeshObjectClient[MeshProjectGroupBinding, MeshObjectName, MeshProjectBindingFilter] -func (c meshProjectGroupBindingClient) Delete(ctx context.Context, name string) error { - return c.meshObject.Delete(ctx, name) +func (MeshProjectGroupBinding) meshObjectPath() string { + return "meshprojectbindings/groupbindings" } diff --git a/client/project_user_binding.go b/client/project_user_binding.go index bf132171..c11f9a84 100644 --- a/client/project_user_binding.go +++ b/client/project_user_binding.go @@ -1,12 +1,5 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) - type MeshProjectUserBinding struct { MeshProjectBinding @@ -17,33 +10,8 @@ type MeshUserBindingStatus struct { UserDisabled bool `json:"userDisabled" tfsdk:"user_disabled"` } -type MeshProjectUserBindingClient interface { - Read(ctx context.Context, name string) (*MeshProjectUserBinding, error) - List(ctx context.Context, filter MeshProjectBindingListFilter) ([]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")} -} - -func (c meshProjectUserBindingClient) Read(ctx context.Context, name string) (*MeshProjectUserBinding, error) { - return c.meshObject.Get(ctx, name) -} - -func (c meshProjectUserBindingClient) List(ctx context.Context, filter MeshProjectBindingListFilter) ([]MeshProjectUserBinding, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(filter)) -} - -func (c meshProjectUserBindingClient) Create(ctx context.Context, binding *MeshProjectUserBinding) (*MeshProjectUserBinding, error) { - return c.meshObject.Post(ctx, binding) -} +type MeshProjectUserBindingClient = MeshObjectClient[MeshProjectUserBinding, MeshObjectName, MeshProjectBindingFilter] -func (c meshProjectUserBindingClient) Delete(ctx context.Context, name string) error { - return c.meshObject.Delete(ctx, name) +func (MeshProjectUserBinding) meshObjectPath() string { + return "meshprojectbindings/userbindings" } diff --git a/client/raw.go b/client/raw.go deleted file mode 100644 index 17bdd4c5..00000000 --- a/client/raw.go +++ /dev/null @@ -1,98 +0,0 @@ -package client - -import ( - "context" - "encoding/json/jsontext" - "fmt" - "iter" - "net/url" - "reflect" - "time" - "uuid" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) - -type ListOptions = internal.ListOptions - -type Page = internal.Page - -// SortCriteria holds one `property,(asc|desc)` criterion per element, such as createdAt,desc. -type SortCriteria []string - -// RawClient answers with the JSON meshStack sent, for the CLI, which writes it out as it came. It -// is the CLI's alone: DoRequest takes the options of internal/http, which Go's internal rule closes -// to the Terraform provider. -type RawClient struct { - httpClient internal.HttpClient - apis map[reflect.Type]rawApi -} - -type rawApi struct { - internal.MeshObjectApi - - sortsByCreatedAt bool -} - -func newRawClient(httpClient internal.HttpClient) *RawClient { - return &RawClient{httpClient: httpClient, apis: map[reflect.Type]rawApi{}} -} - -// List pages through the objects of M, narrowed by filter, a struct whose fields are the query -// parameters of the listing. -func (r *RawClient) List[M any](ctx context.Context, filter any, options ListOptions) iter.Seq2[jsontext.Value, error] { - api, err := r.api[M]() - if err != nil { - return func(yield func(jsontext.Value, error) bool) { - yield(nil, err) - } - } - // Newest first, an object created while the pages are read would land on a page already read - // and push another one onto the next page, to be listed twice. The fixed until leaves it out; an - // endpoint without until ignores the parameter. The filter's own sort and until win. - newestFirst := url.Values{"until": {time.Now().UTC().Format(time.RFC3339Nano)}} - if api.sortsByCreatedAt { - newestFirst.Set("sort", "createdAt,desc") - } - return api.ListSeqAs[jsontext.Value](ctx, options, http.WithUrlQuery(newestFirst), http.WithUrlQuery(filter)) -} - -// Get reads the object of M with the uuid, or the sub-resource that pathElems name below it. -func (r *RawClient) Get[M any](ctx context.Context, uuid uuid.UUID, pathElems ...string) (jsontext.Value, error) { - api, err := r.api[M]() - if err != nil { - return nil, err - } - return api.DoRequest[jsontext.Value](ctx, http.MethodGet, api.ApiUrl.JoinPath(uuid.String()).JoinPath(pathElems...), - http.WithAccept(api.MeshObjectMimeType())) -} - -// DoRequest joins path onto the endpoint, even one that names a host, so that the bearer token -// never leaves for another host. The answer is of any content type, such as a plan artifact's -// application/octet-stream, so it is returned unparsed. -func (r *RawClient) DoRequest(ctx context.Context, method, path string, opts ...http.RequestOption) ([]byte, error) { - return r.httpClient.DoRequest[[]byte](ctx, method, r.httpClient.EndpointUrl.JoinPath(path), opts...) -} - -// with makes List and Get reach the objects of M through the API version of meshObject, so that -// the version stays declared once, in the constructor of M's typed client. -func (r *RawClient) with[M any](meshObject internal.MeshObjectClient[M]) *RawClient { - r.apis[reflect.TypeFor[M]()] = rawApi{MeshObjectApi: meshObject.MeshObjectApi, sortsByCreatedAt: true} - return r -} - -// withOwnOrder is with for a listing that answers 400 to any sort key, as that of building block -// definition versions does, which orders by version number. -func (r *RawClient) withOwnOrder[M any](meshObject internal.MeshObjectClient[M]) *RawClient { - r.apis[reflect.TypeFor[M]()] = rawApi{MeshObjectApi: meshObject.MeshObjectApi} - return r -} - -func (r *RawClient) api[M any]() (rawApi, error) { - api, ok := r.apis[reflect.TypeFor[M]()] - if !ok { - return api, fmt.Errorf("the raw client has no API for %s: add its typed client in client.New", reflect.TypeFor[M]()) - } - return api, nil -} diff --git a/client/raw_test.go b/client/raw_test.go deleted file mode 100644 index 8f8f4a4c..00000000 --- a/client/raw_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package client - -import ( - "encoding/json/jsontext" - "fmt" - "io" - gohttp "net/http" - "slices" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "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/testutil/fakemeshstack" -) - -func newTestHttpClient(server *fakemeshstack.Server) internal.HttpClient { - return internal.HttpClient{ - AuthorizedClient: http.NewClient(fakemeshstack.UserAgent).WithAuthorization(http.BearerToken(fakemeshstack.Token)), - EndpointUrl: xurl.MustParsef("%s", server.URL), - } -} - -func TestRawClient(t *testing.T) { - const serverPageSize = 2 - var workspaces []string - for i := range 5 { - workspaces = append(workspaces, fmt.Sprintf(`{"kind":"meshWorkspace","apiVersion":"v2","metadata":{"name":"workspace-%d"},`+ - `"_links":{"self":{"href":"http://localhost:8080/api/meshobjects/meshworkspaces/workspace-%d"}}}`, i, i)) - } - var items []any - for _, workspace := range workspaces { - items = append(items, jsontext.Value(workspace)) - } - server := fakemeshstack.Start(t, fakemeshstack.Options{Workspaces: items, PageSize: serverPageSize}) - httpClient := newTestHttpClient(server) - raw := newRawClient(httpClient).with(newWorkspaceClient(t.Context(), httpClient).meshObject) - var requests []fakemeshstack.Request - list := func(t *testing.T, filter MeshWorkspaceListFilter, options ListOptions) (listed []string) { - t.Helper() - server.TakeRequests() - for item, err := range raw.List[MeshWorkspace](t.Context(), filter, options) { - require.NoError(t, err) - listed = append(listed, string(item)) - } - requests = server.TakeRequests() - return listed - } - asked := func(parameter string) (values []string) { - for _, r := range requests { - values = append(values, strings.Join(r.URL.Query()[parameter], " ")) - } - return values - } - - before := time.Now() - // Decoding into MeshWorkspace and encoding again would drop what the model lacks: apiVersion and - // kind, which a POST of the edited object needs, and _links, which shows an LLM agent how - // meshObjects relate. - t.Run("a list yields every item of every page as meshStack sent it", func(t *testing.T) { - assert.Equal(t, workspaces, list(t, MeshWorkspaceListFilter{}, ListOptions{})) - }) - - t.Run("the list asks for newest first, and every page ends at the instant the list started", func(t *testing.T) { - assert.Equal(t, slices.Repeat([]string{"createdAt,desc"}, 3), asked("sort")) - untils := asked("until") - assert.Equal(t, slices.Repeat(untils[:1], 3), untils) - until, err := time.Parse(time.RFC3339Nano, untils[0]) - require.NoError(t, err) - assert.WithinRange(t, until, before, time.Now()) - }) - - // The Terraform provider sets no list options, and must get no page size it did not ask for. - t.Run("without list options a page carries no size", func(t *testing.T) { - assert.Equal(t, []string{"", "", ""}, asked("size")) - }) - - t.Run("a page size asks for pages of that size, and takes the smaller ones meshStack caps it to", func(t *testing.T) { - assert.Equal(t, workspaces, list(t, MeshWorkspaceListFilter{}, ListOptions{PageSize: 3})) - assert.Equal(t, []string{"3", "3", "3"}, asked("size")) - }) - - t.Run("the sort of a filter replaces newest first", func(t *testing.T) { - list(t, MeshWorkspaceListFilter{Sort: []string{"createdAt,desc", "name,asc"}}, ListOptions{}) - assert.Equal(t, slices.Repeat([]string{"createdAt,desc name,asc"}, 3), asked("sort")) - }) - - t.Run("a kind without a typed client fails before it asks meshStack", func(t *testing.T) { - server.TakeRequests() - var failed error - for _, err := range raw.List[MeshTenant](t.Context(), nil, ListOptions{}) { - failed = err - } - require.ErrorContains(t, failed, "client.MeshTenant") - assert.Empty(t, server.TakeRequests()) - }) - - t.Run("DoRequest joins any path onto the endpoint, so the token goes nowhere else", func(t *testing.T) { - server.Route("/", func(w gohttp.ResponseWriter, _ *gohttp.Request) { _, _ = io.WriteString(w, `{}`) }) - server.TakeRequests() - for _, path := range []string{"https://other.host/api/x", "//other.host/api/x"} { - _, err := raw.DoRequest(t.Context(), gohttp.MethodGet, path) - require.NoError(t, err) - } - requests := server.TakeRequests() - require.Len(t, requests, 2) - for _, r := range requests { - assert.Equal(t, httpClient.EndpointUrl.Host, r.Host) - } - }) -} diff --git a/client/service_instance.go b/client/service_instance.go index 3b111413..7bdd1df4 100644 --- a/client/service_instance.go +++ b/client/service_instance.go @@ -1,11 +1,7 @@ package client import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" "github.com/meshcloud/meshstack-cli/client/types" - "github.com/meshcloud/meshstack-cli/internal/http" ) type MeshServiceInstance struct { @@ -28,13 +24,10 @@ type MeshServiceInstanceSpec struct { Parameters map[string]types.Any `json:"parameters" tfsdk:"parameters"` } -type MeshServiceInstanceClient interface { - Read(ctx context.Context, instanceId string) (*MeshServiceInstance, error) - List(ctx context.Context, filter MeshServiceInstanceFilter) ([]MeshServiceInstance, error) -} +type MeshServiceInstanceClient = MeshObjectClient[MeshServiceInstance, MeshObjectName, MeshServiceInstanceFilter] -type meshServiceInstanceClient struct { - meshObject internal.MeshObjectClient[MeshServiceInstance] +func (instance MeshServiceInstance) meshObjectId() MeshObjectName { + return MeshObjectName(instance.Metadata.InstanceId) } type MeshServiceInstanceFilter struct { @@ -44,15 +37,3 @@ type MeshServiceInstanceFilter struct { ServiceIdentifier *string `json:"serviceIdentifier"` PlanIdentifier *string `json:"planIdentifier"` } - -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) { - return c.meshObject.List(ctx, http.WithUrlQuery(filter)) -} diff --git a/client/tag_definition.go b/client/tag_definition.go index 0e002266..942d26c1 100644 --- a/client/tag_definition.go +++ b/client/tag_definition.go @@ -1,18 +1,14 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" -) +type MeshTagDefinitionMetadata = MeshObjectByNameMetadata type MeshTagDefinition struct { Metadata MeshTagDefinitionMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshTagDefinitionSpec `json:"spec" tfsdk:"spec"` } -type MeshTagDefinitionMetadata struct { - Name string `json:"name" tfsdk:"name"` +func (d MeshTagDefinition) meshObjectId() MeshObjectName { + return d.Metadata.meshObjectId() } type MeshTagDefinitionSpec struct { @@ -65,38 +61,4 @@ type TagValueMultiSelect struct { DefaultValue *[]string `json:"defaultValue,omitzero" tfsdk:"default_value"` } -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")} -} - -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) { - return c.meshObject.Get(ctx, name) -} - -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) { - return c.meshObject.Put(ctx, tagDefinition.Metadata.Name, tagDefinition) -} - -func (c meshTagDefinitionClient) Delete(ctx context.Context, name string) error { - return c.meshObject.Delete(ctx, name) -} +type MeshTagDefinitionClient = MeshObjectClient[MeshTagDefinition, MeshObjectName, NoFilter] diff --git a/client/tenant_v4.go b/client/tenant_v4.go index 696723c4..d08fd459 100644 --- a/client/tenant_v4.go +++ b/client/tenant_v4.go @@ -1,13 +1,10 @@ package client import ( - "context" "errors" "fmt" - "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 @@ -28,16 +25,17 @@ type MeshTenantLifecycleAction struct { Timestamp string `json:"timestamp" tfsdk:"-"` } +//nolint:recvcheck // a nil tenant is one that is gone, and meshObjectId reads one that is there type MeshTenant struct { Metadata MeshTenantMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshTenantSpec `json:"spec" tfsdk:"spec"` - Status MeshTenantStatus `json:"status" tfsdk:"status"` + Status MeshTenantStatus `json:"status,omitzero" 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"` + Uuid *string `json:"uuid,omitzero" tfsdk:"uuid"` + OwnedByProject string `json:"ownedByProject" tfsdk:"owned_by_project"` + OwnedByWorkspace string `json:"ownedByWorkspace" tfsdk:"owned_by_workspace"` } type MeshTenantSpec struct { @@ -46,7 +44,7 @@ type MeshTenantSpec struct { LandingZoneRef *NamedRef `json:"landingZoneRef" tfsdk:"landing_zone_ref"` // RequestedQuotas is an input at creation only: the backend does not return it on read, so the // Terraform resource keeps the configured value from its state. - RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas" tfsdk:"requested_quotas"` + RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas,omitempty" tfsdk:"requested_quotas"` } type MeshTenantStatus struct { @@ -82,24 +80,7 @@ type AppliedQuotaValue struct { 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"` - RequestedQuotas map[string]RequestQuotaValue `json:"requestedQuotas,omitempty" tfsdk:"requested_quotas"` -} - -type MeshTenantQuery struct { +type MeshTenantFilter struct { Workspace string `json:"workspaceIdentifier"` Project *string `json:"projectIdentifier"` Platform *string `json:"platformIdentifier"` @@ -108,43 +89,7 @@ type MeshTenantQuery struct { 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) - 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")} -} - -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) { - return c.meshObject.List(ctx, http.WithUrlQuery(query)) -} - -func (c meshTenantClient) Delete(ctx context.Context, uuid string) error { - return c.meshObject.Delete(ctx, uuid) -} +type MeshTenantClient = MeshObjectClient[MeshTenant, string, MeshTenantFilter] func (tenant *MeshTenant) CreationSuccessful() (done bool, err error) { switch { @@ -167,6 +112,10 @@ func (tenant *MeshTenant) DeletionState() string { return tenant.Status.Lifecycle.deletionState() } +func (tenant MeshTenant) meshObjectId() string { + return *tenant.Metadata.Uuid +} + const tenantNotObserved = "no successful read after the delete request" func (lifecycle MeshTenantLifecycle) deletionState() string { diff --git a/client/tenant_v4_deletion_test.go b/client/tenant_v4_deletion_test.go index 0bbd0907..173adde8 100644 --- a/client/tenant_v4_deletion_test.go +++ b/client/tenant_v4_deletion_test.go @@ -23,7 +23,6 @@ func TestMeshTenantDeletion(t *testing.T) { {"a tenant marked for deletion is not deleted yet, and says since when", &MeshTenant{Status: MeshTenantStatus{Lifecycle: markedForDeletion}}, false, "MARKED_FOR_DELETION since 2026-07-30T16:14:14Z"}, {"a tenant marked for deletion at no time is not deleted yet, and says what it awaits", &MeshTenant{Status: MeshTenantStatus{Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateMarkedForDeletion}}}, false, "MARKED_FOR_DELETION, awaiting"}, {"an active tenant is not deleted, as meshStack has not acted on it", &MeshTenant{Status: MeshTenantStatus{Lifecycle: MeshTenantLifecycle{State: TenantLifecycleStateActive}}}, false, "has not acted on it"}, - {"a tenant without a lifecycle is not deleted, as meshStack has not acted on it", &MeshTenant{Metadata: MeshTenantMetadata{Uuid: "test-uuid"}}, false, "has not acted on it"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/client/types/clienttypes.go b/client/types/clienttypes.go index 43331ec7..508ace65 100644 --- a/client/types/clienttypes.go +++ b/client/types/clienttypes.go @@ -4,7 +4,7 @@ import ( "reflect" "strings" - "github.com/meshcloud/meshstack-cli/client/types/variant" + "github.com/meshcloud/meshstack-cli/client/types/polymorph" ) type ( @@ -17,7 +17,7 @@ type ( Hash *string `json:"hash,omitzero" tfsdk:"-"` } - SecretOrAny = variant.Variant[Secret, any] + SecretOrAny = polymorph.Variant[Secret, any] Any any ) diff --git a/client/types/hal/link.go b/client/types/hal/link.go new file mode 100644 index 00000000..4136cc97 --- /dev/null +++ b/client/types/hal/link.go @@ -0,0 +1,57 @@ +package hal + +import ( + "context" + "encoding/json/jsontext" + "errors" + "fmt" + "io" + + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/internal/json" +) + +var ErrNoLink = errors.New("meshStack sent no link") + +type Links map[string]Link + +type Link struct { + Href string `json:"href"` + + action LinkAction +} + +type LinkAction func(ctx context.Context, method, href string, options ...http.RequestOption) (io.ReadCloser, error) + +func NewLink(href string, action LinkAction) Link { + return Link{Href: href, action: action} +} + +func LinkUnmarshalers(action LinkAction) *json.Unmarshalers { + return json.UnmarshalFromFunc(func(_ *jsontext.Decoder, l *Link) error { + l.action = action + return errors.ErrUnsupported + }) +} + +func (ls Links) Get[R any](ctx context.Context, rel string, options ...http.RequestOption) (R, error) { + return follow[R](ctx, ls, http.MethodGet, rel, options) +} + +func (ls Links) Post[R any](ctx context.Context, rel string, payload any, options ...http.RequestOption) (R, error) { + return follow[R](ctx, ls, http.MethodPost, rel, append(options, http.WithJsonBody(payload))) +} + +func (ls Links) Delete(ctx context.Context, rel string, options ...http.RequestOption) error { + _, err := follow[struct{}](ctx, ls, http.MethodDelete, rel, options) + return err +} + +func follow[R any](ctx context.Context, ls Links, method, rel string, options []http.RequestOption) (result R, _ error) { + link, found := ls[rel] + if !found || link.action == nil { + return result, fmt.Errorf("%w %q", ErrNoLink, rel) + } + body, err := link.action(ctx, method, link.Href, options...) + return http.UnmarshalJsonBody[R](body, err, LinkUnmarshalers(link.action)) +} diff --git a/client/types/hal/link_test.go b/client/types/hal/link_test.go new file mode 100644 index 00000000..3ec18199 --- /dev/null +++ b/client/types/hal/link_test.go @@ -0,0 +1,34 @@ +package hal_test + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/client/types/hal" +) + +func TestLinksFollowALinkAndGiveTheLinksOfTheAnswerTheSameAction(t *testing.T) { + var followed []string + action := func(_ context.Context, method, href string, _ ...http.RequestOption) (io.ReadCloser, error) { + followed = append(followed, method+" "+href) + return io.NopCloser(strings.NewReader(`{"_links": {"next": {"href": "/second"}}}`)), nil + } + type page struct { + Links hal.Links `json:"_links"` + } + links := hal.Links{"first": hal.NewLink("/first", action)} + + answer, err := links.Get[page](t.Context(), "first") + require.NoError(t, err) + _, err = answer.Links.Post[page](t.Context(), "next", nil) + require.NoError(t, err) + assert.Equal(t, []string{"GET /first", "POST /second"}, followed) + + require.ErrorIs(t, links.Delete(t.Context(), "missing"), hal.ErrNoLink) +} diff --git a/client/types/page.go b/client/types/page.go new file mode 100644 index 00000000..51ea8d98 --- /dev/null +++ b/client/types/page.go @@ -0,0 +1,8 @@ +package types + +type Page struct { + Size int `json:"size"` + TotalElements int `json:"totalElements"` + TotalPages int `json:"totalPages"` + Number int `json:"number"` +} diff --git a/client/types/polymorph/polymorph.go b/client/types/polymorph/polymorph.go new file mode 100644 index 00000000..331923eb --- /dev/null +++ b/client/types/polymorph/polymorph.go @@ -0,0 +1,195 @@ +// Package polymorph reads and writes a struct as Jackson's +// @JsonTypeInfo(use = NAME, include = PROPERTY, property = "type") does. The struct embeds Polymorph +// first and tags one pointer per subtype polymorph:"", with ,inline for fields next to "type". +package polymorph + +import ( + "bytes" + "encoding/json/jsontext" + "errors" + "fmt" + "reflect" + "strings" + "unsafe" + + "github.com/meshcloud/meshstack-cli/client/types/enum" + "github.com/meshcloud/meshstack-cli/internal/json" +) + +type Polymorph[T any, E ~string] struct { + Type enum.Entry[E] `tfsdk:"-"` +} + +func (p *Polymorph[T, E]) MarshalJSONTo(enc *jsontext.Encoder) error { + typeName := p.Type + if typeName == "" { + var err error + if typeName, err = p.InferType(); err != nil { + return err + } + } + return p.outer().marshal(enc, string(typeName)) +} + +func (p *Polymorph[T, E]) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + typeName, err := p.outer().unmarshal(dec) + p.Type = enum.Entry[E](typeName) + return err +} + +func (p *Polymorph[T, E]) InferType() (enum.Entry[E], error) { + typeName, err := p.outer().inferType() + return enum.Entry[E](typeName), err +} + +// outer is the T that embeds p. As the first field, p has the address of T. +func (p *Polymorph[T, E]) outer() polymorphic { + t := reflect.TypeFor[T]() + if t.Kind() != reflect.Struct || t.NumField() == 0 || t.Field(0).Type != reflect.TypeFor[Polymorph[T, E]]() { + panic(fmt.Sprintf("%s needs polymorph.Polymorph as its first field", t)) + } + return newPolymorphic(reflect.ValueOf((*T)(unsafe.Pointer(p))).Elem()) //nolint:gosec // the check above makes p the address of T +} + +type polymorphic struct { + value reflect.Value + subtypes []subtype +} + +type subtype struct { + index int + name string + member string +} + +func newPolymorphic(value reflect.Value) polymorphic { + p := polymorphic{value: value} + for i := range value.NumField() { + field := value.Type().Field(i) + tag, tagged := field.Tag.Lookup("polymorph") + if !tagged { + continue + } + name, inline := strings.CutSuffix(tag, ",inline") + s := subtype{index: i, name: name} + if !inline { + s.member, _, _ = strings.Cut(field.Tag.Get("json"), ",") + } + p.subtypes = append(p.subtypes, s) + } + if len(p.subtypes) == 0 { + panic(fmt.Sprintf("%s needs a field tagged polymorph", value.Type())) + } + return p +} + +func (p polymorphic) marshal(enc *jsontext.Encoder, typeName string) error { + if err := enc.WriteToken(jsontext.BeginObject); err != nil { + return err + } + if err := errors.Join(enc.WriteToken(jsontext.String("type")), enc.WriteToken(jsontext.String(typeName))); err != nil { + return err + } + for _, s := range p.subtypes { + subtype := p.value.Field(s.index) + if subtype.IsNil() { + continue + } + if err := s.marshal(enc, subtype.Interface()); err != nil { + return err + } + } + return enc.WriteToken(jsontext.EndObject) +} + +func (p polymorphic) unmarshal(dec *jsontext.Decoder) (typeName string, _ error) { + raw, err := dec.ReadValue() + if err != nil { + return "", err + } + p.value.SetZero() + if raw.Kind() == jsontext.KindNull { + return "", nil + } + var members map[string]jsontext.Value + if err = json.Unmarshal(raw, &members, dec.Options()); err != nil { + return "", err + } + if encodedType, ok := members["type"]; ok { + if err = json.Unmarshal(encodedType, &typeName); err != nil { + return "", err + } + } + for _, s := range p.subtypes { + if s.name != typeName { + continue + } + field := p.value.Field(s.index) + subtype := reflect.New(field.Type().Elem()) + encoded := raw + if s.member != "" { + var sent bool + if encoded, sent = members[s.member]; !sent && subtype.Elem().NumField() > 0 { + return typeName, nil + } + } + if len(encoded) > 0 { + if err = json.Unmarshal(encoded, subtype.Interface(), dec.Options()); err != nil { + return typeName, fmt.Errorf("%s: %w", typeName, err) + } + } + field.Set(subtype) + } + return typeName, nil +} + +func (p polymorphic) inferType() (string, error) { + var result string + for _, s := range p.subtypes { + if p.value.Field(s.index).IsNil() { + continue + } + if result != "" { + return "", fmt.Errorf("%s: more than one subtype is set: %s and %s", p.value.Type(), result, s.name) + } + result = s.name + } + if result == "" { + return "", fmt.Errorf("%s: no subtype is set", p.value.Type()) + } + return result, nil +} + +func (s subtype) marshal(enc *jsontext.Encoder, subtype any) error { + if s.member != "" { + if err := enc.WriteToken(jsontext.String(s.member)); err != nil { + return err + } + return json.MarshalEncode(enc, subtype, enc.Options()) + } + encoded, err := json.Marshal(subtype, enc.Options()) + if err != nil { + return err + } + fields := jsontext.NewDecoder(bytes.NewReader(encoded)) + if _, err = fields.ReadToken(); err != nil { + return err + } + for fields.PeekKind() != jsontext.KindEndObject { + name, err := fields.ReadToken() + if err != nil { + return err + } + if err = enc.WriteToken(name); err != nil { + return err + } + fieldValue, err := fields.ReadValue() + if err != nil { + return err + } + if err = enc.WriteValue(fieldValue); err != nil { + return err + } + } + return nil +} diff --git a/client/types/polymorph/polymorph_test.go b/client/types/polymorph/polymorph_test.go new file mode 100644 index 00000000..fe109199 --- /dev/null +++ b/client/types/polymorph/polymorph_test.go @@ -0,0 +1,61 @@ +package polymorph_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/types/polymorph" + "github.com/meshcloud/meshstack-cli/internal/json" +) + +type circle struct{} + +type square struct { + Side int `json:"side"` +} + +type flat struct { + polymorph.Polymorph[flat, string] + + Circle *circle `polymorph:"circle,inline"` + Square *square `polymorph:"square,inline"` +} + +type nested struct { + polymorph.Polymorph[nested, string] + + Circle *circle `json:"circle" polymorph:"circle"` + Square *square `json:"square" polymorph:"square"` +} + +func TestPolymorph(t *testing.T) { + t.Run("an inline subtype sits next to the type", func(t *testing.T) { + encoded, err := json.Marshal(flat{Square: &square{Side: 2}}) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"square","side":2}`, string(encoded)) + + var decoded flat + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, flat{Type: "square", Square: &square{Side: 2}}, decoded) + }) + + t.Run("a nested subtype sits in its member, and one without fields needs none", func(t *testing.T) { + encoded, err := json.Marshal(nested{Square: &square{Side: 2}}) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"square","square":{"side":2}}`, string(encoded)) + + var decoded nested + require.NoError(t, json.Unmarshal([]byte(`{"type":"circle"}`), &decoded)) + assert.Equal(t, nested{Type: "circle", Circle: &circle{}}, decoded) + require.NoError(t, json.Unmarshal([]byte(`{"type":"square"}`), &decoded)) + assert.Equal(t, nested{Type: "square"}, decoded, "meshStack leaves out the subtype for a reader that may not see it") + }) + + t.Run("an unknown type decodes to the type alone", func(t *testing.T) { + var decoded flat + require.NoError(t, json.Unmarshal([]byte(`{"type":"triangle","corners":3}`), &decoded)) + assert.Equal(t, flat{Type: "triangle"}, decoded) + }) +} diff --git a/client/types/variant/variant.go b/client/types/polymorph/variant.go similarity index 88% rename from client/types/variant/variant.go rename to client/types/polymorph/variant.go index 127acb34..4c85c091 100644 --- a/client/types/variant/variant.go +++ b/client/types/polymorph/variant.go @@ -1,11 +1,12 @@ -package variant +package polymorph import ( "encoding/json/jsontext" - "encoding/json/v2" "errors" "fmt" "reflect" + + "github.com/meshcloud/meshstack-cli/internal/json" ) // A Variant decodes one JSON value into X or into Y. When both decode, X wins, so Y can be a type @@ -48,22 +49,6 @@ func (v Variant[X, Y]) HasY() bool { return has[Y](v.Y) } -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]) UnmarshalJSONFrom(dec *jsontext.Decoder) error { bytes, err := dec.ReadValue() if err != nil { diff --git a/client/types/variant/infer.go b/client/types/variant/infer.go deleted file mode 100644 index cf20c631..00000000 --- a/client/types/variant/infer.go +++ /dev/null @@ -1,35 +0,0 @@ -package variant - -import ( - "errors" - "fmt" - - "github.com/meshcloud/meshstack-cli/client/types/enum" -) - -type Candidate[T ~string] struct { - Type enum.Entry[T] - IsSet bool -} - -func NewCandidate[T ~string](typ enum.Entry[T], isSet bool) Candidate[T] { - return Candidate[T]{Type: typ, IsSet: isSet} -} - -// InferType returns the type of the one candidate that is set. -func InferType[T ~string](candidates ...Candidate[T]) (enum.Entry[T], error) { - var result enum.Entry[T] - for _, candidate := range candidates { - if !candidate.IsSet { - continue - } - if len(result) > 0 { - return "", fmt.Errorf("more than one variant is set: %s and %s", result, candidate.Type) - } - result = candidate.Type - } - if len(result) == 0 { - return "", errors.New("no variant is set") - } - return result, nil -} diff --git a/client/types/variant/infer_test.go b/client/types/variant/infer_test.go deleted file mode 100644 index 8a937419..00000000 --- a/client/types/variant/infer_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package variant - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/meshcloud/meshstack-cli/client/types/enum" -) - -type shape string - -const ( - circle enum.Entry[shape] = "circle" - square enum.Entry[shape] = "square" -) - -type shapeHolder struct { - Circle *struct{} - Square *struct{ Side int } -} - -func (s shapeHolder) inferType() (enum.Entry[shape], error) { - return InferType( - NewCandidate(circle, s.Circle != nil), - NewCandidate(square, s.Square != nil), - ) -} - -func TestInferType(t *testing.T) { - t.Run("an empty struct counts as set", func(t *testing.T) { - got, err := shapeHolder{Circle: &struct{}{}}.inferType() - require.NoError(t, err) - assert.Equal(t, circle, got) - }) - - t.Run("one variant", func(t *testing.T) { - got, err := shapeHolder{Square: &struct{ Side int }{Side: 2}}.inferType() - require.NoError(t, err) - assert.Equal(t, square, got) - }) - - t.Run("no variant", func(t *testing.T) { - _, err := shapeHolder{}.inferType() - require.EqualError(t, err, "no variant is set") - }) - - t.Run("several variants", func(t *testing.T) { - _, err := shapeHolder{Circle: &struct{}{}, Square: &struct{ Side int }{}}.inferType() - require.EqualError(t, err, "more than one variant is set: circle and square") - }) -} diff --git a/internal/version/version.go b/client/types/version/version.go similarity index 100% rename from internal/version/version.go rename to client/types/version/version.go diff --git a/internal/version/version_test.go b/client/types/version/version_test.go similarity index 98% rename from internal/version/version_test.go rename to client/types/version/version_test.go index 9ec81afc..0ec1952b 100644 --- a/internal/version/version_test.go +++ b/client/types/version/version_test.go @@ -1,12 +1,13 @@ package version import ( - "encoding/json/v2" "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/json" ) func TestParse(t *testing.T) { diff --git a/client/types/xurl/url.go b/client/types/xurl/url.go index a1cc1a52..b890cb2b 100644 --- a/client/types/xurl/url.go +++ b/client/types/xurl/url.go @@ -62,20 +62,6 @@ func (u URL) Clone() URL { return URL{u.URL.Clone()} } -// PathTo is the path of target below u, and false where u does not hold target. A near match, such -// as https://x.io/ab for https://x.io/a, is false as well, because a caller sends u's credential to -// target. -func (u URL) PathTo(target *url.URL) (string, bool) { - if u.URL == nil || target == nil || !strings.EqualFold(target.Scheme, u.Scheme) || !strings.EqualFold(target.Host, u.Host) { - return "", false - } - path, found := strings.CutPrefix(target.Path, strings.TrimSuffix(u.Path, "/")) - if !found || path != "" && !strings.HasPrefix(path, "/") { - return "", false - } - return path, true -} - func (u URL) isLoopback() bool { hostname := u.Hostname() if hostname == "localhost" { diff --git a/client/uuid_filter_test.go b/client/uuid_filter_test.go deleted file mode 100644 index 806c7680..00000000 --- a/client/uuid_filter_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package client - -import ( - "testing" - "uuid" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" -) - -func TestAUuidOfAFilterGoesAsItsTextAndAZeroOneNotAtAll(t *testing.T) { - server := fakemeshstack.Start(t, fakemeshstack.Options{}) - httpClient := newTestHttpClient(server) - raw := newRawClient(httpClient).with(newBuildingBlockRunClient(t.Context(), httpClient).meshObject) - - for _, filter := range []MeshBuildingBlockRunListFilter{ - {BuildingBlockUuid: uuid.MustParse("0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90")}, - {}, - } { - for _, err := range raw.List[MeshBuildingBlockRun](t.Context(), filter, ListOptions{}) { - require.NoError(t, err) - } - } - - requests := server.TakeRequests() - require.Len(t, requests, 2) - assert.Equal(t, []string{"0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90"}, requests[0].URL.Query()["buildingBlockUuid"]) - assert.NotContains(t, requests[1].URL.Query(), "buildingBlockUuid") -} - -func TestListingTheVersionsOfADefinitionRejectsAnIdOfNoUuidBeforeItAsksMeshStack(t *testing.T) { - server := fakemeshstack.Start(t, fakemeshstack.Options{}) - versions := newBuildingBlockDefinitionVersionClient(t.Context(), newTestHttpClient(server)) - - _, err := versions.List(t.Context(), "my-definition") - - require.EqualError(t, err, `building block definition "my-definition": invalid uuid`) - assert.Empty(t, server.TakeRequests()) -} diff --git a/client/workspace.go b/client/workspace.go index 85830420..302584a3 100644 --- a/client/workspace.go +++ b/client/workspace.go @@ -1,20 +1,17 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" -) +import "github.com/meshcloud/meshstack-cli/client/types/hal" type MeshWorkspace struct { Metadata MeshWorkspaceMetadata `json:"metadata" tfsdk:"metadata"` Spec MeshWorkspaceSpec `json:"spec" tfsdk:"spec"` + Links hal.Links `json:"_links,omitzero" tfsdk:"-"` } type MeshWorkspaceMetadata struct { Name string `json:"name" tfsdk:"name"` - CreatedOn string `json:"createdOn" tfsdk:"created_on"` - DeletedOn *string `json:"deletedOn" tfsdk:"deleted_on"` + CreatedOn string `json:"createdOn,omitzero" tfsdk:"created_on"` + DeletedOn *string `json:"deletedOn,omitzero" tfsdk:"deleted_on"` Tags map[string][]string `json:"tags" tfsdk:"tags"` } @@ -23,53 +20,12 @@ type MeshWorkspaceSpec struct { PlatformBuilderAccessEnabled *bool `json:"platformBuilderAccessEnabled,omitzero" tfsdk:"platform_builder_access_enabled"` } -type MeshWorkspaceCreate struct { - 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 MeshWorkspaceListFilter struct { +type MeshWorkspaceFilter struct { Sort SortCriteria `json:"sort"` } -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 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) - 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 (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) -} - -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) { - return c.meshObject.Put(ctx, name, workspace) -} +type MeshWorkspaceClient = MeshObjectClient[MeshWorkspace, MeshObjectName, MeshWorkspaceFilter] -func (c meshWorkspaceClient) Delete(ctx context.Context, name string) error { - return c.meshObject.Delete(ctx, name) +func (workspace MeshWorkspace) meshObjectId() MeshObjectName { + return MeshObjectName(workspace.Metadata.Name) } diff --git a/client/workspace_binding.go b/client/workspace_binding.go index 8d2892be..6c0b5301 100644 --- a/client/workspace_binding.go +++ b/client/workspace_binding.go @@ -1,15 +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"` - ExpiryDate *string `json:"expiryDate,omitzero" tfsdk:"expiry_date"` -} - -type MeshWorkspaceBindingMetadata struct { - Name string `json:"name" tfsdk:"name"` + Metadata MeshObjectByNameMetadata `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,omitzero" tfsdk:"expiry_date"` } type MeshWorkspaceRoleRef struct { @@ -24,9 +20,13 @@ type MeshWorkspaceSubject struct { Name string `json:"name" tfsdk:"name"` } -type MeshWorkspaceBindingListFilter struct { +type MeshWorkspaceBindingFilter struct { WorkspaceIdentifier *string `json:"workspaceIdentifier"` WorkspaceRole *string `json:"workspaceRole"` // Username matches a user binding of that user, or a group binding of a group the user is in. Username *string `json:"username"` } + +func (binding MeshWorkspaceBinding) meshObjectId() MeshObjectName { + return binding.Metadata.meshObjectId() +} diff --git a/client/workspace_group_binding.go b/client/workspace_group_binding.go index 4c4da9c3..513f69ff 100644 --- a/client/workspace_group_binding.go +++ b/client/workspace_group_binding.go @@ -1,43 +1,11 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) - type MeshWorkspaceGroupBinding struct { MeshWorkspaceBinding } -type MeshWorkspaceGroupBindingClient interface { - Read(ctx context.Context, name string) (*MeshWorkspaceGroupBinding, error) - List(ctx context.Context, filter MeshWorkspaceBindingListFilter) ([]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")} -} - -func (c meshWorkspaceGroupBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceGroupBinding, error) { - return c.meshObject.Get(ctx, name) -} - -func (c meshWorkspaceGroupBindingClient) List(ctx context.Context, filter MeshWorkspaceBindingListFilter) ([]MeshWorkspaceGroupBinding, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(filter)) -} - -func (c meshWorkspaceGroupBindingClient) Create(ctx context.Context, binding *MeshWorkspaceGroupBinding) (*MeshWorkspaceGroupBinding, error) { - return c.meshObject.Post(ctx, binding) -} +type MeshWorkspaceGroupBindingClient = MeshObjectClient[MeshWorkspaceGroupBinding, MeshObjectName, MeshWorkspaceBindingFilter] -func (c meshWorkspaceGroupBindingClient) Delete(ctx context.Context, name string) error { - return c.meshObject.Delete(ctx, name) +func (MeshWorkspaceGroupBinding) meshObjectPath() string { + return "meshworkspacebindings/groupbindings" } diff --git a/client/workspace_user_binding.go b/client/workspace_user_binding.go index d8a37dd4..deab8e2a 100644 --- a/client/workspace_user_binding.go +++ b/client/workspace_user_binding.go @@ -1,45 +1,13 @@ package client -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" - "github.com/meshcloud/meshstack-cli/internal/http" -) - type MeshWorkspaceUserBinding struct { MeshWorkspaceBinding Status *MeshUserBindingStatus `json:"status,omitempty" tfsdk:"status"` } -type MeshWorkspaceUserBindingClient interface { - Read(ctx context.Context, name string) (*MeshWorkspaceUserBinding, error) - List(ctx context.Context, filter MeshWorkspaceBindingListFilter) ([]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")} -} - -func (c meshWorkspaceUserBindingClient) Read(ctx context.Context, name string) (*MeshWorkspaceUserBinding, error) { - return c.meshObject.Get(ctx, name) -} - -func (c meshWorkspaceUserBindingClient) List(ctx context.Context, filter MeshWorkspaceBindingListFilter) ([]MeshWorkspaceUserBinding, error) { - return c.meshObject.List(ctx, http.WithUrlQuery(filter)) -} - -func (c meshWorkspaceUserBindingClient) Create(ctx context.Context, binding *MeshWorkspaceUserBinding) (*MeshWorkspaceUserBinding, error) { - return c.meshObject.Post(ctx, binding) -} +type MeshWorkspaceUserBindingClient = MeshObjectClient[MeshWorkspaceUserBinding, MeshObjectName, MeshWorkspaceBindingFilter] -func (c meshWorkspaceUserBindingClient) Delete(ctx context.Context, name string) error { - return c.meshObject.Delete(ctx, name) +func (MeshWorkspaceUserBinding) meshObjectPath() string { + return "meshworkspacebindings/userbindings" } diff --git a/cmd/api/api.go b/cmd/api/api.go index 727c3640..2389a617 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -16,10 +16,9 @@ import ( "github.com/spf13/cobra" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/openapi" "github.com/meshcloud/meshstack-cli/cmd/internal" - "github.com/meshcloud/meshstack-cli/internal/http" - "github.com/meshcloud/meshstack-cli/internal/setting" ) func New() *cobra.Command { @@ -52,27 +51,15 @@ To see what the API docs say about the request, run the same command line with ' return err } ctx := cmd.Context() - var profileSources setting.Sources - if r.target.IsAbs() { - if _, profileSources, err = profileFor(ctx, r.target); err != nil { - return err - } - } - // Resolved before the API docs, whose first download takes a while, so that a missing - // login fails at once. - meshStack, err := internal.ResolveClient(ctx, internal.WithSettingSources(profileSources)) + // Resolved first, so that a missing login fails before the API docs download. + meshStack, err := internal.ResolveClient(ctx, internal.WithSettingSources(r.originAsEndpoint())) if err != nil { return err } - if r.target.IsAbs() { - if err = r.cutTo(meshStack.Endpoint); err != nil { - return err - } - selector.Path = r.target.Path - args = []string{r.target.String()} + if r.target.IsAbs() && !strings.EqualFold(r.origin(), meshStack.Endpoint.String()) { + return fmt.Errorf("%s is not at the endpoint %s of this command; leave out --%s", r.target.Redacted(), meshStack.Endpoint, internal.EndpointFlag.Name) } - spec, wait, err := flags.loadApiDocs(cmd, meshStack.Endpoint) - defer wait() + spec, err := flags.loadApiDocs(cmd, meshStack.Endpoint) if err != nil { slog.WarnContext(ctx, fmt.Sprintf("Sending the request unchanged, because the API docs are not available: %s. "+ "Run %s to see why.", err, docsCommand(cmd, args))) @@ -83,8 +70,14 @@ To see what the API docs say about the request, run the same command line with ' r.header.Set("Content-Type", "application/json") } - answer, err := meshStack.Raw.DoRequest(ctx, r.method, r.target.Path, - http.WithUrlQuery(r.target.Query()), http.WithHeaders(r.header), http.WithBody(r.body)) + options := []http.RequestOption{http.WithUrlQuery(r.target.Query())} + if r.body != nil { + options = append(options, http.WithJsonRequest(r.body, r.header.Get("Content-Type")), http.WithHeader("Accept", r.header.Values("Accept")...)) + } + for key, values := range r.header { + options = append(options, http.WithHeader(key, values...)) + } + answer, err := http.ReadAllBody(meshStack.HttpClient.DoRequest(ctx, r.method, meshStack.Endpoint.JoinPath(r.target.Path), options...)) if httpErr, ok := errors.AsType[client.HttpError](err); ok { if writeErr := writeAnswer(cmd.OutOrStdout(), httpErr.ResponseBody); writeErr != nil { return writeErr @@ -94,7 +87,6 @@ To see what the API docs say about the request, run the same command line with ' return withHint(fmt.Sprintf("%s. Run %s to see what the API expects", httpErr.Summary(), docsCommand(cmd, args)), httpErr) } - // What wraps the HTTP error, such as the auth scope of a 403, stays. return withHint(strings.Replace(err.Error(), httpErr.Error(), httpErr.Summary(), 1), httpErr) } if err != nil { diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index ee7e7612..7d57fbb1 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -6,7 +6,9 @@ import ( "io" gohttp "net/http" "os" + "path/filepath" "strings" + "sync/atomic" "testing" "github.com/spf13/cobra" @@ -29,12 +31,10 @@ const ( ) func TestApi(t *testing.T) { - meshStack := newFakeMeshStack(t) - testlogin.LoggedInTo(t, meshStack.URL) apiDocs, err := os.ReadFile(apiDocsFile) require.NoError(t, err) - docs := fakemeshstack.Start(t, fakemeshstack.Options{ApiDocs: apiDocs}) - t.Setenv("MESHSTACK_API_DOCS_URL", docs.URL+fakemeshstack.ApiDocsPath) + meshStack := newFakeMeshStack(t, apiDocs) + testlogin.LoggedInTo(t, meshStack.URL) postBlock := func(t *testing.T, body string, args ...string) (recordedRequest, error) { t.Helper() _, err := meshStack.run(t, api.New(), body, append([]string{"-X", "POST", "/api/meshobjects/meshbuildingblocks", "--request-json", "-"}, args...)...) @@ -66,12 +66,12 @@ func TestApi(t *testing.T) { t.Run("fails without a login before it downloads the API docs", func(t *testing.T) { t.Setenv(config.DirectorySetting.EnvKey(), t.TempDir()) t.Setenv(auth.ApiTokenSetting.EnvKey(), "") - docs.TakeRequests() + docsRequests := meshStack.docsRequests.Load() _, err := meshStack.run(t, api.New(), "", "/api/meshobjects/meshbuildingblocks") - require.ErrorContains(t, err, "selects no credential") - assert.Empty(t, docs.TakeRequests()) + require.ErrorContains(t, err, "no credential for the endpoint") + assert.Equal(t, docsRequests, meshStack.docsRequests.Load()) assert.Empty(t, meshStack.requests) }) @@ -128,30 +128,25 @@ func TestApi(t *testing.T) { t.Run("writes the body of a failed answer, and fails with a hint to api-docs on a client error", func(t *testing.T) { for status, wantError := range map[int]string{ - gohttp.StatusConflict: "meshStack answered HTTP 409: still in use. Run meshstack api-docs /api/x -H 'Accept: application/json' -X DELETE to see what the API expects", + gohttp.StatusConflict: "meshStack answered HTTP 409: still in use. Run meshstack api-docs /api/x -H 'Accept: application/json' -X POST to see what the API expects", gohttp.StatusBadGateway: "meshStack answered HTTP 502: still in use", - gohttp.StatusForbidden: "auth scope profile default: meshStack answered HTTP 403: still in use", + gohttp.StatusForbidden: "auth manual for " + meshStack.URL + ": meshStack answered HTTP 403: still in use", } { meshStack.answer(status, `{"message":"still in use"}`) - stdout, err := meshStack.run(t, api.New(), "", "-X", "DELETE", "/api/x", "-H", "Accept: application/json") + stdout, err := meshStack.run(t, api.New(), "", "-X", "POST", "/api/x", "-H", "Accept: application/json") require.EqualError(t, err, wantError) assert.JSONEq(t, "{\n \"message\": \"still in use\"\n}\n", stdout) } }) - t.Run("points to api-docs on a 404 only for a path the API docs do not list", func(t *testing.T) { - for path, wantError := range map[string]string{ - "/api/x": "meshStack answered HTTP 404: gone (errorCode NotFound). Run meshstack api-docs /api/x -X GET -H 'Accept: application/json' to see what the API expects", - "/api/meshobjects/meshtenants/0c9ef6d1-5c5d-4b0c-9a4b-1e0e3c1f2a3b": "meshStack answered HTTP 404: gone (errorCode NotFound)", - } { - meshStack.answer(gohttp.StatusNotFound, `{"errorCode":"NotFound","message":"gone"}`) + t.Run("points to api-docs on a 404 for a path the API docs do not list", func(t *testing.T) { + meshStack.answer(gohttp.StatusNotFound, `{"errorCode":"NotFound","message":"gone"}`) - _, err := meshStack.run(t, api.New(), "", path, "-H", "Accept: application/json") + _, err := meshStack.run(t, api.New(), "", "/api/x", "-H", "Accept: application/json") - require.EqualError(t, err, wantError) - } + require.EqualError(t, err, "meshStack answered HTTP 404: gone (errorCode NotFound). Run meshstack api-docs /api/x -X GET -H 'Accept: application/json' to see what the API expects") }) t.Run("adds the clientVersionHint of a failed answer to the error", func(t *testing.T) { @@ -198,7 +193,7 @@ func TestApi(t *testing.T) { require.NoError(t, err) assert.Contains(t, stdout, "| POST | `/api/meshobjects/meshbuildingblocks` | v1, v2-preview |") - assert.Contains(t, stdout, "| GET, DELETE | `/api/meshobjects/meshtenants/{uuid}` | v4 |") + assert.Contains(t, stdout, "| GET, DELETE | `/api/meshobjects/meshtenants/{tenant}` | v3, v4 |") stdout, err = meshStack.run(t, api.NewDocs(), "", "-o", "json") @@ -210,7 +205,7 @@ func TestApi(t *testing.T) { stdout, err := meshStack.run(t, api.NewDocs(), "", "-X", "DELETE") require.NoError(t, err) - assert.Equal(t, "| Methods | Path | API versions |\n|---|---|---|\n| DELETE | `/api/meshobjects/meshtenants/{uuid}` | |\n", stdout) + assert.Equal(t, "| Methods | Path | API versions |\n|---|---|---|\n| DELETE | `/api/meshobjects/meshtenants/{tenant}` | |\n", stdout) _, err = meshStack.run(t, api.NewDocs(), "", "-X", "PATCH") @@ -235,7 +230,8 @@ func TestApi(t *testing.T) { require.NoError(t, err) assert.Contains(t, stdout, "# meshBuildingBlock\n\n## create: `POST /api/meshobjects/meshbuildingblocks`\n\n"+ - "API version "+request.wantVersion+"\n\n**Create a building block**\n\nCreates a building block.\n\n### Parameters", request.args) + "API version "+request.wantVersion+"\n\n**Create building block**\n\n"+ + "Create a new meshBuildingBlock with inputs and parent building block relationships\n\n### Request body", request.args) assert.Contains(t, stdout, "### Request body `application/vnd.meshcloud.api.meshbuildingblock."+request.wantVersion+".hal+json`", request.args) } }) @@ -245,14 +241,23 @@ func TestApi(t *testing.T) { require.NoError(t, err) assert.Equal(t, "# meshTenant\n\n"+ - "## show: `GET /api/meshobjects/meshtenants/{uuid}`\n\nAPI version v4\n\n"+ + "## show: `GET /api/meshobjects/meshtenants/{tenant}`\n\nAPI version v4\n\n**Get tenant**\n\n"+ + "### Parameters\n\n| Name | In | Type | Required | Description |\n|---|---|---|---|---|\n"+ + "| `tenant` | path | string | yes | |\n\n"+ "### Response 200 `application/vnd.meshcloud.api.meshtenant.v4.hal+json`\n\n"+ - "## delete: `DELETE /api/meshobjects/meshtenants/{uuid}`\n\nNo API version\n\n### Response 202\n", stdout) + "## delete: `DELETE /api/meshobjects/meshtenants/{tenant}`\n\nNo API version\n\n**Delete tenant**\n\n"+ + "### Parameters\n\n| Name | In | Type | Required | Description |\n|---|---|---|---|---|\n"+ + "| `tenant` | path | string | yes | DELETE takes the UUID or the full tenant identifier. |\n"+ + "| `purge` | query | boolean | | Removes the meshTenant from meshStack at once. |\n\n"+ + "### Response 202\n\n"+ + "### Response 400 `application/json`\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n"+ + "| `errorCode` | string | yes | |\n| `message` | string | yes | A message that describes the error. |\n", stdout) stdout, err = meshStack.run(t, api.NewDocs(), "", "-X", "GET", "/api/meshobjects/meshtenants/abc", "--api-version", "v3") require.NoError(t, err) - assert.Contains(t, stdout, "## show: `GET /api/meshobjects/meshtenants/{tenantIdentifier}`\n\nAPI version v3\n") + assert.Contains(t, stdout, "## show: `GET /api/meshobjects/meshtenants/{tenant}`\n\nAPI version v3\n") + assert.Contains(t, stdout, "### Response 200 `application/vnd.meshcloud.api.meshtenant.v3.hal+json`\n") assert.NotContains(t, stdout, "DELETE") }) @@ -260,9 +265,13 @@ func TestApi(t *testing.T) { stdout, err := meshStack.run(t, api.NewDocs(), "", "/api/meshobjects/meshtenants/abc", "-X", "GET", "-o", "json") require.NoError(t, err) - assert.JSONEq(t, `{"paths": {"/api/meshobjects/meshtenants/{uuid}": {"get": { - "operationId": "meshTenantV4", - "responses": {"200": {"description": "200", "content": {"application/vnd.meshcloud.api.meshtenant.v4.hal+json": {}}}} + assert.JSONEq(t, `{"paths": {"/api/meshobjects/meshtenants/{tenant}": {"get": { + "tags": ["Tenants"], + "summary": "Get tenant", + "operationId": "meshTenantGet", + "parameters": [{"name": "tenant", "in": "path", "required": true, "schema": {"type": "string"}, "x-meshstack-path-key": "metadata.uuid", "x-meshstack-path-key-by-version": {"v3": "metadata.tenantIdentifier"}}], + "responses": {"200": {"description": "The tenant", "content": {"application/vnd.meshcloud.api.meshtenant.v4.hal+json": {"x-meshstack-lifecycle": {"meshObject": "meshTenant.v4", "state": "GA"}}}}}, + "x-position": 110 }}}}`, stdout) _, err = meshStack.run(t, api.NewDocs(), "", "/api/meshobjects/meshunknowns") @@ -274,17 +283,36 @@ func TestApi(t *testing.T) { stdout, err := meshStack.run(t, api.NewDocs(), "", "--describe", "bb") require.NoError(t, err) - assert.Contains(t, stdout, "# meshBuildingBlock\n\n## create: `POST /api/meshobjects/meshbuildingblocks`\n\nAPI version v2-preview\n\n**Create a building block**\n\nCreates a building block.\n\n### Parameters") + assert.Contains(t, stdout, "# meshBuildingBlock\n\n## create: `POST /api/meshobjects/meshbuildingblocks`\n\nAPI version v2-preview\n\n**Create building block**\n\n"+ + "Create a new meshBuildingBlock with inputs and parent building block relationships\n\n### Request body `"+blockV2Preview+"`") assert.Contains(t, stdout, "### Response 201 `"+blockV2Preview+"`") assert.Contains(t, stdout, "### Response 400 `application/json`") stdout, err = meshStack.run(t, api.NewDocs(), "", "--describe", "tenant.delete", "-o", "json") require.NoError(t, err) - assert.JSONEq(t, `{"paths": {"/api/meshobjects/meshtenants/{uuid}": {"delete": { - "operationId": "meshTenantDeleteV4", - "responses": {"202": {"description": "202"}} - }}}}`, stdout) + assert.JSONEq(t, `{ + "paths": {"/api/meshobjects/meshtenants/{tenant}": {"delete": { + "tags": ["Tenants"], + "summary": "Delete tenant", + "operationId": "meshTenantDelete", + "parameters": [ + {"name": "tenant", "in": "path", "description": "DELETE takes the UUID or the full tenant identifier.", "required": true, "schema": {"type": "string"}, "x-meshstack-path-key": "metadata.uuid"}, + {"name": "purge", "in": "query", "description": "Removes the meshTenant from meshStack at once.", "required": false, "schema": {"type": "boolean"}} + ], + "responses": { + "202": {"description": "Deletion of tenant started"}, + "400": {"description": "The request is invalid. The message names the invalid part.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CustomErrorMessage"}}}} + }, + "x-position": 112 + }}}, + "components": {"schemas": {"CustomErrorMessage": { + "required": ["errorCode", "message"], + "type": "object", + "properties": {"errorCode": {"type": "string"}, "message": {"type": "string", "description": "A message that describes the error."}}, + "description": "The body of every error response." + }}} + }`, stdout) }) t.Run("api-docs --describe takes a kind of the API docs, and neither a path nor a request", func(t *testing.T) { @@ -303,6 +331,7 @@ func TestApi(t *testing.T) { t.Run("without a writable config directory, api sends the request as given and api-docs fails", func(t *testing.T) { dir := os.Getenv(config.DirectorySetting.EnvKey()) + require.NoError(t, os.RemoveAll(filepath.Join(dir, "api-docs")), "the earlier steps stored the API docs") //nolint:gosec // G703: the config directory of this test // G302 counts a directory's x bit, without which nothing can enter it. require.NoError(t, os.Chmod(dir, 0o500)) //nolint:gosec // G302: see above t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) //nolint:gosec // G302: see above @@ -332,12 +361,18 @@ type recordedRequest struct { type fakeMeshStack struct { *fakemeshstack.Server - requests []recordedRequest + requests []recordedRequest + docsRequests atomic.Int32 } -func newFakeMeshStack(t *testing.T) *fakeMeshStack { +func newFakeMeshStack(t *testing.T, apiDocs []byte) *fakeMeshStack { t.Helper() meshStack := &fakeMeshStack{Server: fakemeshstack.Start(t, fakemeshstack.Options{})} + serveApiDocs := fakemeshstack.ServeApiDocs(apiDocs) + meshStack.Route("GET "+fakemeshstack.ApiDocsPath, func(w gohttp.ResponseWriter, r *gohttp.Request) { + meshStack.docsRequests.Add(1) + serveApiDocs(w, r) + }) meshStack.answer(gohttp.StatusOK, `{}`) return meshStack } @@ -362,6 +397,9 @@ func (f *fakeMeshStack) run(t *testing.T, cmd *cobra.Command, stdin string, args err := cmd.ExecuteContext(t.Context()) f.requests = nil for _, r := range f.TakeRequests() { + if r.URL.Path == fakemeshstack.ApiDocsPath { + continue + } f.requests = append(f.requests, recordedRequest{ method: r.Method, pathAndQuery: r.URL.RequestURI(), diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 0d7d83c5..68ca3ba0 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -4,7 +4,6 @@ import ( "cmp" _ "embed" "encoding/json/jsontext" - "encoding/json/v2" "errors" "fmt" gohttp "net/http" @@ -17,6 +16,9 @@ import ( "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/cmd/internal/markdown" + "github.com/meshcloud/meshstack-cli/internal/json" + "github.com/meshcloud/meshstack-cli/internal/profile" + "github.com/meshcloud/meshstack-cli/internal/setting" ) //go:embed docs.md.tmpl @@ -44,9 +46,10 @@ Give the request as you would give it to 'meshstack api': a path or a URL, with --%[6]s %[7]s prints the matching part of the OpenAPI document as JSON. -The command downloads the API docs from your meshStack, so they list only what your meshStack offers. If no endpoint is set, or if your meshStack is too old to serve its API docs, the command uses the API docs of the latest release on docs.meshcloud.io. - ---%[8]s uses the API docs of meshStack's development version instead.`, +The API docs are those the endpoint serves, which the config directory keeps until the endpoint +serves new ones. The endpoint needs no login for them. Where the endpoint serves none, or cannot be +reached and none of its docs are kept, the published API docs stand in with a warning, and --%[8]s +picks those of what meshStack's develop branch has merged.`, methodFlagName, headerFlagName, requestJsonFlagName, apiVersionFlagName, describeFlagName, internal.OutputFlagName, internal.OutputJson, devFlagName), Example: ` meshstack api-docs @@ -67,25 +70,18 @@ The command downloads the API docs from your meshStack, so they list only what y return fmt.Errorf("--%s cannot be combined with a path; see --help", describeFlagName) } var endpoint xurl.URL - if r.target.IsAbs() { - if endpoint, _, err = profileFor(cmd.Context(), r.target); err != nil { - return err - } - if endpoint.URL == nil { - return fmt.Errorf("no stored profile matches %s; give the path relative to the endpoint", r.target.Redacted()) - } - if err = r.cutTo(endpoint); err != nil { - return err - } - } else if endpoint, err = docsEndpoint(cmd.Context()); err != nil { + resolved, _, err := profile.ResolveProfile(cmd.Context(), append(internal.SettingSources(), r.originAsEndpoint()...)) + switch { + case err == nil: + endpoint = resolved.Endpoint + case !errors.Is(err, setting.ErrNoSourceProvidedValue): return err } selector, err := flags.selector(r) if err != nil { return err } - spec, wait, err := flags.loadApiDocs(cmd, endpoint) - defer wait() + spec, err := flags.loadApiDocs(cmd, endpoint) if err != nil { return err } diff --git a/cmd/api/profile.go b/cmd/api/profile.go deleted file mode 100644 index 7d520a8a..00000000 --- a/cmd/api/profile.go +++ /dev/null @@ -1,106 +0,0 @@ -package api - -import ( - "context" - "errors" - "fmt" - "log/slog" - "net/url" - - "github.com/meshcloud/meshstack-cli/client/types/xurl" - "github.com/meshcloud/meshstack-cli/cmd/internal" - "github.com/meshcloud/meshstack-cli/internal/meshstack" - "github.com/meshcloud/meshstack-cli/internal/profile" - "github.com/meshcloud/meshstack-cli/internal/setting" -) - -// profileFor fails where a profile or endpoint named by flag or environment does not hold the URL, -// rather than send that profile's credential to another meshStack. The endpoint it returns is the -// one to cut the URL at, and is zero where the session is to create the profile named. -func profileFor(ctx context.Context, requestURL *url.URL) (endpoint xurl.URL, sources setting.Sources, err error) { - settingSources := internal.SettingSources() - profiles, err := profile.LoadProfiles(ctx, profile.LoadProfilesOptions{SettingSources: settingSources}) - if err != nil { - return xurl.URL{}, nil, err - } - - explicitEndpoint, endpointSource, err := settingSources.ResolveSettingWithSource(ctx, meshstack.EndpointSetting) - endpointIsExplicit := err == nil - if err != nil && !errors.Is(err, setting.ErrNoSourceProvidedValue) { - return xurl.URL{}, nil, err - } - // Checked before the profile name: where both are named, the error is about the endpoint, as it - // overrides the profile's. - if endpointIsExplicit { - if _, held := explicitEndpoint.PathTo(requestURL); !held { - return xurl.URL{}, nil, notHeldError(profiles, requestURL, explicitEndpoint, "", endpointSource.Describe(meshstack.EndpointSetting.EnvKey())) - } - } - - name, nameSource, err := settingSources.ResolveSettingWithSource(ctx, profile.NameSetting) - if err != nil { - return xurl.URL{}, nil, err - } - if _, isDefault := nameSource.(setting.DefaultSource); !isDefault { - named, stored := profiles.Profiles[name] - if !stored { - return explicitEndpoint, nil, nil - } - if _, held := named.Endpoint.PathTo(requestURL); !held { - return xurl.URL{}, nil, notHeldError(profiles, requestURL, named.Endpoint, fmt.Sprintf(" of profile '%s'", named.Name), - nameSource.Describe(profile.NameSetting.EnvKey())) - } - if endpointIsExplicit { - return explicitEndpoint, nil, nil - } - return named.Endpoint, nil, nil - } - - var selected profile.Profile - if endpointIsExplicit { - if len(profiles.Profiles) == 0 { - return explicitEndpoint, nil, nil - } - if selected, _, err = profiles.ForEndpoint(explicitEndpoint, requestURL); err != nil { - return xurl.URL{}, nil, err - } - endpoint = explicitEndpoint - } else { - var current bool - if selected, current, err = profiles.Holding(requestURL); err != nil { - return xurl.URL{}, nil, err - } - if !current { - slog.WarnContext(ctx, fmt.Sprintf("Using profile '%s' for '%s' instead of the current profile ('%s') because its endpoint matches the given URL.", - selected, requestURL.Redacted(), profiles.CurrentProfile)) - } - endpoint = selected.Endpoint - } - return endpoint, setting.Sources{setting.FallbackSource{Source: setting.LookupSource{ - MatchingKey: profile.NameSetting.EnvKey(), - Description: "profile whose endpoint matches " + requestURL.Redacted(), - Func: func(context.Context) (string, error) { return string(selected.Name), nil }, - }}}, nil -} - -func docsEndpoint(ctx context.Context) (xurl.URL, error) { - settingSources := internal.SettingSources() - var storedEndpoint setting.Sources - current, _, err := profile.ResolveProfile(ctx, profile.ResolveProfileOptions{SettingSources: settingSources, StoredOnly: true}) - if err == nil { - storedEndpoint = setting.Sources{current.EndpointSource()} - } else if !errors.Is(err, profile.ErrNoStoredProfile) { - return xurl.URL{}, err - } - endpoint, err := settingSources.ResolveSetting(ctx, meshstack.EndpointSetting, storedEndpoint...) - if errors.Is(err, setting.ErrNoSourceProvidedValue) { - return xurl.URL{}, nil - } - return endpoint, err -} - -func notHeldError(profiles profile.Profiles, requestURL *url.URL, endpoint xurl.URL, ofProfile, source string) error { - urlEndpoint, _ := profiles.EndpointHolding(requestURL) - return fmt.Errorf("%s is under endpoint %s, which does not match the endpoint %s%s from %s; leave out %s, or give the path relative to %s", - requestURL.Redacted(), urlEndpoint, endpoint, ofProfile, source, source, endpoint) -} diff --git a/cmd/api/profile_test.go b/cmd/api/profile_test.go deleted file mode 100644 index 2d81a143..00000000 --- a/cmd/api/profile_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package api - -import ( - "log/slog" - "net/url" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/meshcloud/meshstack-cli/internal/config" - "github.com/meshcloud/meshstack-cli/internal/logs" - "github.com/meshcloud/meshstack-cli/internal/meshstack" - "github.com/meshcloud/meshstack-cli/internal/profile" -) - -func TestProfileForARequestURL(t *testing.T) { - for _, envKey := range []string{profile.NameSetting.EnvKey(), meshstack.EndpointSetting.EnvKey()} { - t.Setenv(envKey, "") - } - configDir := t.TempDir() - t.Setenv(config.DirectorySetting.EnvKey(), configDir) - require.NoError(t, os.WriteFile(config.Directory(configDir).ProfilesJson(), []byte(`{"version":1,"currentProfile":"local","profiles":{ - "local": {"endpoint":"https://localhost:1337"}, - "a": {"endpoint":"https://meshstack.example.com/a"}, - "a-b": {"endpoint":"https://meshstack.example.com/a/b/"}, - "shared-1": {"endpoint":"https://shared.example.com"}, - "shared-2": {"endpoint":"https://shared.example.com"} - }}`), 0o600)) - resolve := func(t *testing.T, requestURL string) (endpoint string, name profile.Name, warnings []string, err error) { - t.Helper() - parsed, err := url.Parse(requestURL) - require.NoError(t, err) - captured := logs.Capture(t) - cutAt, sources, err := profileFor(t.Context(), parsed) - for _, record := range captured.Records(slog.LevelWarn) { - warnings = append(warnings, record.Message) - } - if err != nil { - return "", "", warnings, err - } - name, err = sources.ResolveSetting(t.Context(), profile.NameSetting) - require.NoError(t, err) - return cutAt.String(), name, warnings, nil - } - - t.Run("a profile other than the current one is picked with a warning that names both", func(t *testing.T) { - endpoint, name, warnings, err := resolve(t, "https://meshstack.example.com/a/api") - - require.NoError(t, err) - assert.Equal(t, "https://meshstack.example.com/a", endpoint) - assert.Equal(t, profile.Name("a"), name) - require.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "'a'") - assert.Contains(t, warnings[0], "'local'") - }) - - t.Run("profiles sharing the endpoint, none of them current, fail until the environment names one", func(t *testing.T) { - _, _, _, err := resolve(t, "https://shared.example.com/api") - - require.ErrorContains(t, err, "'shared-1', 'shared-2'") - - t.Setenv(profile.NameSetting.EnvKey(), "shared-2") - endpoint, name, warnings, err := resolve(t, "https://shared.example.com/api") - - require.NoError(t, err) - assert.Equal(t, "https://shared.example.com", endpoint) - assert.Equal(t, profile.Name("shared-2"), name) - assert.Empty(t, warnings) - }) - - t.Run("an explicit profile is picked over a longer endpoint, and fails where it does not hold the URL", func(t *testing.T) { - t.Setenv(profile.NameSetting.EnvKey(), "a") - endpoint, name, _, err := resolve(t, "https://meshstack.example.com/a/b/api") - - require.NoError(t, err) - assert.Equal(t, "https://meshstack.example.com/a", endpoint) - assert.Equal(t, profile.Name("a"), name) - - _, _, _, err = resolve(t, "https://localhost:1337/api") - - require.ErrorContains(t, err, "https://localhost:1337/api is under endpoint https://localhost:1337, "+ - "which does not match the endpoint https://meshstack.example.com/a of profile 'a' from env "+profile.NameSetting.EnvKey()) - }) - - t.Run("an explicit endpoint fails where it does not hold the URL", func(t *testing.T) { - t.Setenv(meshstack.EndpointSetting.EnvKey(), "https://localhost:1337") - - _, _, _, err := resolve(t, "https://meshstack.example.com/a/api") - - require.ErrorContains(t, err, "https://meshstack.example.com/a/api is under endpoint https://meshstack.example.com/a, "+ - "which does not match the endpoint https://localhost:1337 from env "+meshstack.EndpointSetting.EnvKey()) - }) - - t.Run("with no profile stored, an explicit endpoint that holds the URL leaves the name of the first profile to the session", func(t *testing.T) { - require.NoError(t, os.Remove(config.Directory(configDir).ProfilesJson())) - t.Setenv(meshstack.EndpointSetting.EnvKey(), "https://localhost:1337") - - endpoint, name, _, err := resolve(t, "https://localhost:1337/api") - - require.NoError(t, err) - assert.Equal(t, "https://localhost:1337", endpoint) - assert.Equal(t, profile.Name("default"), name) - }) -} diff --git a/cmd/api/request.go b/cmd/api/request.go index 27194e1c..66a41982 100644 --- a/cmd/api/request.go +++ b/cmd/api/request.go @@ -1,8 +1,8 @@ package api import ( + "context" "encoding/json/jsontext" - "encoding/json/v2" "fmt" gohttp "net/http" "net/url" @@ -17,6 +17,9 @@ import ( "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/internal/apidocs" + "github.com/meshcloud/meshstack-cli/internal/json" + "github.com/meshcloud/meshstack-cli/internal/meshstack" + "github.com/meshcloud/meshstack-cli/internal/setting" ) const ( @@ -40,7 +43,7 @@ func (f *requestFlags) register(cmd *cobra.Command, defaultMethod, methodUsage s cmd.Flags().StringArrayVarP(&f.headers, headerFlagName, "H", nil, "add a request header, as 'key: value'") f.requestJson.Register(cmd.Flags(), requestJsonFlagName, "JSON file to send as the request body, or - for stdin") cmd.Flags().StringVar(&f.apiVersion, apiVersionFlagName, "", "version of the meshObject API, such as v1 or v2-preview, instead of the latest") - cmd.Flags().BoolVar(&f.dev, devFlagName, false, "use the API docs of meshStack's development version") + cmd.Flags().BoolVar(&f.dev, devFlagName, false, "where the endpoint serves no API docs, fall back to those of meshStack's develop branch") } type request struct { @@ -81,13 +84,19 @@ func (f *requestFlags) parse(cmd *cobra.Command, args []string) (request, error) return r, nil } -func (r *request) cutTo(endpoint xurl.URL) error { - path, held := endpoint.PathTo(r.target) - if !held { - return fmt.Errorf("%s is not under endpoint %s", r.target.Redacted(), endpoint) +func (r *request) origin() string { + return r.target.Scheme + "://" + r.target.Host +} + +func (r *request) originAsEndpoint() setting.Sources { + if !r.target.IsAbs() { + return nil } - r.target = &url.URL{Path: path, RawQuery: r.target.RawQuery} - return nil + return setting.Sources{setting.LookupSource{ + MatchingKey: meshstack.EndpointSetting.EnvKey(), + Description: "the URL " + r.target.Redacted(), + Func: func(context.Context) (string, error) { return r.origin(), nil }, + }} } func (f *requestFlags) selector(r request) (openapi.Selector, error) { @@ -101,7 +110,7 @@ func (f *requestFlags) selector(r request) (openapi.Selector, error) { if f.apiVersion != "" { given = append(given, f.apiVersion) } - if r.body.Kind() == '{' { + if r.body.Kind() == jsontext.KindBeginObject { var body struct { ApiVersion string `json:"apiVersion"` } @@ -124,14 +133,10 @@ func (f *requestFlags) selector(r request) (openapi.Selector, error) { return selector, nil } -func (f *requestFlags) loadApiDocs(cmd *cobra.Command, endpoint xurl.URL) (openapi.Spec, func(), error) { - httpClient, err := internal.ResolveClientOptions().HttpClient() - if err != nil { - return openapi.Spec{}, func() {}, err - } +func (f *requestFlags) loadApiDocs(cmd *cobra.Command, endpoint xurl.URL) (openapi.Spec, error) { return apidocs.Load(cmd.Context(), apidocs.Options{ Sources: internal.SettingSources(), - Client: httpClient, + Client: internal.ResolveClientOptions().HttpClient(), Endpoint: endpoint, Version: internal.Version, Dev: f.dev, diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 7fa0a2a1..62d6f868 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "slices" - "uuid" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -33,7 +32,7 @@ func NewLoginTo(name profile.Name) *cobra.Command { func newLogin(profileSources setting.Sources) *cobra.Command { var ( openStdinFlag = newStdinFlag() - apiKeyFlag = internal.NewFlagForSetting[uuid.UUID](apiKeyFlagName, setting.ApiKeyClientId) + apiKeyFlag = internal.NewFlagForSetting[string](apiKeyFlagName, setting.ApiKeyClientId) apiTokenFlag = internal.NewFlagForSetting[bool]("apitoken", setting.ApiToken) output internal.ShowFlag ) @@ -66,8 +65,8 @@ Afterwards, the command prints the same output as 'meshstack auth status' for th case cmd.Flags().Changed(apiKeyFlag.Name.String()): authWith = credential.ApiKeyName opts.SettingSources = append(opts.SettingSources, - apiKeyFlag.AsSourceUnless(func(id uuid.UUID) bool { - return id == uuid.Nil() + apiKeyFlag.AsSourceUnless(func(id string) bool { + return id == "" }), newPromptingSource(setting.ApiKeyClientSecret.EnvKey(), &openStdinFlag, promptedFrom, "API Client Secret"), ) diff --git a/cmd/auth/login_apikey_test.go b/cmd/auth/login_apikey_test.go index 9f811509..e7b3d19a 100644 --- a/cmd/auth/login_apikey_test.go +++ b/cmd/auth/login_apikey_test.go @@ -10,14 +10,13 @@ import ( "github.com/meshcloud/meshstack-cli/internal/meshstack" ) -func TestLoginRejectsAnApiKeyIdOfNoUuidWhileItParsesTheFlags(t *testing.T) { +func TestLoginRejectsAnEmptyApiKeyIdWhileItParsesTheFlags(t *testing.T) { configDir := withEmptyConfigDir(t) tests := []struct { arg string wantErr string }{ - {arg: "--apikey=my-key", wantErr: `invalid argument "my-key" for "--apikey" flag: invalid uuid`}, {arg: "--apikey=", wantErr: `invalid argument "" for "--apikey" flag: the API key id is empty; give a bare --apikey to read it from the environment`}, } for _, tt := range tests { @@ -38,5 +37,5 @@ func TestABareApiKeyFlagLeavesTheIdToTheEnvironment(t *testing.T) { _, err := execute(t, "login", "--apikey") - assert.EqualError(t, err, "value from source 'env MESHSTACK_API_KEY' could not be parsed: invalid uuid\ntry setting flag --apikey") + assert.ErrorContains(t, err, "cannot login with api key my-key at https://meshstack.example.io/api/login") } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 42717ed6..e38eaa26 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -101,9 +101,7 @@ func TestProfileSelection(t *testing.T) { var asked bytes.Buffer source := newProfileSelectionSource(prompt.New(strings.NewReader(tt.answers), &asked), tt.stdin) - ctx := profile.SetSelectionInContext(t.Context(), func() (profile.Selection, error) { - return tt.selection, nil - }) + ctx := profile.SetSelectionInContext(t.Context(), tt.selection) selected, err := source.Lookup(ctx, setting.Profile.EnvKey()) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index df7c8c4b..3ee68a52 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -1,7 +1,6 @@ package auth import ( - "errors" "fmt" "log/slog" @@ -23,16 +22,15 @@ Without --` + internal.ProfileFlag.Name.String() + `, the command asks which of Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - currentProfile, _, err := profile.ResolveProfile(ctx, profile.ResolveProfileOptions{ - SettingSources: append(internal.SettingSources(), newProfileSelectionSource(prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr()), false)), - StoredOnly: true, - }) - if errors.Is(err, profile.ErrNoStoredProfile) { - slog.WarnContext(ctx, "Nothing to log out of: "+err.Error()) - return nil - } else if err != nil { + currentProfile, _, err := profile.ResolveProfile(ctx, + append(internal.SettingSources(), newProfileSelectionSource(prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr()), false))) + if err != nil { return err } + if currentProfile.Name == "" { + slog.WarnContext(ctx, "Nothing to log out of: no stored profile is used") + return nil + } if err := auth.Logout(ctx, currentProfile, internal.ResolveClientOptions()); err != nil { return err } diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 09fb39c5..31513903 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/internal/config" - "github.com/meshcloud/meshstack-cli/internal/logs" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/profile" ) @@ -22,15 +21,11 @@ func withEmptyConfigDir(t *testing.T) config.Directory { return configDir } -func TestLogoutOfAProfileThatDoesNotExistOnlyWarns(t *testing.T) { +func TestLogoutOfAProfileThatDoesNotExistFails(t *testing.T) { configDir := withEmptyConfigDir(t) - captured := logs.Capture(t) - _, err := execute(t, "auth", "logout", "-p", "missing") - require.NoError(t, err) - assert.Contains(t, captured.String(), "level=WARN") - assert.Contains(t, captured.String(), "no stored profile is named 'missing'") + require.ErrorContains(t, err, "there is no profile 'missing'") assert.NoFileExists(t, configDir.ProfilesJson(), "the logout created no profile") } diff --git a/cmd/auth/status_test.go b/cmd/auth/status_test.go index a09cd2cb..e0a60b45 100644 --- a/cmd/auth/status_test.go +++ b/cmd/auth/status_test.go @@ -3,7 +3,6 @@ package auth import ( "bytes" "encoding/base64" - "encoding/json/v2" "fmt" "io" "testing" @@ -15,6 +14,7 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/internal/auth" + "github.com/meshcloud/meshstack-cli/internal/json" "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" "github.com/meshcloud/meshstack-cli/internal/testutil/testlogin" ) @@ -57,7 +57,7 @@ func TestStatusOfAnApiTokenFromTheEnvironment(t *testing.T) { t.Run("is Markdown where the output is no terminal", func(t *testing.T) { output := executeStatus(t) - assert.Contains(t, output, "| Profile | default |\n| --- | --- |\n") + assert.Contains(t, output, "| Profile | none |\n| --- | --- |\n") assert.Contains(t, output, "| Credential | API token, from env MESHSTACK_API_TOKEN |\n") assert.Contains(t, output, "| | User runner |\n") assert.Contains(t, output, "| meshStack | 2026.40.0 |\n") diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 188bac61..055a6bda 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -26,7 +26,7 @@ Paste the token as the Bearer token in the API docs to try a request against you if err != nil { return err } - _, err = fmt.Fprintln(cmd.OutOrStdout(), token) + _, err = fmt.Fprintln(cmd.OutOrStdout(), string(token)) return err }, } diff --git a/cmd/buildingblock/abort_run.go b/cmd/buildingblock/abort_run.go index 4798b138..c885eea9 100644 --- a/cmd/buildingblock/abort_run.go +++ b/cmd/buildingblock/abort_run.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" gohttp "net/http" - "uuid" "github.com/spf13/cobra" @@ -16,10 +15,7 @@ import ( ) func newAbortRun() *cobra.Command { - var ( - output internal.OutputFlag - buildingBlockUuid uuid.UUID - ) + var output internal.OutputFlag cmd := &cobra.Command{ Use: "abort-run ", @@ -29,8 +25,9 @@ func newAbortRun() *cobra.Command { The command shows the run and asks you to confirm, so it needs a terminal. After the abort, it prints the building block.`, Example: ` meshstack buildingblock abort-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb abort-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90`, - Args: internal.UuidArg(&buildingBlockUuid), - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + buildingBlockUuid := args[0] ctx := cmd.Context() action, err := newRunAction(cmd, buildingBlockUuid, output.Format) if err != nil { @@ -46,19 +43,19 @@ The command shows the run and asks you to confirm, so it needs a terminal. After } func (a runAction) abort(ctx context.Context) error { - _, block, err := a.readBuildingBlock(ctx) + block, err := a.readBuildingBlock(ctx) if err != nil { return err } - latest := cmp.Or(block.Links.LatestDryRun, block.Links.LatestRun) - if latest == nil { + latest := cmp.Or(block.Status.GetLatestDryRunUuid(), block.Status.GetLatestRunUuid()) + if latest == "" { return fmt.Errorf("building block %s has no run to abort", a.buildingBlockUuid) } - run, err := a.meshStack.BuildingBlockRunAction.ReadRun(ctx, *latest) + run, err := a.readRun(ctx, latest) if err != nil { return err } - if run.Links.Abort == nil { + if _, found := run.Links["abort"]; !found { return fmt.Errorf("run %s of building block %s (action %s) has the status %s and cannot be aborted, or you are not allowed to abort it", run.Metadata.Uuid, a.buildingBlockUuid, block.Status.Action, run.Status) } @@ -74,7 +71,7 @@ func (a runAction) abort(ctx context.Context) error { slog.InfoContext(ctx, fmt.Sprintf("Did not abort run %s.", run.Metadata.Uuid)) return nil } - err = a.meshStack.BuildingBlockRunAction.AbortRun(ctx, *run.Links.Abort) + _, err = run.Links.Post[struct{}](ctx, "abort", nil) if httpErr, ok := errors.AsType[client.HttpError](err); ok && httpErr.StatusCode == gohttp.StatusConflict { return fmt.Errorf("run %s can no longer be aborted", run.Metadata.Uuid) } diff --git a/cmd/buildingblock/approve_run.go b/cmd/buildingblock/approve_run.go index 27fe7385..419347f5 100644 --- a/cmd/buildingblock/approve_run.go +++ b/cmd/buildingblock/approve_run.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" gohttp "net/http" - "uuid" "github.com/spf13/cobra" @@ -17,10 +16,7 @@ import ( ) func newApproveRun() *cobra.Command { - var ( - output internal.OutputFlag - buildingBlockUuid uuid.UUID - ) + var output internal.OutputFlag cmd := &cobra.Command{ Use: "approve-run ", @@ -32,8 +28,9 @@ The command shows the plan and asks you to approve it, so it needs a terminal. I A building block whose status.action is APPROVE_RUN in 'meshstack buildingblock list' has a run that is waiting for approval. After the approval, the command prints the building block.`, Example: ` meshstack buildingblock approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90`, - Args: internal.UuidArg(&buildingBlockUuid), - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + buildingBlockUuid := args[0] ctx := cmd.Context() action, err := newRunAction(cmd, buildingBlockUuid, output.Format) if err != nil { @@ -49,32 +46,32 @@ A building block whose status.action is APPROVE_RUN in 'meshstack buildingblock } func (a runAction) approve(ctx context.Context) error { - _, block, err := a.readBuildingBlock(ctx) + block, err := a.readBuildingBlock(ctx) if err != nil { return err } - if block.Links.LatestRun == nil { + if block.Status.GetLatestRunUuid() == "" { return fmt.Errorf("building block %s has no run to approve", a.buildingBlockUuid) } - run, err := a.meshStack.BuildingBlockRunAction.ReadRun(ctx, *block.Links.LatestRun) + run, err := a.readRun(ctx, block.Status.GetLatestRunUuid()) if err != nil { return err } - if run.Links.Approve == nil { + if _, found := run.Links["approve"]; !found { return fmt.Errorf("the latest run %s of building block %s (action %s) is not waiting for approval, or you are not allowed to approve it", run.Metadata.Uuid, a.buildingBlockUuid, block.Status.Action) } - if run.Links.Predecessor == nil { + if _, found := run.Links["predecessor"]; !found { return fmt.Errorf("run %s is waiting for approval, but meshStack does not say which run created its plan", run.Metadata.Uuid) } - plan, err := a.meshStack.BuildingBlockRunAction.ReadRun(ctx, *run.Links.Predecessor) + plan, err := run.Links.Get[client.MeshBuildingBlockRun](ctx, "predecessor") if err != nil { return err } - if plan.Links.DownloadLogs == nil { + if _, found := plan.Links["downloadLogs"]; !found { return fmt.Errorf("cannot read the logs of run %s, which created the plan for run %s", plan.Metadata.Uuid, run.Metadata.Uuid) } - logs, err := a.meshStack.BuildingBlockRunAction.ReadLogs(ctx, *plan.Links.DownloadLogs) + logs, err := plan.Links.Get[client.MeshBuildingBlockRunLogs](ctx, "downloadLogs") if err != nil { return err } @@ -94,7 +91,7 @@ func (a runAction) approve(ctx context.Context) error { slog.InfoContext(ctx, fmt.Sprintf("Did not approve run %s.", run.Metadata.Uuid)) return nil } - err = a.meshStack.BuildingBlockRunAction.ApproveRun(ctx, *run.Links.Approve, plan.Metadata.Uuid) + _, err = run.Links.Post[struct{}](ctx, "approve", client.MeshBuildingBlockRunApproval{PredecessorRunUuid: plan.Metadata.Uuid}) if httpErr, ok := errors.AsType[client.HttpError](err); ok { switch httpErr.StatusCode { case gohttp.StatusConflict: diff --git a/cmd/buildingblock/approve_run_internal_test.go b/cmd/buildingblock/approve_run_internal_test.go index b4b9e60d..920fdf73 100644 --- a/cmd/buildingblock/approve_run_internal_test.go +++ b/cmd/buildingblock/approve_run_internal_test.go @@ -2,18 +2,17 @@ package buildingblock import ( "bytes" - "encoding/json/v2" "io" gohttp "net/http" "strings" "testing" - "uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/cmd/internal/prompt" + "github.com/meshcloud/meshstack-cli/internal/json" "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" "github.com/meshcloud/meshstack-cli/internal/testutil/testlogin" ) @@ -38,7 +37,7 @@ func newTestRunAction(t *testing.T, server *fakemeshstack.Server, answers string require.NoError(t, err) var asked bytes.Buffer return runAction{ - meshStack: meshStack, prompt: prompt.New(strings.NewReader(answers), &asked), buildingBlockUuid: uuid.MustParse(buildingBlockUuid), + meshStack: meshStack, prompt: prompt.New(strings.NewReader(answers), &asked), buildingBlockUuid: buildingBlockUuid, out: io.Discard, format: internal.OutputJson, }, &asked } @@ -52,7 +51,7 @@ func TestApproveRunApprovesOnlyThePlanItShowed(t *testing.T) { `"spec": {"runNumber": 2, "behavior": "APPLY", "buildingBlock": {"uuid": "`+buildingBlockUuid+`", "spec": {"displayName": "my-block"}}}, `+ `"_links": {"predecessor": {"href": "`+runsPath+planRun+`"}, "approve": {"href": "`+runsPath+waitingRun+`/approve"}}}`) server := fakemeshstack.Start(t, fakemeshstack.Options{ - BuildingBlocks: []any{meshObject(t, `{"metadata": {"uuid": "`+buildingBlockUuid+`"}, "status": {"action": "APPROVE_RUN"}, `+ + BuildingBlocks: []any{meshObject(t, `{"metadata": {"uuid": "`+buildingBlockUuid+`"}, "status": {"action": "APPROVE_RUN", "latestRunUuid": "`+waitingRun+`"}, `+ `"_links": {"latestRun": {"href": "`+runsPath+waitingRun+`"}}}`)}, BuildingBlockRuns: []any{run, meshObject(t, `{"metadata": {"uuid": "`+planRun+`"}, "status": "SUCCEEDED", `+ `"_links": {"downloadLogs": {"href": "`+runsPath+planRun+`/logs"}}}`)}, diff --git a/cmd/buildingblock/buildingblock.go b/cmd/buildingblock/buildingblock.go index 084fd853..a12128d2 100644 --- a/cmd/buildingblock/buildingblock.go +++ b/cmd/buildingblock/buildingblock.go @@ -2,12 +2,9 @@ package buildingblock import ( "context" - "encoding/json/jsontext" - "encoding/json/v2" "fmt" "io" "log/slog" - "uuid" "github.com/spf13/cobra" @@ -44,12 +41,12 @@ func New() *cobra.Command { type runAction struct { meshStack client.Client prompt prompt.Prompt - buildingBlockUuid uuid.UUID + buildingBlockUuid string out io.Writer format internal.OutputFormat } -func newRunAction(cmd *cobra.Command, buildingBlockUuid uuid.UUID, format internal.OutputFormat) (runAction, error) { +func newRunAction(cmd *cobra.Command, buildingBlockUuid string, format internal.OutputFormat) (runAction, error) { p := prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr()) // A script cannot approve or abort: the decision belongs to a person who saw the run. if !p.UsesTerminal() { @@ -62,28 +59,36 @@ func newRunAction(cmd *cobra.Command, buildingBlockUuid uuid.UUID, format intern return runAction{meshStack: meshStack, prompt: p, buildingBlockUuid: buildingBlockUuid, out: cmd.OutOrStdout(), format: format}, nil } -type linkedBuildingBlock struct { - Status client.MeshBuildingBlockV2Status `json:"status"` - Links client.MeshBuildingBlockV2Links `json:"_links"` +func (a runAction) readBuildingBlock(ctx context.Context) (client.MeshBuildingBlockV2, error) { + block, err := a.meshStack.BuildingBlockV2.Read(ctx, a.buildingBlockUuid) + switch { + case err != nil: + return client.MeshBuildingBlockV2{}, err + case block == nil: + return client.MeshBuildingBlockV2{}, fmt.Errorf("building block %s not found", a.buildingBlockUuid) + } + return *block, nil } -func (a runAction) readBuildingBlock(ctx context.Context) (jsontext.Value, linkedBuildingBlock, error) { - var block linkedBuildingBlock - raw, err := a.meshStack.Raw.Get[client.MeshBuildingBlockV2](ctx, a.buildingBlockUuid) - if err != nil { - return nil, block, err +func (a runAction) readRun(ctx context.Context, runUuid string) (client.MeshBuildingBlockRun, error) { + run, err := a.meshStack.BuildingBlockRun.Read(ctx, runUuid) + switch { + case err != nil: + return client.MeshBuildingBlockRun{}, err + case run == nil: + return client.MeshBuildingBlockRun{}, fmt.Errorf("run %s of building block %s not found", runUuid, a.buildingBlockUuid) } - return raw, block, json.Unmarshal(raw, &block) + return *run, nil } // report reads the building block again and writes it, because a run hides the actions // APPROVE_RUN and REVIEW_ABORTED_RUN that only the building block shows. func (a runAction) report(ctx context.Context, done string) error { - raw, block, err := a.readBuildingBlock(ctx) + block, err := a.readBuildingBlock(ctx) if err != nil { return err } - if err := a.format.WriteItem(a.out, raw); err != nil { + if err := a.format.WriteItem(a.out, client.WithKindAndApiVersion(a.meshStack.BuildingBlockV2, block)); err != nil { return err } slog.InfoContext(ctx, fmt.Sprintf("%s Building block %s now has the action %s.", done, a.buildingBlockUuid, block.Status.Action)) diff --git a/cmd/buildingblock/list.go b/cmd/buildingblock/list.go index 8f8973e4..2d85fb68 100644 --- a/cmd/buildingblock/list.go +++ b/cmd/buildingblock/list.go @@ -19,13 +19,17 @@ func newList() *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { var ( - filter client.MeshBuildingBlockV2ListFilter + filter client.MeshBuildingBlockV2Filter err error ) if filter.WorkspaceIdentifier, err = internal.ListWorkspace(cmd.Context()); err != nil { return err } - return flags.Run[client.MeshBuildingBlockV2](cmd, filter) + meshStack, err := internal.ResolveClient(cmd.Context()) + if err != nil { + return err + } + return flags.Run(cmd, meshStack.BuildingBlockV2, filter) }, } diff --git a/cmd/buildingblock/tfstate/exec.go b/cmd/buildingblock/tfstate/exec.go index 9fd6f0c4..4b142a3a 100644 --- a/cmd/buildingblock/tfstate/exec.go +++ b/cmd/buildingblock/tfstate/exec.go @@ -12,7 +12,6 @@ import ( "slices" "syscall" "time" - "uuid" "github.com/charmbracelet/x/term" "github.com/spf13/cobra" @@ -21,7 +20,7 @@ import ( "github.com/meshcloud/meshstack-cli/internal/tfstate" ) -// runLockTimeout must match stateLockTimeout in ../building-block-runner/tf-block-runner/tfrun/tfcmd.go. +// runLockTimeout must match stateLockTimeout in ../building-block-runner/internal/executor/tf/tfcmd.go. const runLockTimeout = 5 * time.Minute type mode string @@ -59,7 +58,7 @@ func (m *mode) Type() string { func newExec() *cobra.Command { var ( - buildingBlockUuid uuid.UUID + buildingBlockUuid string command []string access = modeRead force bool @@ -95,7 +94,8 @@ With --%[8]s, exec copies the stored state into that directory before it replace cmd.CommandPath(), cmd.CommandPath()) } command = args[1:] - return internal.UuidArg(&buildingBlockUuid)(cmd, args[:1]) + buildingBlockUuid = args[0] + return nil }, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() @@ -111,10 +111,7 @@ With --%[8]s, exec copies the stored state into that directory before it replace if err != nil { return err } - api, err := internal.ResolveClientOptions().HttpClient() - if err != nil { - return err - } + api := internal.ResolveClientOptions().HttpClient() var problems []error proxy := &tfstate.Proxy{ Store: store, @@ -139,7 +136,7 @@ With --%[8]s, exec copies the stored state into that directory before it replace proxy.LockWarning = runLockTimeout if !force { proxy.BeforeWrite = func(ctx context.Context) error { - if running := tfstate.NoRunOf(ctx, meshStack.Raw, buildingBlockUuid); running != nil { + if running := tfstate.NoRunOf(ctx, meshStack.BuildingBlockV2, buildingBlockUuid); running != nil { return fmt.Errorf("%w: wait for it to finish, or run again with --%s", running, forceFlagName) } return nil diff --git a/cmd/buildingblock/tfstate/force_unlock.go b/cmd/buildingblock/tfstate/force_unlock.go index a4869407..09f04dec 100644 --- a/cmd/buildingblock/tfstate/force_unlock.go +++ b/cmd/buildingblock/tfstate/force_unlock.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "log/slog" - "uuid" "github.com/spf13/cobra" @@ -14,10 +13,7 @@ import ( ) func newForceUnlock() *cobra.Command { - var ( - buildingBlockUuid uuid.UUID - force bool - ) + var force bool cmd := &cobra.Command{ Use: "force-unlock ", @@ -29,8 +25,9 @@ If the lock belongs to a run of the building block that is pending or in progres By default, the command uses the state stored in the building block's own workspace, metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace.`, Example: ` meshstack bb tfstate force-unlock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 echo yes | meshstack bb tfstate force-unlock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --force`, - Args: internal.UuidArg(&buildingBlockUuid), - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + buildingBlockUuid := args[0] ctx := cmd.Context() meshStack, err := internal.ResolveClient(ctx) if err != nil { @@ -42,15 +39,15 @@ By default, the command uses the state stored in the building block's own worksp } lock, err := store.ReadLock(ctx) if errors.Is(err, tfstate.ErrNoLock) { - slog.InfoContext(ctx, "The state of building block "+buildingBlockUuid.String()+" in workspace "+store.Workspace+" has no lock") + slog.InfoContext(ctx, "The state of building block "+buildingBlockUuid+" in workspace "+store.Workspace+" has no lock") return nil } else if err != nil { return withRightsHint(err) } - slog.InfoContext(ctx, "The state of building block "+buildingBlockUuid.String()+" is locked by "+lock.String()) + slog.InfoContext(ctx, "The state of building block "+buildingBlockUuid+" is locked by "+lock.String()) if !force { - if running := lock.NoUnfinishedRun(ctx, meshStack.Raw); running != nil { + if running := lock.NoUnfinishedRun(ctx, meshStack.BuildingBlockRun); running != nil { return fmt.Errorf("%w: wait for it to finish, or run again with --%s", running, forceFlagName) } } @@ -69,7 +66,7 @@ By default, the command uses the state stored in the building block's own worksp } else if err != nil { return withRightsHint(err) } - slog.InfoContext(ctx, "Released the lock "+lock.Info.ID+" on the state of building block "+buildingBlockUuid.String()) + slog.InfoContext(ctx, "Released the lock "+lock.Info.ID+" on the state of building block "+buildingBlockUuid) return nil }, } diff --git a/cmd/buildingblock/tfstate/show.go b/cmd/buildingblock/tfstate/show.go index 998dd29a..53a1080f 100644 --- a/cmd/buildingblock/tfstate/show.go +++ b/cmd/buildingblock/tfstate/show.go @@ -3,7 +3,6 @@ package tfstate import ( "errors" "log/slog" - "uuid" "github.com/spf13/cobra" @@ -12,8 +11,6 @@ import ( ) func newShow() *cobra.Command { - var buildingBlockUuid uuid.UUID - return &cobra.Command{ Use: "show ", Short: "Show the state of a building block", @@ -21,8 +18,9 @@ func newShow() *cobra.Command { By default, the command uses the state stored in the building block's own workspace, metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace.`, Example: ` meshstack bb tfstate show 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 | jq .resources`, - Args: internal.UuidArg(&buildingBlockUuid), - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + buildingBlockUuid := args[0] ctx := cmd.Context() meshStack, err := internal.ResolveClient(ctx) if err != nil { @@ -34,7 +32,7 @@ By default, the command uses the state stored in the building block's own worksp } state, err := store.Get(ctx) if errors.Is(err, tfstate.ErrNoState) { - slog.InfoContext(ctx, "Building block "+buildingBlockUuid.String()+" has no state in workspace "+store.Workspace+" yet") + slog.InfoContext(ctx, "Building block "+buildingBlockUuid+" has no state in workspace "+store.Workspace+" yet") return nil } else if err != nil { return withRightsHint(err) diff --git a/cmd/buildingblock/tfstate/tfstate.go b/cmd/buildingblock/tfstate/tfstate.go index 383f6556..8b5bf01f 100644 --- a/cmd/buildingblock/tfstate/tfstate.go +++ b/cmd/buildingblock/tfstate/tfstate.go @@ -4,14 +4,12 @@ import ( "context" "errors" "fmt" - "uuid" "github.com/spf13/cobra" "github.com/meshcloud/meshstack-cli/client" "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/internal/tfstate" - "github.com/meshcloud/meshstack-cli/pkg/setting" ) var rights = `meshStack gives the state only to a login that has the rights of one of these rows, in the workspace that the login works in: @@ -54,12 +52,12 @@ func New() *cobra.Command { // openStore ignores the profile's default workspace. Otherwise that workspace would replace the // building block's own workspace, which the runner stores the state under, for every user who has a // default. -func openStore(ctx context.Context, meshStack client.Client, buildingBlockUuid uuid.UUID) (tfstate.Store, error) { - workspace, err := setting.ResolveWorkspace(ctx, internal.SettingSources()) +func openStore(ctx context.Context, meshStack client.Client, buildingBlockUuid string) (tfstate.Store, error) { + workspace, err := internal.ResolveWorkspace(ctx, internal.IgnoreDefaultWorkspace) if err != nil { return tfstate.Store{}, err } - return tfstate.OpenStore(ctx, meshStack.Raw, workspace, buildingBlockUuid) + return tfstate.OpenStore(ctx, meshStack, workspace, buildingBlockUuid) } func withRightsHint(err error) error { diff --git a/cmd/buildingblock/tfstate/tfstate_test.go b/cmd/buildingblock/tfstate/tfstate_test.go index e56b2d32..8084af15 100644 --- a/cmd/buildingblock/tfstate/tfstate_test.go +++ b/cmd/buildingblock/tfstate/tfstate_test.go @@ -3,6 +3,7 @@ package tfstate_test import ( "bytes" "context" + "encoding/json/jsontext" "errors" "fmt" "log/slog" @@ -17,9 +18,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/cmd/buildingblock/tfstate" "github.com/meshcloud/meshstack-cli/cmd/internal" - "github.com/meshcloud/meshstack-cli/internal/http" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" "github.com/meshcloud/meshstack-cli/internal/testutil/testlogin" @@ -75,7 +76,7 @@ func runChild(steps string) int { address, _ := url.Parse(os.Getenv("TF_HTTP_ADDRESS")) r := httptest.NewRequestWithContext(context.Background(), step, "/", nil) r.SetBasicAuth(os.Getenv("TF_HTTP_USERNAME"), os.Getenv("TF_HTTP_PASSWORD")) - _, _ = http.NewClient(fakemeshstack.UserAgent).DoRequest[[]byte](context.Background(), step, address, http.WithHeaders(r.Header), http.WithBody([]byte(state))) + _ = http.IgnoreResponse(http.NewClient(fakemeshstack.UserAgent).DoRequest(context.Background(), step, address, http.WithHeader("Authorization", r.Header.Get("Authorization")), http.WithJsonRequest(jsontext.Value(state), ""))) } return 0 } @@ -86,7 +87,7 @@ func readAsTheProvider() error { if err != nil { return err } - block, err := meshStack.Raw.DoRequest(ctx, http.MethodGet, "/api/meshobjects/meshbuildingblocks/"+buildingBlockUuid) + block, err := http.ReadAllBody(meshStack.HttpClient.DoRequest(ctx, http.MethodGet, meshStack.Endpoint.JoinPath("/api/meshobjects/meshbuildingblocks/"+buildingBlockUuid))) fmt.Print(string(block)) return err } diff --git a/cmd/buildingblock/trigger_run.go b/cmd/buildingblock/trigger_run.go index bf48d674..d0c6d829 100644 --- a/cmd/buildingblock/trigger_run.go +++ b/cmd/buildingblock/trigger_run.go @@ -1,22 +1,22 @@ package buildingblock import ( + "errors" "fmt" "log/slog" - "uuid" "github.com/spf13/cobra" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/types/hal" "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/cmd/internal/buildingblock" ) func newTriggerRun() *cobra.Command { var ( - output internal.OutputFlag - buildingBlockUuid uuid.UUID - wait bool + output internal.OutputFlag + wait bool ) cmd := &cobra.Command{ @@ -31,14 +31,25 @@ With --` + waitFlagName + `, the command waits until the run has ended. This is 'meshstack api-docs --describe buildingblock.trigger-run' shows the fields of the response.`, Example: ` meshstack buildingblock trigger-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb trigger-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 -o ndjson | jq -r .status.latestRunUuid`, - Args: internal.UuidArg(&buildingBlockUuid), - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + buildingBlockUuid := args[0] ctx := cmd.Context() meshStack, err := internal.ResolveClient(ctx) if err != nil { return err } - answer, err := meshStack.BuildingBlockV2.TriggerRun(ctx, buildingBlockUuid.String()) + block, err := meshStack.BuildingBlockV2.Read(ctx, buildingBlockUuid) + switch { + case err != nil: + return err + case block == nil: + return fmt.Errorf("building block %s not found", buildingBlockUuid) + } + answer, err := block.Links.Post[client.MeshBuildingBlockV2](ctx, "triggerRun", client.MeshBuildingBlockV2RunTrigger{}) + if errors.Is(err, hal.ErrNoLink) { + return fmt.Errorf("meshStack lets you trigger no run of building block %s", buildingBlockUuid) + } if err != nil { return err } @@ -47,20 +58,20 @@ With --` + waitFlagName + `, the command waits until the run has ended. This is accepted = *answer.Status } if wait { - return awaitRunEnd(ctx, cmd.OutOrStdout(), output.Format, meshStack.Raw, buildingBlockUuid, accepted) + return awaitRunEnd(ctx, cmd.OutOrStdout(), output.Format, meshStack.BuildingBlockV2, buildingBlockUuid, accepted) } - runWait := buildingblock.NewRunWait(meshStack.Raw, buildingBlockUuid) - reacted, block, err := runWait.AwaitReaction(ctx, accepted) + runWait := buildingblock.NewRunWait(meshStack.BuildingBlockV2, buildingBlockUuid) + reacted, err := runWait.AwaitReaction(ctx, accepted) if err != nil { return err } - if err := output.Format.WriteItem(cmd.OutOrStdout(), reacted); err != nil { + if err := output.Format.WriteItem(cmd.OutOrStdout(), client.WithKindAndApiVersion(meshStack.BuildingBlockV2, *reacted)); err != nil { return err } - if err := runWait.StartFailure(block); err != nil { + if err := runWait.StartFailure(reacted); err != nil { return err } - slog.InfoContext(ctx, startedRunMessage(buildingBlockUuid, accepted, *block.Status)) + slog.InfoContext(ctx, startedRunMessage(buildingBlockUuid, accepted, *reacted.Status)) return nil }, } @@ -73,7 +84,7 @@ With --` + waitFlagName + `, the command waits until the run has ended. This is const waitFlagName = "wait" -func startedRunMessage(buildingBlockUuid uuid.UUID, accepted, reacted client.MeshBuildingBlockV2Status) string { +func startedRunMessage(buildingBlockUuid string, accepted, reacted client.MeshBuildingBlockV2Status) string { switch dryRun, run := reacted.GetLatestDryRunUuid(), reacted.GetLatestRunUuid(); { case dryRun != "" && dryRun != accepted.GetLatestDryRunUuid(): return fmt.Sprintf("meshStack started the preflight run %s of building block %s. The run that makes the changes then waits for approval. "+ diff --git a/cmd/buildingblock/trigger_run_test.go b/cmd/buildingblock/trigger_run_test.go index 90ea4577..e656efea 100644 --- a/cmd/buildingblock/trigger_run_test.go +++ b/cmd/buildingblock/trigger_run_test.go @@ -25,8 +25,9 @@ func TestTriggerRunPrintsTheBuildingBlockAndFailsWithTheReasonMeshStackCouldNotS ) failure := func(failedOn string) string { return fmt.Sprintf(`{"metadata": {"uuid": %q, "ownedByWorkspace": "my-workspace"}, "spec": {}, `+ - `"status": {"action": "PENDING", "latestRunUuid": "a0000000-0000-4000-8000-000000000001", "runStartFailure": %q, "runStartFailedOn": %q}}`, - buildingBlockUuid, reason, failedOn) + `"status": {"action": "PENDING", "latestRunUuid": "a0000000-0000-4000-8000-000000000001", "runStartFailure": %q, "runStartFailedOn": %q}, `+ + `"_links": {"triggerRun": {"href": "/api/meshobjects/meshbuildingblocks/%s/trigger-run"}}}`, + buildingBlockUuid, reason, failedOn, buildingBlockUuid) } meshStack := fakemeshstack.Start(t, fakemeshstack.Options{BuildingBlocks: []any{jsontext.Value(failure("2026-10-01T10:05:00Z"))}}) meshStack.Route("POST /api/meshobjects/meshbuildingblocks/{uuid}/trigger-run", func(w gohttp.ResponseWriter, _ *gohttp.Request) { diff --git a/cmd/buildingblock/wait.go b/cmd/buildingblock/wait.go index c420e28f..2ac602d6 100644 --- a/cmd/buildingblock/wait.go +++ b/cmd/buildingblock/wait.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "log/slog" - "uuid" "github.com/spf13/cobra" @@ -16,9 +15,8 @@ import ( func newWait() *cobra.Command { var ( - output internal.OutputFlag - buildingBlockUuid uuid.UUID - beforeInput internal.JsonInputFlag[client.MeshBuildingBlockV2] + output internal.OutputFlag + beforeInput internal.JsonInputFlag[client.MeshBuildingBlockV2] ) cmd := &cobra.Command{ @@ -31,8 +29,9 @@ meshStack responds to a write before it starts the run. So right after the write The command prints the building block. It fails and names the action if the run failed, was aborted or could not start, or if the run is waiting for a person or a dependency.`, Example: ` meshstack api -X PUT /api/meshobjects/meshbuildingblocks/0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --request-json block.json | meshstack bb wait 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --before=-`, - Args: internal.UuidArg(&buildingBlockUuid), - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + buildingBlockUuid := args[0] before, err := readBefore(cmd, beforeInput, buildingBlockUuid) if err != nil { return err @@ -41,7 +40,7 @@ The command prints the building block. It fails and names the action if the run if err != nil { return err } - return awaitRunEnd(cmd.Context(), cmd.OutOrStdout(), output.Format, meshStack.Raw, buildingBlockUuid, before) + return awaitRunEnd(cmd.Context(), cmd.OutOrStdout(), output.Format, meshStack.BuildingBlockV2, buildingBlockUuid, before) }, } @@ -54,14 +53,14 @@ The command prints the building block. It fails and names the action if the run const beforeFlagName = "before" -func readBefore(cmd *cobra.Command, input internal.JsonInputFlag[client.MeshBuildingBlockV2], buildingBlockUuid uuid.UUID) (client.MeshBuildingBlockV2Status, error) { +func readBefore(cmd *cobra.Command, input internal.JsonInputFlag[client.MeshBuildingBlockV2], buildingBlockUuid string) (client.MeshBuildingBlockV2Status, error) { source := input.Source before, err := input.Read(cmd) if err != nil { return client.MeshBuildingBlockV2Status{}, err } switch { - case before.Metadata.Uuid != nil && *before.Metadata.Uuid != buildingBlockUuid.String(): + case before.Metadata.Uuid != nil && *before.Metadata.Uuid != buildingBlockUuid: return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--%s %s contains building block %s, but the command waits for %s", beforeFlagName, source, *before.Metadata.Uuid, buildingBlockUuid) case before.Status == nil: return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--%s %s contains no status of the building block", beforeFlagName, source) @@ -71,13 +70,13 @@ func readBefore(cmd *cobra.Command, input internal.JsonInputFlag[client.MeshBuil // awaitRunEnd writes the building block once its run has ended, and fails on an outcome someone has // to act on. -func awaitRunEnd(ctx context.Context, out io.Writer, format internal.OutputFormat, raw *client.RawClient, buildingBlockUuid uuid.UUID, before client.MeshBuildingBlockV2Status) error { - wait := buildingblock.NewRunWait(raw, buildingBlockUuid) - ended, block, err := wait.AwaitEnd(ctx, before) +func awaitRunEnd(ctx context.Context, out io.Writer, format internal.OutputFormat, blocks client.MeshBuildingBlockV2Client, buildingBlockUuid string, before client.MeshBuildingBlockV2Status) error { + wait := buildingblock.NewRunWait(blocks, buildingBlockUuid) + block, err := wait.AwaitEnd(ctx, before) if err != nil { return err } - if err = format.WriteItem(out, ended); err != nil { + if err = format.WriteItem(out, client.WithKindAndApiVersion(blocks, *block)); err != nil { return err } if err = wait.Outcome(block); err != nil { diff --git a/cmd/buildingblockdefinition/list.go b/cmd/buildingblockdefinition/list.go index c6896d08..ed1395ac 100644 --- a/cmd/buildingblockdefinition/list.go +++ b/cmd/buildingblockdefinition/list.go @@ -22,7 +22,11 @@ func newList() *cobra.Command { if err != nil { return err } - return flags.Run[client.MeshBuildingBlockDefinition](cmd, client.MeshBuildingBlockDefinitionListFilter{ + meshStack, err := internal.ResolveClient(cmd.Context()) + if err != nil { + return err + } + return flags.Run(cmd, meshStack.BuildingBlockDefinition, client.MeshBuildingBlockDefinitionFilter{ OwnedByWorkspace: workspace, IncludeAllPublished: true, }) diff --git a/cmd/buildingblockdefinitionversion/list.go b/cmd/buildingblockdefinitionversion/list.go index f649218d..b66b1c27 100644 --- a/cmd/buildingblockdefinitionversion/list.go +++ b/cmd/buildingblockdefinitionversion/list.go @@ -1,8 +1,6 @@ package buildingblockdefinitionversion import ( - "uuid" - "github.com/spf13/cobra" "github.com/meshcloud/meshstack-cli/client" @@ -14,7 +12,7 @@ const definitionFlagName = "definition" func newList() *cobra.Command { var ( flags internal.ListFlags - definitionUuid uuid.UUID + definitionUuid string ) cmd := &cobra.Command{ @@ -27,13 +25,17 @@ Give the definition by its uuid; 'meshstack buildingblockdefinition list' shows meshstack bbdv list --definition 3c9e1f7a-2b4d-4e6f-8a1c-5d7b9e2f4a6c --limit 1`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return flags.Run[client.MeshBuildingBlockDefinitionVersion](cmd, client.MeshBuildingBlockDefinitionVersionListFilter{ + meshStack, err := internal.ResolveClient(cmd.Context()) + if err != nil { + return err + } + return flags.Run(cmd, meshStack.BuildingBlockDefinitionVersion, client.MeshBuildingBlockDefinitionVersionFilter{ BuildingBlockDefinitionUuid: definitionUuid, }) }, } - cmd.Flags().Var((*internal.UuidFlag)(&definitionUuid), definitionFlagName, "list the versions of the building block definition with this uuid") + cmd.Flags().StringVar(&definitionUuid, definitionFlagName, "", "list the versions of the building block definition with this uuid") _ = cmd.MarkFlagRequired("definition") flags.Register(cmd.Flags()) diff --git a/cmd/buildingblockrun/list.go b/cmd/buildingblockrun/list.go index bec8a1ea..b829479e 100644 --- a/cmd/buildingblockrun/list.go +++ b/cmd/buildingblockrun/list.go @@ -2,14 +2,10 @@ package buildingblockrun import ( "context" - "encoding/json/jsontext" - "encoding/json/v2" "errors" "fmt" "iter" "slices" - "time" - "uuid" "github.com/spf13/cobra" @@ -22,7 +18,7 @@ const buildingBlockFlagName = "building-block" func newList() *cobra.Command { var ( flags internal.ListFlags - buildingBlockUuid uuid.UUID + buildingBlockUuid string ) cmd := &cobra.Command{ @@ -35,72 +31,64 @@ With --` + buildingBlockFlagName + `, the command lists only the runs of that bu meshstack bbrun list --workspace my-workspace --limit 20`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if buildingBlockUuid != uuid.Nil() { - return flags.Run[client.MeshBuildingBlockRun](cmd, client.MeshBuildingBlockRunListFilter{BuildingBlockUuid: buildingBlockUuid}) + if buildingBlockUuid != "" { + meshStack, err := internal.ResolveClient(cmd.Context()) + if err != nil { + return err + } + return flags.Run(cmd, meshStack.BuildingBlockRun, client.MeshBuildingBlockRunFilter{BuildingBlockUuid: buildingBlockUuid}) } var ( - blockFilter client.MeshBuildingBlockV2ListFilter + blockFilter client.MeshBuildingBlockV2Filter err error ) if blockFilter.WorkspaceIdentifier, err = internal.ListWorkspace(cmd.Context()); err != nil { return err } - return flags.RunSeq(cmd, func(ctx context.Context, meshStack client.Client, options client.ListOptions) iter.Seq2[jsontext.Value, error] { - return allRuns(ctx, meshStack, blockFilter, options.PageSize) + meshStack, err := internal.ResolveClient(cmd.Context()) + if err != nil { + return err + } + return flags.RunSeq(cmd, func(ctx context.Context, options ...client.ListOption) iter.Seq2[any, error] { + return internal.WithKindAndApiVersion(meshStack.BuildingBlockRun, allRuns(ctx, meshStack, blockFilter, options)) }) }, } - cmd.Flags().Var((*internal.UuidFlag)(&buildingBlockUuid), buildingBlockFlagName, "list the runs of the building block with this uuid") + cmd.Flags().StringVar(&buildingBlockUuid, buildingBlockFlagName, "", "list the runs of the building block with this uuid") flags.Register(cmd.Flags()) return cmd } -// runsPageSizeOfEachBlock caps the page size a limited listing asks for, which is its limit, for the -// runs of each building block: a listing of every building block reads the first page of each one -// before it lists a single run. An unlimited listing reads every page anyway, and takes meshStack's -// default page size. +// runsPageSizeOfEachBlock is small, because a listing reads the first page of every block's runs. const runsPageSizeOfEachBlock = 10 // allRuns merges the runs of every building block, because the run list endpoint takes one building // block at a time and has no list of every run. -func allRuns(ctx context.Context, meshStack client.Client, blockFilter client.MeshBuildingBlockV2ListFilter, pageSize int) iter.Seq2[jsontext.Value, error] { - if pageSize > 0 { - pageSize = min(pageSize, runsPageSizeOfEachBlock) - } - return func(yield func(jsontext.Value, error) bool) { - var runsOfEachBlock []iter.Seq2[jsontext.Value, error] - // The blocks are read raw because only their uuid is needed, and a block the client cannot - // fully decode still has runs to list. - for rawBlock, err := range meshStack.Raw.List[client.MeshBuildingBlockV2](ctx, blockFilter, client.ListOptions{}) { +func allRuns(ctx context.Context, meshStack client.Client, blockFilter client.MeshBuildingBlockV2Filter, options []client.ListOption) iter.Seq2[client.MeshBuildingBlockRun, error] { + options = append(options, client.WithPageSize(runsPageSizeOfEachBlock)) + return func(yield func(client.MeshBuildingBlockRun, error) bool) { + var runsOfEachBlock []iter.Seq2[client.MeshBuildingBlockRun, error] + for block, err := range meshStack.BuildingBlockV2.List(ctx, blockFilter) { if err != nil { - yield(nil, err) + yield(client.MeshBuildingBlockRun{}, err) return } - var buildingBlock struct { - Metadata struct { - Uuid uuid.UUID `json:"uuid"` - } `json:"metadata"` - } - if err := json.Unmarshal(rawBlock, &buildingBlock); err != nil { - yield(nil, err) - return - } - if buildingBlock.Metadata.Uuid == uuid.Nil() { - yield(nil, errors.New("building block has no metadata.uuid: this must be a bug in the meshStack CLI or meshStack")) + if block.Metadata.Uuid == nil { + yield(client.MeshBuildingBlockRun{}, errors.New("building block has no metadata.uuid: this must be a bug in the meshStack CLI or meshStack")) return } - runsOfEachBlock = append(runsOfEachBlock, runsOf(ctx, meshStack, buildingBlock.Metadata.Uuid, pageSize)) + runsOfEachBlock = append(runsOfEachBlock, runsOf(ctx, meshStack, *block.Metadata.Uuid, options)) } mergeNewestFirst(runsOfEachBlock)(yield) } } -func runsOf(ctx context.Context, meshStack client.Client, buildingBlockUuid uuid.UUID, pageSize int) iter.Seq2[jsontext.Value, error] { - return func(yield func(jsontext.Value, error) bool) { - filter := client.MeshBuildingBlockRunListFilter{BuildingBlockUuid: buildingBlockUuid} - for run, err := range meshStack.Raw.List[client.MeshBuildingBlockRun](ctx, filter, client.ListOptions{PageSize: pageSize}) { +func runsOf(ctx context.Context, meshStack client.Client, buildingBlockUuid string, options []client.ListOption) iter.Seq2[client.MeshBuildingBlockRun, error] { + return func(yield func(client.MeshBuildingBlockRun, error) bool) { + filter := client.MeshBuildingBlockRunFilter{BuildingBlockUuid: buildingBlockUuid} + for run, err := range meshStack.BuildingBlockRun.List(ctx, filter, options...) { if err != nil { err = fmt.Errorf("listing the runs of building block %s: %w", buildingBlockUuid, err) } @@ -114,8 +102,8 @@ func runsOf(ctx context.Context, meshStack client.Client, buildingBlockUuid uuid // mergeNewestFirst merges runs that each come newest first. It reads the first run of every one // before it yields any, and after that reads on only in the runs it yielded the head of. Runs created // at the same time keep the order of the sequences they come from. -func mergeNewestFirst(runs []iter.Seq2[jsontext.Value, error]) iter.Seq2[jsontext.Value, error] { - return func(yield func(jsontext.Value, error) bool) { +func mergeNewestFirst(runs []iter.Seq2[client.MeshBuildingBlockRun, error]) iter.Seq2[client.MeshBuildingBlockRun, error] { + return func(yield func(client.MeshBuildingBlockRun, error) bool) { heads := make([]*runHead, 0, len(runs)) stops := make([]func(), 0, len(runs)) defer func() { @@ -128,7 +116,7 @@ func mergeNewestFirst(runs []iter.Seq2[jsontext.Value, error]) iter.Seq2[jsontex stops = append(stops, stop) head := &runHead{next: next} if ok, err := head.advance(); err != nil { - yield(nil, err) + yield(client.MeshBuildingBlockRun{}, err) return } else if ok { heads = append(heads, head) @@ -137,7 +125,7 @@ func mergeNewestFirst(runs []iter.Seq2[jsontext.Value, error]) iter.Seq2[jsontex for len(heads) > 0 { newest := 0 for i, head := range heads { - if head.createdAt.After(heads[newest].createdAt) { + if head.run.Metadata.CreatedAt.After(heads[newest].run.Metadata.CreatedAt) { newest = i } } @@ -145,7 +133,7 @@ func mergeNewestFirst(runs []iter.Seq2[jsontext.Value, error]) iter.Seq2[jsontex return } if ok, err := heads[newest].advance(); err != nil { - yield(nil, err) + yield(client.MeshBuildingBlockRun{}, err) return } else if !ok { heads = slices.Delete(heads, newest, newest+1) @@ -155,9 +143,8 @@ func mergeNewestFirst(runs []iter.Seq2[jsontext.Value, error]) iter.Seq2[jsontex } type runHead struct { - next func() (jsontext.Value, error, bool) - run jsontext.Value - createdAt time.Time + next func() (client.MeshBuildingBlockRun, error, bool) + run client.MeshBuildingBlockRun } func (h *runHead) advance() (bool, error) { @@ -165,12 +152,6 @@ func (h *runHead) advance() (bool, error) { if !ok || err != nil { return false, err } - var created struct { - Metadata client.MeshBuildingBlockRunMetadata `json:"metadata"` - } - if err := json.Unmarshal(run, &created); err != nil { - return false, fmt.Errorf("reading the metadata.createdAt of a building block run: %w", err) - } - h.run, h.createdAt = run, created.Metadata.CreatedAt + h.run = run return true, nil } diff --git a/cmd/buildingblockrun/list_internal_test.go b/cmd/buildingblockrun/list_internal_test.go index 0e0c5768..c2228a15 100644 --- a/cmd/buildingblockrun/list_internal_test.go +++ b/cmd/buildingblockrun/list_internal_test.go @@ -1,72 +1,70 @@ package buildingblockrun import ( - "encoding/json/jsontext" - "encoding/json/v2" "errors" "fmt" "iter" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client" ) // blockRuns yields a run named block/i, created at the i-th of createdAts, and counts into pulled the -// runs a reader took from it. -func blockRuns(block string, pulled *int, createdAts ...string) iter.Seq2[jsontext.Value, error] { - return func(yield func(jsontext.Value, error) bool) { +// runs a reader took from it. The name goes into the first two bytes of the run's uuid. +func blockRuns(t *testing.T, block string, pulled *int, createdAts ...string) iter.Seq2[client.MeshBuildingBlockRun, error] { + t.Helper() + return func(yield func(client.MeshBuildingBlockRun, error) bool) { for i, createdAt := range createdAts { *pulled++ - run := fmt.Sprintf(`{"metadata": {"uuid": "%s/%d", "createdAt": %q}}`, block, i, createdAt) - if !yield(jsontext.Value(run), nil) { + created, err := time.Parse(time.RFC3339Nano, createdAt) + require.NoError(t, err) + id := string([]byte{block[0], byte(i)}) + if !yield(client.MeshBuildingBlockRun{Metadata: client.MeshBuildingBlockRunMetadata{Uuid: id, CreatedAt: created}}, nil) { return } } } } -func uuidsOf(t *testing.T, runs iter.Seq2[jsontext.Value, error], n int) []string { +func namesOf(t *testing.T, runs iter.Seq2[client.MeshBuildingBlockRun, error], n int) []string { t.Helper() - var uuids []string + var names []string for run, err := range runs { require.NoError(t, err) - var listed struct { - Metadata struct { - Uuid string `json:"uuid"` - } `json:"metadata"` - } - require.NoError(t, json.Unmarshal(run, &listed)) - if uuids = append(uuids, listed.Metadata.Uuid); len(uuids) == n { + if names = append(names, fmt.Sprintf("%c/%d", run.Metadata.Uuid[0], run.Metadata.Uuid[1])); len(names) == n { break } } - return uuids + return names } func TestMergeNewestFirst(t *testing.T) { t.Run("lists the runs of every block newest first", func(t *testing.T) { var pulled int - merged := mergeNewestFirst([]iter.Seq2[jsontext.Value, error]{ - blockRuns("a", &pulled, "2026-10-01T05:00:00Z", "2026-10-01T03:00:00Z", "2026-10-01T01:00:00Z"), - blockRuns("empty", &pulled), - blockRuns("c", &pulled, "2026-10-01T04:00:00Z", "2026-10-01T03:00:00Z", "2026-10-01T00:00:00Z"), - blockRuns("d", &pulled, "2026-10-01T03:00:00.000Z"), + merged := mergeNewestFirst([]iter.Seq2[client.MeshBuildingBlockRun, error]{ + blockRuns(t, "a", &pulled, "2026-10-01T05:00:00Z", "2026-10-01T03:00:00Z", "2026-10-01T01:00:00Z"), + blockRuns(t, "empty", &pulled), + blockRuns(t, "c", &pulled, "2026-10-01T04:00:00Z", "2026-10-01T03:00:00Z", "2026-10-01T00:00:00Z"), + blockRuns(t, "d", &pulled, "2026-10-01T03:00:00.000Z"), }) - assert.Equal(t, []string{"a/0", "c/0", "a/1", "c/1", "d/0", "a/2", "c/2"}, uuidsOf(t, merged, 0), + assert.Equal(t, []string{"a/0", "c/0", "a/1", "c/1", "d/0", "a/2", "c/2"}, namesOf(t, merged, 0), "runs created at the same time keep the order of their blocks") }) t.Run("reads no block further than its first run or the last run it listed", func(t *testing.T) { var pulledA, pulledB, pulledC int - merged := mergeNewestFirst([]iter.Seq2[jsontext.Value, error]{ - blockRuns("a", &pulledA, "2026-10-01T05:00:00Z", "2026-10-01T04:00:00Z", "2026-10-01T03:00:00Z"), - blockRuns("b", &pulledB, "2026-10-01T02:00:00Z", "2026-10-01T01:00:00Z"), - blockRuns("c", &pulledC, "2026-10-01T01:00:00Z"), + merged := mergeNewestFirst([]iter.Seq2[client.MeshBuildingBlockRun, error]{ + blockRuns(t, "a", &pulledA, "2026-10-01T05:00:00Z", "2026-10-01T04:00:00Z", "2026-10-01T03:00:00Z"), + blockRuns(t, "b", &pulledB, "2026-10-01T02:00:00Z", "2026-10-01T01:00:00Z"), + blockRuns(t, "c", &pulledC, "2026-10-01T01:00:00Z"), }) - assert.Equal(t, []string{"a/0", "a/1"}, uuidsOf(t, merged, 2)) + assert.Equal(t, []string{"a/0", "a/1"}, namesOf(t, merged, 2)) assert.Equal(t, 2, pulledA, "no further than the last listed run of a") assert.Equal(t, 1, pulledB, "only the first run of b, before the merge listed any") assert.Equal(t, 1, pulledC, "only the first run of c, before the merge listed any") @@ -75,9 +73,11 @@ func TestMergeNewestFirst(t *testing.T) { t.Run("yields only the error of a block that fails before every block answered", func(t *testing.T) { var pulled int failure := errors.New("listing the runs of building block b failed") - merged := mergeNewestFirst([]iter.Seq2[jsontext.Value, error]{ - blockRuns("a", &pulled, "2026-10-01T05:00:00Z"), - func(yield func(jsontext.Value, error) bool) { yield(nil, failure) }, + merged := mergeNewestFirst([]iter.Seq2[client.MeshBuildingBlockRun, error]{ + blockRuns(t, "a", &pulled, "2026-10-01T05:00:00Z"), + func(yield func(client.MeshBuildingBlockRun, error) bool) { + yield(client.MeshBuildingBlockRun{}, failure) + }, }) var errs []error diff --git a/cmd/buildingblockrun/logs.go b/cmd/buildingblockrun/logs.go index 33b5a37c..e8fc58ab 100644 --- a/cmd/buildingblockrun/logs.go +++ b/cmd/buildingblockrun/logs.go @@ -2,14 +2,12 @@ package buildingblockrun import ( "context" - "encoding/json/v2" "errors" "fmt" "io" "log/slog" "strings" "time" - "uuid" "github.com/spf13/cobra" @@ -21,9 +19,8 @@ const followFlagName = "follow" func newLogs() *cobra.Command { var ( - output internal.OutputFlag - follow bool - runUuid uuid.UUID + output internal.OutputFlag + follow bool ) cmd := &cobra.Command{ @@ -37,8 +34,9 @@ With --` + followFlagName + `, the command prints new log lines as text until th Example: ` meshstack buildingblockrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c meshstack bbrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c -o ndjson meshstack bbrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c --follow`, - Args: internal.UuidArg(&runUuid), - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + runUuid := args[0] ctx := cmd.Context() meshStack, err := internal.ResolveClient(ctx) if err != nil { @@ -46,10 +44,14 @@ With --` + followFlagName + `, the command prints new log lines as text until th } if follow { return followRun(ctx, cmd.OutOrStdout(), runUuid, func(ctx context.Context) (runSnapshot, error) { - return readRun(ctx, meshStack, runUuid) + return readSnapshot(ctx, meshStack, runUuid) }) } - logs, err := meshStack.Raw.Get[client.MeshBuildingBlockRun](ctx, runUuid, "logs") + run, err := readRun(ctx, meshStack, runUuid) + if err != nil { + return err + } + logs, err := run.Links.Get[client.MeshBuildingBlockRunLogs](ctx, "downloadLogs") if err != nil { return err } @@ -78,7 +80,7 @@ const ( const ( pollInterval = 2 * time.Second maxPollWait = time.Minute - // The HTTP client has retried each failed poll for minutes already, see internal/http. + // The HTTP client has retried each failed poll for minutes already, see client/http. maxFailedPollsInARow = 5 ) @@ -87,27 +89,33 @@ type runSnapshot struct { steps []client.MeshBuildingBlockRunStepLog } -// readRun reads the run's status before its logs, so that the logs of a finished run are its -// last ones. -func readRun(ctx context.Context, meshStack client.Client, runUuid uuid.UUID) (runSnapshot, error) { - rawRun, err := meshStack.Raw.Get[client.MeshBuildingBlockRun](ctx, runUuid) - if err != nil { - return runSnapshot{}, err - } - var run struct { - Status string `json:"status"` +var errRunNotFound = errors.New("building block run not found") + +func readRun(ctx context.Context, meshStack client.Client, runUuid string) (client.MeshBuildingBlockRun, error) { + run, err := meshStack.BuildingBlockRun.Read(ctx, runUuid) + switch { + case err != nil: + return client.MeshBuildingBlockRun{}, err + case run == nil: + return client.MeshBuildingBlockRun{}, fmt.Errorf("%w: %s", errRunNotFound, runUuid) } - if err = json.Unmarshal(rawRun, &run); err != nil { + return *run, nil +} + +// readSnapshot reads the status before the logs, so that a finished run's logs are its last ones. +func readSnapshot(ctx context.Context, meshStack client.Client, runUuid string) (runSnapshot, error) { + run, err := readRun(ctx, meshStack, runUuid) + if err != nil { return runSnapshot{}, err } - logs, err := meshStack.BuildingBlockRun.GetLogs(ctx, runUuid.String()) + logs, err := run.Links.Get[client.MeshBuildingBlockRunLogs](ctx, "downloadLogs") if err != nil { return runSnapshot{}, err } return runSnapshot{status: run.Status, steps: logs.Steps}, nil } -func followRun(ctx context.Context, w io.Writer, runUuid uuid.UUID, read func(context.Context) (runSnapshot, error)) error { +func followRun(ctx context.Context, w io.Writer, runUuid string, read func(context.Context) (runSnapshot, error)) error { var ( written writtenLogs readOnce bool @@ -153,7 +161,7 @@ func followRun(ctx context.Context, w io.Writer, runUuid uuid.UUID, read func(co } } -func stoppedFollowing(runUuid uuid.UUID) error { +func stoppedFollowing(runUuid string) error { return fmt.Errorf("stopped following building block run %s before it finished", runUuid) } @@ -161,10 +169,10 @@ func stoppedFollowing(runUuid uuid.UUID) error { // deleted, from a failure that can clear. func isClientError(err error) bool { httpErr, ok := errors.AsType[client.HttpError](err) - return ok && httpErr.IsClientError() + return ok && httpErr.IsClientError() || errors.Is(err, errRunNotFound) } -func (run runSnapshot) failure(runUuid uuid.UUID) error { +func (run runSnapshot) failure(runUuid string) error { var abortedStep string for _, step := range run.steps { switch step.Status { diff --git a/cmd/buildingblockrun/logs_test.go b/cmd/buildingblockrun/logs_test.go index e063f63c..6d73beb7 100644 --- a/cmd/buildingblockrun/logs_test.go +++ b/cmd/buildingblockrun/logs_test.go @@ -9,7 +9,6 @@ import ( "testing" "testing/synctest" "time" - "uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,7 +16,7 @@ import ( "github.com/meshcloud/meshstack-cli/client" ) -var runUuid = uuid.MustParse("7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c") +var runUuid = "7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c" func step(displayName, status string, userMessage, systemMessage *string) client.MeshBuildingBlockRunStepLog { return client.MeshBuildingBlockRunStepLog{DisplayName: displayName, Status: status, UserMessage: userMessage, SystemMessage: systemMessage} diff --git a/cmd/eventlog/list.go b/cmd/eventlog/list.go index c979a853..9f0f76a7 100644 --- a/cmd/eventlog/list.go +++ b/cmd/eventlog/list.go @@ -43,7 +43,7 @@ The command lists the event logs of the workspace. With an admin credential and if err != nil { return err } - filter := client.MeshEventLogListFilter{ + filter := client.MeshEventLogFilter{ From: from.instant, Until: until.instant, Title: title, @@ -52,7 +52,11 @@ The command lists the event logs of the workspace. With an admin credential and if workspace != nil { filter.WorkspaceIdentifier = *workspace } - return flags.Run[client.MeshEventLog](cmd, filter) + meshStack, err := internal.ResolveClient(cmd.Context()) + if err != nil { + return err + } + return flags.Run(cmd, meshStack.EventLog, filter) }, } diff --git a/cmd/internal/buildingblock/run_wait.go b/cmd/internal/buildingblock/run_wait.go index 57ec7780..49f22319 100644 --- a/cmd/internal/buildingblock/run_wait.go +++ b/cmd/internal/buildingblock/run_wait.go @@ -4,11 +4,8 @@ package buildingblock import ( "context" - "encoding/json/jsontext" - "encoding/json/v2" "fmt" "time" - "uuid" "github.com/meshcloud/meshstack-cli/client" ) @@ -23,39 +20,39 @@ const ( ) type RunWait struct { - raw *client.RawClient - buildingBlockUuid uuid.UUID + blocks client.MeshBuildingBlockV2Client + buildingBlockUuid string startEvery time.Duration startWithin time.Duration endEvery time.Duration } -func NewRunWait(raw *client.RawClient, buildingBlockUuid uuid.UUID) RunWait { - return RunWait{raw, buildingBlockUuid, runStartPollInterval, runStartTimeout, runEndPollInterval} +func NewRunWait(blocks client.MeshBuildingBlockV2Client, buildingBlockUuid string) RunWait { + return RunWait{blocks, buildingBlockUuid, runStartPollInterval, runStartTimeout, runEndPollInterval} } // AwaitReaction returns the building block once meshStack has reacted to a write since before. -func (w RunWait) AwaitReaction(ctx context.Context, before client.MeshBuildingBlockV2Status) (jsontext.Value, *client.MeshBuildingBlockV2, error) { +func (w RunWait) AwaitReaction(ctx context.Context, before client.MeshBuildingBlockV2Status) (*client.MeshBuildingBlockV2, error) { ctx, cancel := context.WithTimeoutCause(ctx, w.startWithin, fmt.Errorf( "meshStack did not react to the write of building block %s within %s: it started no run and reported no failure to start one", w.buildingBlockUuid, w.startWithin)) defer cancel() - reacted, block, err := w.poll(ctx, w.startEvery, func(block *client.MeshBuildingBlockV2) (bool, error) { + block, err := w.poll(ctx, w.startEvery, func(block *client.MeshBuildingBlockV2) (bool, error) { return block.Status != nil && block.Status.RunChangedSince(before), nil }) - return reacted, block, withMeshPanelLink(err, block) + return block, withMeshPanelLink(err, block) } // AwaitEnd returns the building block once meshStack has reacted to a write since before, and the run // is no longer running. -func (w RunWait) AwaitEnd(ctx context.Context, before client.MeshBuildingBlockV2Status) (jsontext.Value, *client.MeshBuildingBlockV2, error) { - if _, _, err := w.AwaitReaction(ctx, before); err != nil { - return nil, nil, err +func (w RunWait) AwaitEnd(ctx context.Context, before client.MeshBuildingBlockV2Status) (*client.MeshBuildingBlockV2, error) { + if _, err := w.AwaitReaction(ctx, before); err != nil { + return nil, err } - ended, block, err := w.poll(ctx, w.endEvery, func(block *client.MeshBuildingBlockV2) (bool, error) { + block, err := w.poll(ctx, w.endEvery, func(block *client.MeshBuildingBlockV2) (bool, error) { return block.RunEndedSince(client.MeshBuildingBlockV2Status{}) }) - return ended, block, withMeshPanelLink(err, block) + return block, withMeshPanelLink(err, block) } func (w RunWait) StartFailure(block *client.MeshBuildingBlockV2) error { @@ -81,34 +78,36 @@ func (w RunWait) Outcome(block *client.MeshBuildingBlockV2) error { } func withMeshPanelLink(err error, block *client.MeshBuildingBlockV2) error { - if link := block.MeshPanelLink(); err != nil && link != "" { - return fmt.Errorf("%w. Open it in meshPanel: %s", err, link) + if err == nil || block == nil { + return err + } + if meshPanel, found := block.Links["meshPanel"]; found { + return fmt.Errorf("%w. Open it in meshPanel: %s", err, meshPanel.Href) } return err } // poll reads the building block every interval until done says so, and returns the last read, also // when ctx ends the poll. -func (w RunWait) poll(ctx context.Context, every time.Duration, done func(*client.MeshBuildingBlockV2) (bool, error)) (jsontext.Value, *client.MeshBuildingBlockV2, error) { +func (w RunWait) poll(ctx context.Context, every time.Duration, done func(*client.MeshBuildingBlockV2) (bool, error)) (*client.MeshBuildingBlockV2, error) { var last *client.MeshBuildingBlockV2 for { - current, err := w.raw.Get[client.MeshBuildingBlockV2](ctx, w.buildingBlockUuid) - if err != nil && ctx.Err() != nil { - return nil, last, context.Cause(ctx) - } else if err != nil { - return nil, last, err - } - var block client.MeshBuildingBlockV2 - if err := json.Unmarshal(current, &block); err != nil { - return nil, last, err + current, err := w.blocks.Read(ctx, w.buildingBlockUuid) + switch { + case err != nil && ctx.Err() != nil: + return last, context.Cause(ctx) + case err != nil: + return last, err + case current == nil: + return last, fmt.Errorf("building block %s not found", w.buildingBlockUuid) } - last = &block + last = current if ended, err := done(last); ended || err != nil { - return current, last, err + return last, err } select { case <-ctx.Done(): - return nil, last, context.Cause(ctx) + return last, context.Cause(ctx) case <-time.After(every): } } diff --git a/cmd/internal/buildingblock/run_wait_test.go b/cmd/internal/buildingblock/run_wait_test.go index 8f5109a8..9c53ed52 100644 --- a/cmd/internal/buildingblock/run_wait_test.go +++ b/cmd/internal/buildingblock/run_wait_test.go @@ -6,19 +6,18 @@ import ( "sync/atomic" "testing" "time" - "uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/types/xurl" - "github.com/meshcloud/meshstack-cli/internal/http" "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" ) func TestRunWait(t *testing.T) { - buildingBlockUuid := uuid.MustParse("b1d2c3e4-0000-4000-8000-000000000003") + buildingBlockUuid := "b1d2c3e4-0000-4000-8000-000000000003" before := client.MeshBuildingBlockV2Status{Action: client.BuildingBlockActionNoAction, LatestRunUuid: new("run-1")} var status atomic.Pointer[string] meshStack := fakemeshstack.Start(t, fakemeshstack.Options{}) @@ -26,13 +25,14 @@ func TestRunWait(t *testing.T) { _, _ = fmt.Fprintf(w, `{"metadata": {"uuid": %q}, "spec": {}, "status": {%s}, "_links": {"meshPanel": {"href": "https://panel.example/block"}}}`, buildingBlockUuid, *status.Load()) }) - raw := client.New(t.Context(), xurl.MustParsef("%s", meshStack.URL), http.NewClient(fakemeshstack.UserAgent), http.BearerToken(fakemeshstack.Token)).Raw - wait := RunWait{raw: raw, buildingBlockUuid: buildingBlockUuid, startEvery: time.Millisecond, startWithin: 50 * time.Millisecond} + blocks := client.New(t.Context(), xurl.MustParsef("%s", meshStack.URL), http.NewClient(fakemeshstack.UserAgent), http.BearerToken(fakemeshstack.Token)).BuildingBlockV2 + wait := NewRunWait(blocks, buildingBlockUuid) + wait.startEvery, wait.startWithin = time.Millisecond, 50*time.Millisecond t.Run("a preflight run that a trigger with an approval policy starts is a reaction", func(t *testing.T) { status.Store(new(`"action": "IN_PROGRESS", "latestRunUuid": "run-1", "latestDryRunUuid": "preflight-1"`)) - _, block, err := wait.AwaitReaction(t.Context(), before) + block, err := wait.AwaitReaction(t.Context(), before) require.NoError(t, err) assert.Equal(t, "preflight-1", block.Status.GetLatestDryRunUuid()) @@ -41,18 +41,18 @@ func TestRunWait(t *testing.T) { t.Run("fails when meshStack does not react within the start window", func(t *testing.T) { status.Store(new(`"action": "NO_ACTION", "latestRunUuid": "run-1"`)) - _, _, err := wait.AwaitReaction(t.Context(), before) + _, err := wait.AwaitReaction(t.Context(), before) - require.ErrorContains(t, err, "meshStack did not react to the write of building block "+buildingBlockUuid.String()+" within 50ms") + require.ErrorContains(t, err, "meshStack did not react to the write of building block "+buildingBlockUuid+" within 50ms") require.ErrorContains(t, err, "https://panel.example/block") }) t.Run("fails with the reason meshStack could not start the run", func(t *testing.T) { status.Store(new(`"action": "REVIEW_ERRORS", "latestRunUuid": "run-1", "runStartFailure": "no runner", "runStartFailedOn": "2026-10-29T10:00:00Z"`)) - _, block, err := wait.AwaitReaction(t.Context(), before) + block, err := wait.AwaitReaction(t.Context(), before) require.NoError(t, err) - require.ErrorContains(t, wait.StartFailure(block), "meshStack could not start the run of building block "+buildingBlockUuid.String()+": no runner") + require.ErrorContains(t, wait.StartFailure(block), "meshStack could not start the run of building block "+buildingBlockUuid+": no runner") }) } diff --git a/cmd/internal/client.go b/cmd/internal/client.go index 01fb4bbd..66a777e6 100644 --- a/cmd/internal/client.go +++ b/cmd/internal/client.go @@ -36,6 +36,14 @@ func ResolveClient(ctx context.Context, modifiers ...ResolveClientOptionsModifie return auth.ResolveClient(ctx, ResolveClientOptions(modifiers...)) } +func IgnoreDefaultWorkspace(opts *auth.ResolveClientOptions) { + opts.IgnoreDefaultWorkspace = true +} + +func ResolveWorkspace(ctx context.Context, modifiers ...ResolveClientOptionsModifier) (string, error) { + return auth.ResolveWorkspace(ctx, ResolveClientOptions(modifiers...)) +} + func ResolveSession(ctx context.Context, modifiers ...ResolveClientOptionsModifier) (auth.Session, error) { return auth.ResolveSession(ctx, ResolveClientOptions(modifiers...)) } diff --git a/cmd/internal/color/color.go b/cmd/internal/color/color.go index 6e32039c..a8d923ad 100644 --- a/cmd/internal/color/color.go +++ b/cmd/internal/color/color.go @@ -87,7 +87,7 @@ func (e env) isOff(name string) bool { // evidence, and a log viewer outside the list, such as Jenkins without its AnsiColor plugin, shows // the escape codes as text. So does the system log of a building block run in meshPanel. Its // runner starts a script or tofu with neither CI nor TERM set, which keeps a run plain: see -// cleanSystemEnv in tf-block-runner/tfrun of ../building-block-runner. +// cleanSystemEnv in ../building-block-runner/internal/executor/tf/tfcmd.go. func (e env) ciProfile() colorprofile.Profile { has := func(name string) bool { _, found := e[name]; return found } switch { diff --git a/cmd/internal/flags.go b/cmd/internal/flags.go index 9ee4a753..de69a2ac 100644 --- a/cmd/internal/flags.go +++ b/cmd/internal/flags.go @@ -3,7 +3,6 @@ package internal import ( "context" "fmt" - "uuid" "github.com/spf13/pflag" @@ -38,7 +37,7 @@ func (n FlagName) String() string { return string(n) } -type Flag[T string | bool | uuid.UUID] struct { +type Flag[T string | bool] struct { Name FlagName Shorthand string Help string @@ -46,7 +45,7 @@ type Flag[T string | bool | uuid.UUID] struct { SettingEnvKey string } -func NewFlagForSetting[T string | bool | uuid.UUID](name FlagName, s setting.Setting) Flag[T] { +func NewFlagForSetting[T string | bool](name FlagName, s setting.Setting) Flag[T] { return Flag[T]{Name: name, Help: s.Help(), SettingEnvKey: s.EnvKey()} } @@ -61,8 +60,6 @@ func (flag *Flag[T]) Register(flags *pflag.FlagSet) (flagName string) { flags.StringVarP(v, flag.Name.String(), flag.Shorthand, *v, flag.Help) case *bool: flags.BoolVarP(v, flag.Name.String(), flag.Shorthand, *v, flag.Help) - case *uuid.UUID: - flags.VarP((*UuidFlag)(v), flag.Name.String(), flag.Shorthand, flag.Help) default: panic(fmt.Sprintf("cannot register flag with value type %T", flag.Value)) } diff --git a/cmd/internal/list.go b/cmd/internal/list.go index e175d97e..7f40c0ed 100644 --- a/cmd/internal/list.go +++ b/cmd/internal/list.go @@ -2,17 +2,17 @@ package internal import ( "context" - "encoding/json/jsontext" "fmt" "iter" "log/slog" "strconv" + "time" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/meshcloud/meshstack-cli/client" - "github.com/meshcloud/meshstack-cli/pkg/auth" + "github.com/meshcloud/meshstack-cli/client/types" ) type ListFlags struct { @@ -32,40 +32,49 @@ func (f *ListFlags) Register(flags *pflag.FlagSet) { // ListWorkspace is the workspace the session works in, which a listing keeps to as every other // command does, and nil where the session works in none. func ListWorkspace(ctx context.Context) (*string, error) { - workspace, err := auth.ResolveWorkspace(ctx, ResolveClientOptions()) + workspace, err := ResolveWorkspace(ctx) if err != nil || workspace == "" { return nil, err } return &workspace, nil } -// Run lists the objects of M that filter narrows. -func (f *ListFlags) Run[M any](cmd *cobra.Command, filter any) error { - return f.RunSeq(cmd, func(ctx context.Context, meshStack client.Client, options client.ListOptions) iter.Seq2[jsontext.Value, error] { - return meshStack.Raw.List[M](ctx, filter, options) +func (f *ListFlags) Run[M any, Id ~string, Filter any](cmd *cobra.Command, kind client.MeshObjectClient[M, Id, Filter], filter Filter) error { + var page types.Page + return f.run(cmd, &page, func(ctx context.Context, options ...client.ListOption) iter.Seq2[any, error] { + return WithKindAndApiVersion(kind, kind.List(ctx, filter, append(options, client.WithCapturedPage(&page))...)) }) } +func WithKindAndApiVersion[M any, Id ~string, Filter any](kind client.MeshObjectClient[M, Id, Filter], objects iter.Seq2[M, error]) iter.Seq2[any, error] { + return func(yield func(any, error) bool) { + for object, err := range objects { + if err != nil { + yield(nil, err) + return + } + if !yield(client.WithKindAndApiVersion(kind, object), nil) { + return + } + } + } +} + // RunSeq is Run for a listing that is not one kind's, such as the runs of every building block. -func (f *ListFlags) RunSeq(cmd *cobra.Command, list func(ctx context.Context, meshStack client.Client, options client.ListOptions) iter.Seq2[jsontext.Value, error]) error { +func (f *ListFlags) RunSeq(cmd *cobra.Command, list func(ctx context.Context, options ...client.ListOption) iter.Seq2[any, error]) error { + return f.run(cmd, nil, list) +} + +func (f *ListFlags) run(cmd *cobra.Command, page *types.Page, list func(ctx context.Context, options ...client.ListOption) iter.Seq2[any, error]) error { ctx := cmd.Context() - meshStack, err := ResolveClient(ctx) - if err != nil { - return err - } result := ListResult{Limit: int(f.limit), Defaulted: !cmd.Flags().Changed(limitFlagName)} - listOptions := client.ListOptions{ - PageSize: result.Limit, - OnPage: func(page client.Page) { - if result.Total == nil { - result.Total = &page.TotalElements - } - }, - } - items := counted(First(list(ctx, meshStack, listOptions), result.Limit), &result.Listed) + items := counted(First(list(ctx, client.WithNewestFirst(time.Now()), client.WithPageSize(result.Limit)), result.Limit), &result.Listed) if err := f.output.Format.WriteList(cmd.OutOrStdout(), items); err != nil { return err } + if page != nil { + result.Total = &page.TotalElements + } if note := result.CutShortNote(); note != "" { slog.InfoContext(ctx, note) } diff --git a/cmd/internal/markdown/credential.md.tmpl b/cmd/internal/markdown/credential.md.tmpl index 4250f86c..c092c9ba 100644 --- a/cmd/internal/markdown/credential.md.tmpl +++ b/cmd/internal/markdown/credential.md.tmpl @@ -2,7 +2,7 @@ {{.OidcLogin.AccessLevel.Label}}{{with .Token}}{{with .AccessLevel}}{{if ne . $.OidcLogin.AccessLevel}}, but the token grants {{.Label}}{{end}}{{end}}{{end}} {{- end -}} {{- define "credential" -}} -| Profile | {{cell .Profile}} | +| Profile | {{with .Profile}}{{cell .}}{{else}}none{{end}} | | --- | --- | | Endpoint | {{cell .Endpoint}} | {{- if ne .Kind "manual"}} @@ -18,9 +18,9 @@ | | {{template "access" .}} | {{- end}} {{- with .OidcLogin}} -| | {{if .SessionEndsAt.IsZero}}Session with no end known{{else if .SessionEnded}}💤 Session ended {{at .SessionEndsAt}}, run `meshstack login --profile {{$.Profile}}`{{else}}Session ends at the latest {{at .SessionEndsAt}}, then run `meshstack login --profile {{$.Profile}}`{{end}} | +| | {{if .SessionEndsAt.IsZero}}Session with no end known{{else if .SessionEnded}}💤 Session ended {{at .SessionEndsAt}}, run `{{$.LoginHint}}`{{else}}Session ends at the latest {{at .SessionEndsAt}}, then run `{{$.LoginHint}}`{{end}} | {{- end}} -| | {{with $token}}{{if .Expired}}Token expired {{at .ExpiresAt}}, {{if eq $.Kind "manual"}}cannot be renewed, run `meshstack login --profile {{$.Profile}} --apitoken`{{else if and $.OidcLogin $.OidcLogin.SessionEnded}}cannot be renewed{{else}}renewed on next use{{end}} +| | {{with $token}}{{if .Expired}}Token expired {{at .ExpiresAt}}, {{if eq $.Kind "manual"}}cannot be renewed, run `{{$.LoginHint}} --apitoken`{{else if and $.OidcLogin $.OidcLogin.SessionEnded}}cannot be renewed{{else}}renewed on next use{{end}} {{- else}}Token expires {{at .ExpiresAt}}{{end}}{{with .Workspace}}, for workspace {{cell .}}{{end}} {{- else}}No token cached yet{{if ne .Kind "manual"}}; one is created on next use{{end}}{{end}} | {{- range $i, $unused := .Unused}} diff --git a/cmd/internal/markdown/markdown_test.go b/cmd/internal/markdown/markdown_test.go index a5545632..0516c3a1 100644 --- a/cmd/internal/markdown/markdown_test.go +++ b/cmd/internal/markdown/markdown_test.go @@ -6,7 +6,6 @@ import ( "testing" "testing/synctest" "time" - "uuid" "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" @@ -23,7 +22,7 @@ var credentialOnly = Parse("test", `{{template "credential" .}}`) func TestABrowserLoginShowsItsUserAccessAndSessionEnd(t *testing.T) { synctest.Test(t, func(t *testing.T) { status := auth.CredentialStatus{ - Profile: "dev", Endpoint: xurl.MustParsef("https://meshstack.example.com"), + Profile: "dev", LoginHint: "meshstack login --profile dev", Endpoint: xurl.MustParsef("https://meshstack.example.com"), Kind: credential.OidcLoginName, Sources: []string{"file credentials/dev.json"}, Workspace: "ops", Token: &auth.TokenStatus{ @@ -48,7 +47,7 @@ func TestABrowserLoginShowsItsUserAccessAndSessionEnd(t *testing.T) { func TestAnApiKeyShowsItsClientIdAndNoCachedTokenYet(t *testing.T) { synctest.Test(t, func(t *testing.T) { - clientId := uuid.MustParse("11111111-45bf-42ba-a965-2097b9d0d181") + clientId := "11111111-45bf-42ba-a965-2097b9d0d181" status := auth.CredentialStatus{ Profile: "ci", Endpoint: xurl.MustParsef("https://meshstack.example.com"), Kind: credential.ApiKeyName, Sources: []string{"env MESHSTACK_API_KEY", "env MESHSTACK_API_SECRET"}, @@ -77,7 +76,7 @@ func TestAnApiKeyShowsItsClientIdAndNoCachedTokenYet(t *testing.T) { func TestAnExpiredApiTokenSaysItCannotBeRenewed(t *testing.T) { status := auth.CredentialStatus{ - Profile: "run", Endpoint: xurl.MustParsef("https://meshstack.example.com"), + Profile: "run", LoginHint: "meshstack login --profile run", Endpoint: xurl.MustParsef("https://meshstack.example.com"), Kind: credential.ManualName, Sources: []string{"env MESHSTACK_API_TOKEN"}, Token: &auth.TokenStatus{ExpiresAt: time.Now().Add(-time.Hour), Workspace: "ops", ClientId: "11111111-45bf-42ba-a965-2097b9d0d181"}, } diff --git a/cmd/internal/output.go b/cmd/internal/output.go index 8394f53b..d8da742c 100644 --- a/cmd/internal/output.go +++ b/cmd/internal/output.go @@ -2,7 +2,6 @@ package internal import ( "encoding/json/jsontext" - "encoding/json/v2" "fmt" "io" "iter" @@ -11,6 +10,7 @@ import ( "github.com/spf13/pflag" "github.com/meshcloud/meshstack-cli/cmd/internal/markdown" + "github.com/meshcloud/meshstack-cli/internal/json" ) const OutputFlagName = "output" @@ -60,7 +60,7 @@ func (f *OutputFlag) Type() string { // WriteList leaves a listing that fails part way through unterminated, so that json output which // stopped early does not parse as a complete array. ndjson has no end to leave out, so there only // the exit status tells a cut listing from a complete one. -func (format OutputFormat) WriteList(w io.Writer, items iter.Seq2[jsontext.Value, error]) error { +func (format OutputFormat) WriteList(w io.Writer, items iter.Seq2[any, error]) error { list, err := format.listing() if err != nil { return err @@ -70,14 +70,19 @@ func (format OutputFormat) WriteList(w io.Writer, items iter.Seq2[jsontext.Value if itemErr != nil { return itemErr } + encoded, marshalErr := json.Marshal(item) + if marshalErr != nil { + return marshalErr + } separator := list.between if written == 0 { separator = list.begin } - if err = list.format(&item); err != nil { + value := jsontext.Value(encoded) + if err = list.format(&value); err != nil { return err } - if _, err = fmt.Fprintf(w, "%s%s%s", separator, item, list.after); err != nil { + if _, err = fmt.Fprintf(w, "%s%s%s", separator, value, list.after); err != nil { return err } written++ @@ -90,8 +95,12 @@ func (format OutputFormat) WriteList(w io.Writer, items iter.Seq2[jsontext.Value return err } -func (format OutputFormat) WriteItem(w io.Writer, item jsontext.Value) error { - var err error +func (format OutputFormat) WriteItem(w io.Writer, object any) error { + encoded, err := json.Marshal(object) + if err != nil { + return err + } + item := jsontext.Value(encoded) switch format { case OutputNdjson: err = item.Compact() diff --git a/cmd/internal/output_test.go b/cmd/internal/output_test.go index 6a338da0..4702daae 100644 --- a/cmd/internal/output_test.go +++ b/cmd/internal/output_test.go @@ -13,8 +13,8 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/internal" ) -func items(values ...string) iter.Seq2[jsontext.Value, error] { - return func(yield func(jsontext.Value, error) bool) { +func items(values ...string) iter.Seq2[any, error] { + return func(yield func(any, error) bool) { for _, value := range values { if !yield(jsontext.Value(value), nil) { return @@ -118,7 +118,7 @@ func TestAnEmptyListing(t *testing.T) { func TestAFailingPageStopsTheListing(t *testing.T) { pageErr := errors.New("page 1 failed") - failing := func(yield func(jsontext.Value, error) bool) { + failing := func(yield func(any, error) bool) { if yield(jsontext.Value(`{"kind":"meshWorkspace"}`), nil) { yield(nil, pageErr) } diff --git a/cmd/internal/read.go b/cmd/internal/read.go index 7c61c5cc..cf09a0c1 100644 --- a/cmd/internal/read.go +++ b/cmd/internal/read.go @@ -2,13 +2,14 @@ package internal import ( "encoding/json/jsontext" - "encoding/json/v2" "fmt" "io" "os" "github.com/spf13/cobra" "github.com/spf13/pflag" + + "github.com/meshcloud/meshstack-cli/internal/json" ) // JsonInputFlag names a JSON file to read into a T, or - for stdin. diff --git a/cmd/internal/testacc/api_docs_test.go b/cmd/internal/testacc/api_docs_test.go index d34b2f06..f3f00006 100644 --- a/cmd/internal/testacc/api_docs_test.go +++ b/cmd/internal/testacc/api_docs_test.go @@ -1,31 +1,94 @@ package testacc import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "io" gohttp "net/http" + "net/url" + "os" + "path/filepath" "regexp" "slices" "strings" "testing" + "github.com/Shopify/toxiproxy/v2" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/cmd/internal" + "github.com/meshcloud/meshstack-cli/internal/json" ) var pathParameterRe = regexp.MustCompile(`\{([^}]+)}`) +const fallbackWarning = "which may not match it" + func TestAccApiDocs(t *testing.T) { - requireLocalStack(t) - spec := devApiDocs(t) - require.NotEmpty(t, spec.Operations) + endpoint := requireLocalStack(t) + proxy := newEndpointProxy(t, endpoint) + c := newCLI(t, proxy.endpoint) + var spec openapi.Spec + + t.Run("with the endpoint unreachable and nothing stored, api-docs reads the published docs of develop", func(t *testing.T) { + proxy.breakConnections(t) + defer proxy.heal(t) + + output, err := c.run("", "api-docs", "--dev") + + require.NoErrorf(t, err, "output:\n%s", output) + assert.Contains(t, output, "https://docs.dev.meshcloud.io/api/meshstack-openapi-docs.json, "+fallbackWarning) + assert.Empty(t, c.storedApiDocs(t), "the published docs are not stored") + }) + + t.Run("api-docs stores the docs of the endpoint under their hash, and asks for newer ones with their ETag", func(t *testing.T) { + output, err := c.run("", "api-docs") + + require.NoErrorf(t, err, "output:\n%s", output) + assert.NotContains(t, output, "level=WARN") + document := servedApiDocs(t, endpoint) + spec, err = openapi.Parse(bytes.NewReader(document)) + require.NoError(t, err) + hash := sha256.Sum256(document) + assert.Equal(t, []string{hex.EncodeToString(hash[:]) + ".json"}, c.storedApiDocs(t)) + index := c.apiDocsIndex(t) + assert.Equal(t, proxy.endpoint, index.Endpoint) + assert.Equal(t, hex.EncodeToString(hash[:]), index.Document) + require.NotEmptyf(t, index.ETag, "%s/api/openapi sends no ETag, so every run downloads the docs again; "+ + "meshfed-release 3a2224431fa adds it", endpoint) + + // A 304 fits into the limit, and the document does not. + proxy.addToxic(t, "limit_data", "downstream", map[string]any{"bytes": 8192}) + defer proxy.heal(t) + output, err = c.run("", "api-docs") + + require.NoErrorf(t, err, "output:\n%s", output) + assert.NotContains(t, output, "level=WARN", "the stored docs are up to date") + }) - t.Run("every operation documents the parameters of its path", func(t *testing.T) { + t.Run("with the endpoint unreachable, api-docs reads the docs stored of it", func(t *testing.T) { + proxy.breakConnections(t) + defer proxy.heal(t) + + output, err := c.run("", "api-docs") + + require.NoErrorf(t, err, "output:\n%s", output) + assert.Contains(t, output, "so this command reads the ones stored before") + assert.NotContains(t, output, fallbackWarning) + }) + + t.Run("every operation has a doc whose fields and parameters have a type, and documents the parameters of its path", func(t *testing.T) { + require.NotEmpty(t, spec.Operations) for _, operation := range spec.Operations { doc, err := spec.Doc(operation) - require.NoError(t, err) + require.NoErrorf(t, err, "%s %s", operation.Method, operation.PathTemplate) var documented []string for _, parameter := range doc.Parameters { + assert.NotEmptyf(t, parameter.Type, "%s %s: parameter %s", operation.Method, operation.PathTemplate, parameter.Name) if parameter.In == "path" { documented = append(documented, parameter.Name) } @@ -33,6 +96,11 @@ func TestAccApiDocs(t *testing.T) { for _, match := range pathParameterRe.FindAllStringSubmatch(string(operation.PathTemplate), -1) { assert.Containsf(t, documented, match[1], "%s %s", operation.Method, operation.PathTemplate) } + for _, body := range slices.Concat(doc.RequestBodies, responseBodies(doc)) { + for _, field := range body.Fields { + assert.NotRegexpf(t, `^$| of$`, field.Type, "%s %s: %s field %s", operation.Method, operation.PathTemplate, body.MediaType, field.Path) + } + } } }) @@ -45,4 +113,95 @@ func TestAccApiDocs(t *testing.T) { assert.Falsef(t, operation.LatestApiVersion().IsZero(), "%s %s", operation.Method, operation.PathTemplate) } }) + + t.Run("api-docs describes every kind", func(t *testing.T) { + kinds := spec.Kinds() + require.NotEmpty(t, kinds) + for _, kind := range kinds { + output, err := c.run("", "api-docs", "--describe", string(internal.KindCommandOf(kind))) + require.NoErrorf(t, err, "%s, output:\n%s", kind, output) + } + }) +} + +func responseBodies(doc openapi.OperationDoc) []openapi.Body { + var bodies []openapi.Body + for _, response := range doc.Responses { + bodies = append(bodies, response.Bodies...) + } + return bodies +} + +func servedApiDocs(t *testing.T, endpoint string) []byte { + t.Helper() + req, err := gohttp.NewRequestWithContext(t.Context(), gohttp.MethodGet, endpoint+"/api/openapi", nil) + require.NoError(t, err) + resp, err := gohttp.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, gohttp.StatusOK, resp.StatusCode) + document, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return document +} + +func (c *cli) storedApiDocs(t *testing.T) []string { + t.Helper() + documents, err := filepath.Glob(filepath.Join(c.configDir, "api-docs", "*.json")) + require.NoError(t, err) + for i, document := range documents { + documents[i] = filepath.Base(document) + } + return documents +} + +type apiDocsIndex struct { + Endpoint string `json:"endpoint"` + Document string `json:"document"` + ETag string `json:"etag"` +} + +func (c *cli) apiDocsIndex(t *testing.T) apiDocsIndex { + t.Helper() + indexes, err := filepath.Glob(filepath.Join(c.configDir, "api-docs", "endpoints", "*.json")) + require.NoError(t, err) + require.Len(t, indexes, 1) + content, err := os.ReadFile(indexes[0]) + require.NoError(t, err) + var index apiDocsIndex + require.NoError(t, json.Unmarshal(content, &index)) + return index +} + +type endpointProxy struct { + proxy *toxiproxy.Proxy + endpoint string +} + +func newEndpointProxy(t *testing.T, endpoint string) endpointProxy { + t.Helper() + upstream, err := url.Parse(endpoint) + require.NoError(t, err) + proxy := toxiproxy.NewProxy(toxiproxy.NewServer(toxiproxy.NewMetricsContainer(nil), zerolog.Nop()), t.Name(), "127.0.0.1:0", upstream.Host) + require.NoError(t, proxy.Start()) + t.Cleanup(proxy.Stop) + return endpointProxy{proxy: proxy, endpoint: "http://" + proxy.Listen} +} + +func (p endpointProxy) addToxic(t *testing.T, toxicType, stream string, attributes map[string]any) { + t.Helper() + toxic, err := json.Marshal(map[string]any{"type": toxicType, "stream": stream, "attributes": attributes}) + require.NoError(t, err) + _, err = p.proxy.Toxics.AddToxicJson(bytes.NewReader(toxic)) + require.NoError(t, err) +} + +func (p endpointProxy) breakConnections(t *testing.T) { + t.Helper() + p.addToxic(t, "reset_peer", "upstream", map[string]any{"timeout": 0}) +} + +func (p endpointProxy) heal(t *testing.T) { + t.Helper() + p.proxy.Toxics.ResetToxics(t.Context()) } diff --git a/cmd/internal/testacc/api_get_test.go b/cmd/internal/testacc/api_get_test.go index 13c1f387..5e03738e 100644 --- a/cmd/internal/testacc/api_get_test.go +++ b/cmd/internal/testacc/api_get_test.go @@ -4,9 +4,9 @@ import ( "cmp" "context" "encoding/json/jsontext" - "encoding/json/v2" "errors" "fmt" + "log/slog" gohttp "net/http" "net/url" "regexp" @@ -19,10 +19,13 @@ import ( "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/internal/apidocs" - "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/json" + "github.com/meshcloud/meshstack-cli/internal/logs" "github.com/meshcloud/meshstack-cli/pkg/auth" ) @@ -35,6 +38,9 @@ var getExceptions = map[openapi.PathTemplate]getException{ "/api/meshobjects/meshbuildingblockruns": { query: map[string]objectField{"buildingBlockUuid": {"meshBuildingBlock", "uuid"}}, }, + "/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/artifact": { + skip: "answers 403 to a user login; the building block runner's acceptance tests cover it", + }, "/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/plan-artifact": { skip: "the API docs say it is in development, and answers 404 in normal operation", }, @@ -54,7 +60,7 @@ type objectField struct { func everyGetOperationAnswers(c *cli) func(*testing.T) { return func(t *testing.T) { - spec := devApiDocs(t) + spec := c.apiDocsOf(t) objects := firstObjects{spec: spec, meshStack: c.client(t)} gets, err := spec.Select(openapi.Selector{Method: gohttp.MethodGet}) require.NoError(t, err) @@ -98,19 +104,18 @@ func organizationAdmin(t *testing.T, logins []devLogin) organizationAdminLogin { return organizationAdminLogin{} } -func devApiDocs(t *testing.T) openapi.Spec { +func (c *cli) apiDocsOf(t *testing.T) openapi.Spec { t.Helper() t.Setenv(envConfigDir, t.TempDir()) - httpClient, err := internal.ResolveClientOptions().HttpClient() - require.NoError(t, err) - spec, wait, err := apidocs.Load(t.Context(), apidocs.Options{ - Sources: internal.SettingSources(), - Client: httpClient, - Version: internal.Version, - Dev: true, + captured := logs.Capture(t) + spec, err := apidocs.Load(t.Context(), apidocs.Options{ + Sources: internal.SettingSources(), + Client: internal.ResolveClientOptions().HttpClient(), + Endpoint: xurl.MustParsef("%s", c.endpoint), + Version: internal.Version, }) - wait() require.NoError(t, err) + require.Empty(t, captured.Lines(slog.LevelWarn)) return spec } @@ -158,9 +163,9 @@ type listed struct { func (o *firstObjects) get(ctx context.Context, r getRequest, operation openapi.Operation) (int, string, error) { options := []http.RequestOption{http.WithUrlQuery(r.query)} if mediaType, ok := operation.LatestMediaType(); ok { - options = append(options, http.WithAccept(mediaType.Name)) + options = append(options, http.WithHeader("Accept", mediaType.Name)) } - body, err := o.meshStack.Raw.DoRequest(ctx, gohttp.MethodGet, r.path, options...) + body, err := http.ReadAllBody(o.meshStack.HttpClient.DoRequest(ctx, gohttp.MethodGet, o.meshStack.Endpoint.JoinPath(r.path), options...)) if httpErr, ok := errors.AsType[client.HttpError](err); ok { return httpErr.StatusCode, string(httpErr.ResponseBody), nil } diff --git a/cmd/internal/testacc/api_test.go b/cmd/internal/testacc/api_test.go index f8591162..cafdee0f 100644 --- a/cmd/internal/testacc/api_test.go +++ b/cmd/internal/testacc/api_test.go @@ -1,7 +1,6 @@ package testacc import ( - "encoding/json/v2" "testing" "github.com/stretchr/testify/assert" @@ -26,20 +25,22 @@ func apiAsksForJson(c *cli) func(*testing.T) { } } +// The API docs list no version for a delete, so request.negotiate sends no Accept and Content-Type. +func apiDeletesWithNoMediaType(c *cli) func(*testing.T) { + return func(t *testing.T) { + output, err := c.run("", "api", "-X", "DELETE", "/api/meshobjects/meshtenants/00000000-0000-4000-8000-000000000000") + + require.ErrorContainsf(t, err, "meshStack answered HTTP 404", "a tenant that does not exist is not found, rather than refused for its headers, output:\n%s", output) + assert.NotContains(t, err.Error(), "api-docs", "a 404 on a path the API docs list has no hint to them") + } +} + func apiFollowsASelfLink(c *cli) func(*testing.T) { return func(t *testing.T) { var href string - for item, err := range c.client(t).Raw.List[client.MeshWorkspace](t.Context(), nil, client.ListOptions{PageSize: 1}) { + for workspace, err := range c.client(t).Workspace.List(t.Context(), client.MeshWorkspaceFilter{}, client.WithPageSize(1)) { require.NoError(t, err) - var links struct { - Links struct { - Self struct { - Href string `json:"href"` - } `json:"self"` - } `json:"_links"` - } - require.NoError(t, json.Unmarshal(item, &links)) - href = links.Links.Self.Href + href = workspace.Links["self"].Href break } require.NotEmpty(t, href, "the workspace list answers no workspace with a self link") diff --git a/cmd/internal/testacc/buildingblock_test.go b/cmd/internal/testacc/buildingblock_test.go index 21e12a9f..b18d146b 100644 --- a/cmd/internal/testacc/buildingblock_test.go +++ b/cmd/internal/testacc/buildingblock_test.go @@ -1,7 +1,6 @@ package testacc import ( - "encoding/json/v2" "slices" "strings" "testing" @@ -9,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/json" ) // ndjsonObjects skips the lines of the log, which share the output with stdout. diff --git a/cmd/internal/testacc/list_test.go b/cmd/internal/testacc/list_test.go index 5e279497..e26d508c 100644 --- a/cmd/internal/testacc/list_test.go +++ b/cmd/internal/testacc/list_test.go @@ -1,7 +1,6 @@ package testacc import ( - "encoding/json/v2" "os" "slices" "strings" @@ -9,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/json" ) // workspaceHoldingABuildingBlock is for the steps that need a building block, which a listing diff --git a/cmd/internal/testacc/login_test.go b/cmd/internal/testacc/login_test.go index c1fc1652..d216cffd 100644 --- a/cmd/internal/testacc/login_test.go +++ b/cmd/internal/testacc/login_test.go @@ -1,7 +1,6 @@ package testacc import ( - "encoding/json/v2" "html" "io" "maps" @@ -18,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/meshcloud/meshstack-cli/internal/json" "github.com/meshcloud/meshstack-cli/pkg/setting" ) @@ -235,6 +235,7 @@ func TestAccApiKeyLogin(t *testing.T) { t.Run("every list command answers", everyListCommandAnswers(c)) t.Run("every GET operation answers the API key", everyGetOperationAnswers(c)) t.Run("a meshObject endpoint refuses JSON, and api sends --request-json in its media type", apiAsksForJson(c)) + t.Run("a meshObject endpoint takes a DELETE without a media type", apiDeletesWithNoMediaType(c)) t.Run("api picks the profile for the self link of a list answer by its URL alone, with no endpoint in the environment", apiFollowsASelfLink(c)) t.Run("a wait ends once the run has ended", waitEndsTheRun(c)) t.Run("trigger-run names the run it started", triggerRunNamesTheRunItStarted(c)) diff --git a/cmd/internal/testacc/testacc_test.go b/cmd/internal/testacc/testacc_test.go index be316324..101746b7 100644 --- a/cmd/internal/testacc/testacc_test.go +++ b/cmd/internal/testacc/testacc_test.go @@ -2,7 +2,6 @@ package testacc import ( "context" - "encoding/json/v2" goio "io" "log/slog" gohttp "net/http" @@ -25,6 +24,7 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/eventlog" "github.com/meshcloud/meshstack-cli/cmd/profile" "github.com/meshcloud/meshstack-cli/cmd/workspace" + "github.com/meshcloud/meshstack-cli/internal/json" "github.com/meshcloud/meshstack-cli/pkg/io" "github.com/meshcloud/meshstack-cli/pkg/setting" ) @@ -146,7 +146,7 @@ func (c *cli) environ() []string { // no test can import, and adds only persistent flags, which this suite sets through the environment. func newRootCommand() *cobra.Command { root := &cobra.Command{Use: "meshstack", SilenceUsage: true} - root.AddCommand(api.New(), auth.New(), auth.NewLoginShortcut(), + root.AddCommand(api.New(), api.NewDocs(), auth.New(), auth.NewLoginShortcut(), buildingblock.New(), buildingblockdefinition.New(), buildingblockdefinitionversion.New(), buildingblockrun.New(), eventlog.New(), profile.New(), workspace.New()) return root diff --git a/cmd/internal/testacc/tfstate_test.go b/cmd/internal/testacc/tfstate_test.go index c702a1d9..3e5a0d84 100644 --- a/cmd/internal/testacc/tfstate_test.go +++ b/cmd/internal/testacc/tfstate_test.go @@ -3,7 +3,6 @@ package testacc import ( "context" "crypto/rand" - "encoding/json/v2" "fmt" "maps" "os" @@ -17,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/meshcloud/meshstack-cli/internal/json" "github.com/meshcloud/meshstack-cli/internal/tfstate" ) @@ -191,9 +191,10 @@ func bootstrapBuildingBlock(t *testing.T, c *cli, workspaceManager string) boots t.Helper() runId := "cli-" + strings.ToLower(rand.Text()[:8]) dir := copyTestdata(t, "bootstrap") + repositoryUrl, ref := localRepository(t) vars := []string{ "-var", "profile=" + defaultProfile, "-var", "run_id=" + runId, "-var", "workspace_manager=" + workspaceManager, - "-var", "repository_url=" + repositoryUrl, "-var", "git_ref=" + runnerRef(t), + "-var", "repository_url=" + repositoryUrl, "-var", "git_ref=" + ref, } c.tofu(t, dir, append([]string{"init", "-input=false"}, vars...)...) @@ -225,27 +226,10 @@ func bootstrapBuildingBlock(t *testing.T, c *cli, workspaceManager string) boots return bootstrappedBlock{} } -const repositoryUrl = "https://github.com/meshcloud/meshstack-cli.git" - -// runnerRef names the commit whose testdata/buildingblock the tf-block-runner runs, so that a branch -// tests its own module. The runner clones only the branches and tags of GitHub, so for the merge -// commit of a pull request, which meshfed-release's satellite lane tests, it takes the pull request's head. -func runnerRef(t *testing.T) string { +// localRepository lets the tf-block-runner clone the committed HEAD, so that a branch tests its own module. +func localRepository(t *testing.T) (url, ref string) { t.Helper() - head := git(t, "rev-parse", "HEAD") - remote := map[string]string{} - for line := range strings.Lines(git(t, "ls-remote", repositoryUrl)) { - sha, ref, _ := strings.Cut(strings.TrimSpace(line), "\t") - remote[ref] = sha - } - for ref, sha := range remote { - if pull, isMerge := strings.CutSuffix(ref, "/merge"); isMerge && strings.HasPrefix(ref, "refs/pull/") && sha == head { - return remote[pull+"/head"] - } - } - output, err := exec.CommandContext(t.Context(), "git", "fetch", "--dry-run", "--depth=1", repositoryUrl, head).CombinedOutput() - require.NoErrorf(t, err, "push HEAD %s first, as the tf-block-runner checks it out from %s:\n%s", head, repositoryUrl, output) - return head + return "file://" + git(t, "rev-parse", "--show-toplevel"), git(t, "rev-parse", "HEAD") } func git(t *testing.T, args ...string) string { diff --git a/cmd/internal/uuid.go b/cmd/internal/uuid.go deleted file mode 100644 index 63168f7b..00000000 --- a/cmd/internal/uuid.go +++ /dev/null @@ -1,46 +0,0 @@ -package internal - -import ( - "fmt" - "uuid" - - "github.com/spf13/cobra" -) - -// UuidFlag and [UuidArg] reject a malformed uuid while cobra parses the command line. meshStack -// answers one with an empty list or a 404, which says nothing about a typo. -type UuidFlag uuid.UUID - -func (f *UuidFlag) String() string { - if *f == (UuidFlag{}) { - return "" - } - return uuid.UUID(*f).String() -} - -func (f *UuidFlag) Set(value string) error { - parsed, err := uuid.Parse(value) - if err != nil { - return err - } - *f = UuidFlag(parsed) - return nil -} - -func (f *UuidFlag) Type() string { - return "uuid" -} - -func UuidArg(id *uuid.UUID) cobra.PositionalArgs { - return func(cmd *cobra.Command, args []string) error { - if err := cobra.ExactArgs(1)(cmd, args); err != nil { - return err - } - parsed, err := uuid.Parse(args[0]) - if err != nil { - return fmt.Errorf("invalid argument %q: %w", args[0], err) - } - *id = parsed - return nil - } -} diff --git a/cmd/internal/uuid_test.go b/cmd/internal/uuid_test.go deleted file mode 100644 index 038cdbdd..00000000 --- a/cmd/internal/uuid_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package internal_test - -import ( - "testing" - "uuid" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/meshcloud/meshstack-cli/cmd/internal" -) - -func TestAUuidFlagFailsWhileTheFlagsAreParsed(t *testing.T) { - var id uuid.UUID - flags := pflag.NewFlagSet("test", pflag.ContinueOnError) - flags.Var((*internal.UuidFlag)(&id), "definition", "") - - require.EqualError(t, flags.Parse([]string{"--definition", "my-definition"}), - `invalid argument "my-definition" for "--definition" flag: invalid uuid`) - require.NoError(t, flags.Parse([]string{"--definition", "3c9e1f7a-2b4d-4e6f-8a1c-5d7b9e2f4a6c"})) - assert.Equal(t, uuid.MustParse("3c9e1f7a-2b4d-4e6f-8a1c-5d7b9e2f4a6c"), id) -} - -func TestAnUnsetUuidFlagShowsNoDefault(t *testing.T) { - flags := pflag.NewFlagSet("test", pflag.ContinueOnError) - flags.Var(new(internal.UuidFlag), "definition", "the definition") - - assert.Equal(t, " --definition uuid the definition\n", flags.FlagUsages()) -} - -func TestUuidArgParsesTheOnlyArgument(t *testing.T) { - tests := []struct { - name string - args []string - want uuid.UUID - wantErr string - }{ - {name: "a uuid", args: []string{"7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c"}, want: uuid.MustParse("7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c")}, - {name: "no uuid", args: []string{"my-run"}, wantErr: `invalid argument "my-run": invalid uuid`}, - {name: "two arguments", args: []string{"7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c", "more"}, wantErr: "accepts 1 arg(s), received 2"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var id uuid.UUID - err := internal.UuidArg(&id)(&cobra.Command{}, tt.args) - if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, id) - }) - } -} diff --git a/cmd/meshstack/meshstack.go b/cmd/meshstack/meshstack.go index 5c1b2759..884e3fb7 100644 --- a/cmd/meshstack/meshstack.go +++ b/cmd/meshstack/meshstack.go @@ -28,7 +28,7 @@ import ( func main() { // No deadline bounds a command as a whole, since a listing takes as long as it is long. The - // HTTP client gives up on a server that stopped answering, see internal/http. + // HTTP client gives up on a server that stopped answering, see client/http. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) err := newRootCommand().ExecuteContext(ctx) stop() @@ -51,7 +51,7 @@ Start with 'meshstack login'. It logs you in and stores the login in a profile. AI agents: read https://docs.meshcloud.io/agent-context/index.md before you act, starting with its guardrails. The skill for this CLI is https://github.com/meshcloud/meshstack-cli/blob/main/skills/meshstack-cli/SKILL.md. -A command reads the endpoint, the profile and the workspace from its flag, else from the environment variable that the flag's help lists, else from the profile. A profile stores an endpoint and a default workspace. If you choose no profile, the command uses the profile whose endpoint matches, else the current profile, which 'meshstack login' sets. +A command reads the endpoint, the profile and the workspace from its flag, else from the environment variable that the flag's help lists, else from the profile. A profile stores an endpoint and a default workspace. If you choose no profile, the command uses the current profile, which 'meshstack login' sets. If you give another endpoint than the current profile has, the command uses the only profile with that endpoint. AI agents and scripts: add --%s %s to a list or show command to get JSON. 'meshstack api' sends a request to any path of the meshStack API, and 'meshstack api-docs' describes that path. diff --git a/cmd/profile/form.go b/cmd/profile/form.go index b2533e96..798449c1 100644 --- a/cmd/profile/form.go +++ b/cmd/profile/form.go @@ -92,8 +92,7 @@ func parseEndpoint(value string) (endpoint xurl.URL, err error) { if value == "" { return endpoint, errors.New("a profile needs an endpoint") } - err = endpoint.UnmarshalText([]byte(value)) - return + return meshstack.EndpointSetting.Parse(value) } // localEndpoint is meshfed-api of the local stack in ../meshfed-release, on Spring's default port. @@ -288,7 +287,7 @@ func (f *form) lookUpWorkspaces(ctx context.Context, profiles profile.Profiles) endpoint := f.endpoint var asked []profile.Profile if parsed, err := parseEndpoint(endpoint); err == nil { - asked = profiles.MatchingEndpoint(parsed) + asked = profiles.ForEndpoint(parsed) } return func() tea.Msg { return workspacesLookedUp{form: f, endpoint: endpoint, names: knownWorkspaces(ctx, asked)} @@ -336,5 +335,5 @@ func listWorkspaces(ctx context.Context, p *profile.Profile) ([]client.MeshWorks if err != nil { return nil, err } - return c.Workspace.List(ctx) + return meshstack.ListWorkspaces(ctx, c.Workspace) } diff --git a/cmd/profile/form_test.go b/cmd/profile/form_test.go index a5c48c6f..52e8da33 100644 --- a/cmd/profile/form_test.go +++ b/cmd/profile/form_test.go @@ -2,7 +2,6 @@ package profile import ( "testing" - "uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -53,11 +52,11 @@ func TestTheWorkspacesThatTheProfilesAtTheEndpointReachAreSuggested(t *testing.T ) credentials, err := profiles.Profiles["ci"].Credentials(t.Context()) require.NoError(t, err) - credentials.Set(&credential.ApiKey{Endpoint: endpoint, ClientId: uuid.MustParse(apiKey.ClientId), ClientSecret: apiKey.ClientSecret}) + credentials.Set(&credential.ApiKey{Endpoint: endpoint, ClientId: apiKey.ClientId, ClientSecret: apiKey.ClientSecret}) require.NoError(t, credentials.Store(t.Context())) // The logged-out profile is asked as well, and fails without a call. - assert.Equal(t, []string{"app-team", "platform-team"}, knownWorkspaces(t.Context(), profiles.MatchingEndpoint(endpoint))) - assert.Empty(t, profiles.MatchingEndpoint(xurl.MustParsef("https://unknown.example.io"))) + assert.Equal(t, []string{"app-team", "platform-team"}, knownWorkspaces(t.Context(), profiles.ForEndpoint(endpoint))) + assert.Empty(t, profiles.ForEndpoint(xurl.MustParsef("https://unknown.example.io"))) assert.Equal(t, int64(1), server.Counts().Logins) } diff --git a/cmd/profile/profile.go b/cmd/profile/profile.go index 2d93965d..1dc40f05 100644 --- a/cmd/profile/profile.go +++ b/cmd/profile/profile.go @@ -26,7 +26,7 @@ In a terminal, this command opens an interactive list where you can add, edit an return cmd.Help() } return withLockedProfiles(ctx, func(profiles profile.Profiles) error { - selection, err := profiles.SelectionFor(ctx, profile.ResolveProfileOptions{SettingSources: internal.SettingSources()}) + selection, err := profiles.SelectionFor(ctx, internal.SettingSources()) if err != nil { return err } diff --git a/cmd/profile/select_profile.go b/cmd/profile/select_profile.go index 0d9c9f98..8b9d6656 100644 --- a/cmd/profile/select_profile.go +++ b/cmd/profile/select_profile.go @@ -18,7 +18,7 @@ func selectProfile(ctx context.Context, cmd *cobra.Command, p prompt.Prompt, pro selected, err = profiles.Find(profile.Name(internal.ProfileFlag.Value)) return selected, true, err } - selection, err := profiles.SelectionFor(ctx, profile.ResolveProfileOptions{SettingSources: internal.SettingSources()}) + selection, err := profiles.SelectionFor(ctx, internal.SettingSources()) if err != nil { return nil, false, err } diff --git a/cmd/profile/show.go b/cmd/profile/show.go index 7e54282b..6d38ed44 100644 --- a/cmd/profile/show.go +++ b/cmd/profile/show.go @@ -3,7 +3,6 @@ package profile import ( "context" _ "embed" - "fmt" "log/slog" "github.com/spf13/cobra" @@ -11,7 +10,6 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/cmd/internal/markdown" "github.com/meshcloud/meshstack-cli/internal/auth" - "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/profile" ) @@ -24,7 +22,7 @@ var showTemplate = markdown.Parse("show", showTemplateText) type entry struct { *profile.Profile `json:",inline"` - Name profile.Name `json:"name"` + Name profile.Name `json:"name,omitzero"` Current bool `json:"current"` } @@ -35,7 +33,6 @@ func entryOf(profiles profile.Profiles, p *profile.Profile) entry { type shown struct { entry `json:",inline"` - // New is the profile a first login would create, which is not stored yet. New bool `json:"new,omitzero"` Status *auth.CredentialStatus `json:"status,omitzero"` @@ -65,26 +62,16 @@ func newShow() *cobra.Command { Short: "Show a profile and the status of its stored credential", Long: `Show the profile that other commands would use, and the status of its stored credential. Flags and environment variables can override that credential. -If the profile does not exist yet, the command shows what the first login would create, without saving anything.`, +If the command uses no stored profile for the endpoint, it shows the endpoint alone.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - opts := profile.ResolveProfileOptions{SettingSources: internal.SettingSources(), EndpointOptional: true} - stored, err := profile.LoadProfiles(ctx, profile.LoadProfilesOptions{SettingSources: opts.SettingSources}) + shownProfile, stored, err := profile.ResolveProfile(ctx, internal.SettingSources()) if err != nil { return err } - shownProfile, _, err := profile.ResolveProfile(ctx, opts) - if err != nil { - return err - } - if endpoint, source, err := opts.ResolveSettingWithSource(ctx, meshstack.EndpointSetting); err == nil && !endpoint.Equal(shownProfile.Endpoint) { - slog.WarnContext(ctx, fmt.Sprintf("Profile '%s' has the endpoint '%s', which does not match the endpoint '%s' from %s, so commands fail with this profile", - shownProfile.Name, shownProfile.Endpoint, endpoint, source.Describe(meshstack.EndpointSetting.EnvKey()))) - } s := showOf(ctx, stored, shownProfile) - _, isStored := stored.Profiles[shownProfile.Name] - s.New = !isStored + s.New = shownProfile.Name == "" return output.Show(cmd.OutOrStdout(), showTemplate, s) }, } diff --git a/cmd/profile/show.md.tmpl b/cmd/profile/show.md.tmpl index f3b2e372..aa820623 100644 --- a/cmd/profile/show.md.tmpl +++ b/cmd/profile/show.md.tmpl @@ -2,15 +2,15 @@ {{- with .Status -}} {{template "credential" .}} {{- else -}} -| Profile | {{cell .Name}} | +| Profile | {{with .Name}}{{cell .}}{{else}}none{{end}} | | --- | --- | | Endpoint | {{with .Endpoint.URL}}{{cell .}}{{else}}none{{end}} | | Default workspace | {{or .DefaultWorkspace "none"}} | -| Credential | none, run `meshstack login --profile {{.Name}}` | +| Credential | none, run `{{.LoginHint}}` | {{- end}} {{- if .New}} -This profile is not saved yet. It is saved on the first login. +No stored profile is used. A login saves one. {{- else if .Current}} This is the current profile. diff --git a/cmd/profile/show_test.go b/cmd/profile/show_test.go index 8925dfe0..07334d9d 100644 --- a/cmd/profile/show_test.go +++ b/cmd/profile/show_test.go @@ -1,7 +1,6 @@ package profile import ( - "encoding/json/v2" "log/slog" "maps" "slices" @@ -12,6 +11,7 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/json" "github.com/meshcloud/meshstack-cli/internal/logs" "github.com/meshcloud/meshstack-cli/internal/profile" ) @@ -86,42 +86,28 @@ func TestShow(t *testing.T) { require.NoError(t, err) assert.Contains(t, output, "| Profile | dev |\n") assert.Contains(t, output, "| Credential | API token, from file ") - assert.Contains(t, output, "| | No token cached yet |\n") + assert.Contains(t, output, "| | Token expires 2100-01-01") assert.Contains(t, output, "This is the current profile.") }) - t.Run("shows the current profile with a warning where it is for another endpoint, and that endpoint has several profiles", func(t *testing.T) { + t.Run("shows the endpoint alone where several profiles have it and none of them is current", func(t *testing.T) { require.NoError(t, profiles.Put(t.Context(), nil, profile.Profile{Name: "staging", Endpoint: endpointB})) - captured := logs.Capture(t) output, err := execute(t, "", "show", "--endpoint", "https://b.example.io") require.NoError(t, err) - assert.Contains(t, output, "| Profile | dev |\n") - assert.Contains(t, captured.String(), "Profile 'dev' has the endpoint 'https://a.example.io', which does not match the endpoint 'https://b.example.io' from flag --endpoint, so commands fail with this profile") - }) - - t.Run("shows the profile a first command would create, without storing it, where no profile is current", func(t *testing.T) { - profiles.CurrentProfile = "" - require.NoError(t, profiles.Store(t.Context())) - - output, err := execute(t, "", "show", "--endpoint", "https://b.example.io") - - require.NoError(t, err) - assert.Contains(t, output, "| Profile | default |\n| --- | --- |\n| Endpoint | https://b.example.io |\n") - assert.Contains(t, output, "This profile is not saved yet. It is saved on the first login.") + assert.Contains(t, output, "| Profile | none |\n| --- | --- |\n| Endpoint | https://b.example.io |\n") + assert.Contains(t, output, "| Credential | none, run `meshstack login --endpoint https://b.example.io` |\n") + assert.Contains(t, output, "No stored profile is used. A login saves one.") requireStoredNames(t, "dev", "prod", "staging") }) - t.Run("shows the profile a first command would create without any profile or endpoint", func(t *testing.T) { + t.Run("fails without any profile or endpoint", func(t *testing.T) { emptyConfigDir(t) - output, err := execute(t, "", "show") + _, err := execute(t, "", "show") - require.NoError(t, err) - assert.Contains(t, output, "| Profile | default |\n| --- | --- |\n| Endpoint | none |\n| Default workspace | none |\n") - assert.Contains(t, output, "This profile is not saved yet. It is saved on the first login.") - requireStoredNames(t) + require.ErrorContains(t, err, "or run 'meshstack login'") }) } diff --git a/cmd/profile/store_test.go b/cmd/profile/store_test.go index 37a3e812..eb093e00 100644 --- a/cmd/profile/store_test.go +++ b/cmd/profile/store_test.go @@ -13,6 +13,7 @@ import ( "github.com/meshcloud/meshstack-cli/internal/auth/credential" "github.com/meshcloud/meshstack-cli/internal/logs" "github.com/meshcloud/meshstack-cli/internal/profile" + "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" ) var ( @@ -44,6 +45,7 @@ func storeCredentials(t *testing.T, p *profile.Profile) { credentials, err := p.Credentials(t.Context()) require.NoError(t, err) credentials.Manual = &credential.Manual{Endpoint: p.Endpoint} + require.NoError(t, credentials.Manual.Token.UnmarshalText([]byte(fakemeshstack.Token))) require.NoError(t, credentials.Store(t.Context())) } diff --git a/cmd/workspace/list.go b/cmd/workspace/list.go index 99546fce..6c5527ef 100644 --- a/cmd/workspace/list.go +++ b/cmd/workspace/list.go @@ -20,7 +20,11 @@ If you work in the admin workspace and have a role that can list all workspaces, meshstack ws list --workspace admin-workspace --limit unlimited -o ndjson`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return flags.Run[client.MeshWorkspace](cmd, client.MeshWorkspaceListFilter{}) + meshStack, err := internal.ResolveClient(cmd.Context()) + if err != nil { + return err + } + return flags.Run(cmd, meshStack.Workspace, client.MeshWorkspaceFilter{}) }, } diff --git a/cmd/workspace/list_test.go b/cmd/workspace/list_test.go index e7df607e..94649830 100644 --- a/cmd/workspace/list_test.go +++ b/cmd/workspace/list_test.go @@ -29,7 +29,7 @@ func TestAListCutShortByTheLimitSaysHowManyThereAre(t *testing.T) { require.NoError(t, cmd.ExecuteContext(t.Context())) - assert.JSONEq(t, `[{"metadata":{"name":"workspace-0"}}]`, stdout.String()) + assert.JSONEq(t, `[{"apiVersion":"v2","kind":"meshWorkspace","metadata":{"name":"workspace-0","tags":{}},"spec":{"displayName":""}}]`, stdout.String()) assert.Contains(t, captured.String(), "listed the first 1 of 6") assert.Len(t, server.TakeRequests(), 1) } diff --git a/docs/demo/fakemeshstack/meshstack.go b/docs/demo/fakemeshstack/meshstack.go index 4515d041..93bcce8f 100644 --- a/docs/demo/fakemeshstack/meshstack.go +++ b/docs/demo/fakemeshstack/meshstack.go @@ -28,6 +28,7 @@ func (i installation) meshStack() *fakemeshstack.Server { Issuer: "https://" + i.sso + "/realms/meshfed", Workspaces: workspaces(), BuildingBlocks: buildingBlocks(), + ApiDocs: apiDocs, }) } @@ -37,8 +38,6 @@ func (i installation) meshStack() *fakemeshstack.Server { //go:embed api-docs.json var apiDocs []byte -const docsHost = "docs.meshstack.example.com" - func workspaces() (objects []any) { for _, workspace := range [][2]string{ {"platform-team", "Platform Team"}, diff --git a/docs/demo/fakemeshstack/server.go b/docs/demo/fakemeshstack/server.go index 9e72cf1c..e2a587f9 100644 --- a/docs/demo/fakemeshstack/server.go +++ b/docs/demo/fakemeshstack/server.go @@ -9,7 +9,6 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/json/jsontext" - "encoding/json/v2" "encoding/pem" "errors" "fmt" @@ -17,12 +16,13 @@ import ( "log" "net" gohttp "net/http" + "net/url" "os" "path/filepath" "slices" "time" - "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" + "github.com/meshcloud/meshstack-cli/internal/json" ) // lifetime ends a server that the tape failed to stop. @@ -52,7 +52,6 @@ func serve(dir string) error { mux.Handle(i.api+"/", meshStack) mux.Handle(i.sso+"/", meshStack) } - mux.Handle(docsHost+"/", fakemeshstack.New(fakemeshstack.Options{ApiDocs: apiDocs})) failed := make(chan error, 2) go func() { server := &gohttp.Server{ @@ -82,7 +81,7 @@ func hostNames() (hosts []string) { for _, i := range installations { hosts = append(hosts, i.api, i.sso) } - return append(hosts, docsHost) + return hosts } // tunnel is an HTTPS proxy for the installations' host names only. Refusing every other host, @@ -184,8 +183,8 @@ func writeDemoConfig(dir string, certPEM []byte, proxyAddr string) error { return err } - env := fmt.Sprintf("export MESHSTACK_CONFIG_DIR=%q\nexport HTTPS_PROXY=%q\nexport SSL_CERT_FILE=%q\nexport MESHSTACK_API_DOCS_URL=%q\n", - configDir, "http://"+proxyAddr, caFile, "https://"+docsHost+fakemeshstack.ApiDocsPath) + env := fmt.Sprintf("export MESHSTACK_CONFIG_DIR=%q\nexport HTTPS_PROXY=%q\nexport SSL_CERT_FILE=%q\n", + configDir, (&url.URL{Scheme: "http", Host: proxyAddr}).String(), caFile) if err := os.WriteFile(filepath.Join(dir, "env.tmp"), []byte(env), 0o600); err != nil { return err } diff --git a/fakemeshstack b/fakemeshstack new file mode 100755 index 00000000..5ec83bc9 Binary files /dev/null and b/fakemeshstack differ diff --git a/flake.nix b/flake.nix index 895afce7..335c5606 100644 --- a/flake.nix +++ b/flake.nix @@ -38,7 +38,7 @@ # directory's tests. docs/demo holds the demo's fake meshStack, which is no part of the CLI. excludedPackages = [ "docs/demo" ]; - vendorHash = "sha256-BWGrfEfxYXvXbwiKBWGuvOkuNn+dSxvPGKKAQtw89JM="; + vendorHash = "sha256-/X1NLL5viODpfFpO9JotEJ6jS0LnzPPlIT7lqSg9XPs="; # .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 diff --git a/go.mod b/go.mod index 383fa118..7325bfcd 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,13 @@ module github.com/meshcloud/meshstack-cli -// 1.27 is the floor because internal/http declares generic methods, which no earlier release +// 1.27 is the floor because client/http declares generic methods, which no earlier release // compiles. Keep the pins in flake.nix (go_1_27 and GOROOT) and in the // Dockerfile in lock-step. Raising it also breaks the nixpkgs package // (buildGo127Module in pkgs/by-name/me/meshstack-cli/package.nix of NixOS/nixpkgs): // its automatic update PRs cannot change the Go version, so a maintainer has to. -go 1.27 +go 1.27.0 + +toolchain go1.27.4 // gotestsum is not a convenience: meshfed-release's go-satellite build plugin runs // `go tool gotestsum` in this directory to drive the acceptance suite. @@ -22,10 +24,12 @@ require ( charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.6 charm.land/log/v2 v2.0.1 + github.com/Shopify/toxiproxy/v2 v2.13.0 github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/x/ansi v0.11.9 github.com/charmbracelet/x/term v0.2.2 github.com/gofrs/flock v0.13.1 + github.com/rs/zerolog v1.35.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.12.1 @@ -114,7 +118,6 @@ require ( 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 @@ -132,6 +135,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gordonklaus/ineffassign v0.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/mux v1.8.1 // 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 @@ -168,7 +172,6 @@ require ( 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.30 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/mgechev/revive v1.15.0 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -176,16 +179,17 @@ require ( 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/prometheus/client_golang v1.24.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // 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 @@ -194,6 +198,7 @@ require ( 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/rs/xid v1.6.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 @@ -252,6 +257,7 @@ require ( golang.org/x/vuln v1.8.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/gotestsum v1.13.0 // indirect diff --git a/go.sum b/go.sum index 06a37c6d..290f70e8 100644 --- a/go.sum +++ b/go.sum @@ -14,38 +14,6 @@ charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= charm.land/log/v2 v2.0.1 h1:uMk+t1hM/fomIqt7vn/Ub0AKo7IHvnDu6NK17nbOVx0= charm.land/log/v2 v2.0.1/go.mod h1:VXZNB8zoPPEIcvd7bRi2fpQpGCWweCSfWOwczA9dibs= -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= @@ -56,7 +24,6 @@ dev.gaijin.team/go/exhaustruct/v5 v5.0.3 h1:yOeA7DNjlT8y4yfmN6nWWYYggA13N523YAj9 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= @@ -71,10 +38,8 @@ github.com/Antonboom/nilnil v1.1.2 h1:aNlFuJhaEseXe4fHO3xbjXlSeEiQVYa2lEkWD2s2hA 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= @@ -87,6 +52,8 @@ github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgy 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/Shopify/toxiproxy/v2 v2.13.0 h1:+SASZmq0GBNEc38gHzWvWkntJCk7olgfZr1sP6O/Bhk= +github.com/Shopify/toxiproxy/v2 v2.13.0/go.mod h1:MeXHdiGjtrmZqR8N1kR9++itfDm2FnzhdfQHOyKFfm8= 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= @@ -95,11 +62,6 @@ github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsr 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= @@ -120,8 +82,6 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -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= @@ -148,9 +108,6 @@ github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 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= @@ -181,17 +138,12 @@ github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= -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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -217,10 +169,6 @@ github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -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= @@ -239,22 +187,12 @@ github.com/ghostiam/protogetter v0.3.21 h1:EeWTGvL/Eyosp653hiWb6Byx4b69iJC4/E+za 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= @@ -286,36 +224,6 @@ github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5W 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= @@ -342,45 +250,25 @@ github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2b 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-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= -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 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= 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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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= @@ -400,47 +288,30 @@ github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b 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/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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= @@ -487,8 +358,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= -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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -499,17 +368,12 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4 github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 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/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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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= @@ -533,32 +397,15 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v 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/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= 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= @@ -573,9 +420,12 @@ github.com/raeperd/recvcheck v0.3.0 h1:PM+XYvyxIj3bo+kobJfFTdTuU3Lmfu96mKDbyHDbR 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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= 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= @@ -597,9 +447,6 @@ github.com/securego/gosec/v2 v2.28.0 h1:ZsSdiDb0AtTpLFVol5z91gbMei9ZiLEPG/pZjZuj 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= @@ -627,7 +474,6 @@ github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRk 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= @@ -670,7 +516,6 @@ github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+ 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= @@ -692,61 +537,27 @@ go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR5 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/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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= @@ -755,103 +566,36 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -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.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -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.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -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= @@ -865,9 +609,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= -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= @@ -875,51 +617,10 @@ 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.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -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= @@ -937,96 +638,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T 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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 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= @@ -1035,19 +656,9 @@ 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/internal/apidocs/apidocs.go b/internal/apidocs/apidocs.go index cdf5d27c..c3be9d21 100644 --- a/internal/apidocs/apidocs.go +++ b/internal/apidocs/apidocs.go @@ -1,25 +1,21 @@ -// Package apidocs loads the OpenAPI document of the meshStack API into the config directory, and -// keeps it there up to date. package apidocs import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "errors" "fmt" "log/slog" - gourl "net/url" + gohttp "net/http" "os" - "strings" + "time" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/client/types/version" "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/setting" - "github.com/meshcloud/meshstack-cli/internal/version" ) var ( @@ -27,103 +23,77 @@ var ( devUrl = xurl.MustParsef("https://docs.dev.meshcloud.io/api/meshstack-openapi-docs.json") ) -// urlEnv names the document of the fake meshStack that the tests and the demo serve, or as a file:// -// URL the one the acceptance suite generates from the meshStack it runs against. It is no -// setting.Setting, and no help names it, so that nobody else comes to rely on it. -const urlEnv = "MESHSTACK_API_DOCS_URL" +// requestTimeout is short, because the stored or the published docs can stand in. +const requestTimeout = 15 * time.Second type Options struct { setting.Sources Client http.Client Endpoint xurl.URL - // Version is the CLI's. A build of no release reads the docs of develop, as Dev does. + // Dev and a Version of no release fall back to the published docs of develop. Version string Dev bool } -// ErrNoConfigDir is returned rather than a document downloaded for nothing, because 4.7MB per -// command is too much to fetch again and again. var ErrNoConfigDir = errors.New("no writable config directory to keep the API docs in") -// Load returns a wait that the caller runs when the command has done its work, so that a slow -// download can finish. -func Load(ctx context.Context, opts Options) (spec openapi.Spec, wait func(), err error) { - wait = func() {} - customEnv := os.Getenv(urlEnv) - if path, isFile := strings.CutPrefix(customEnv, "file://"); isFile { - document, readErr := os.ReadFile(path) //nolint:gosec // G304: the path is the one the acceptance suite sets - if readErr != nil { - return spec, wait, fmt.Errorf("%s: %w", urlEnv, readErr) - } - if spec, err = openapi.Parse(bytes.NewReader(document)); err != nil { - return spec, wait, fmt.Errorf("%s: %w", path, err) - } - return spec, wait, nil +func Load(ctx context.Context, opts Options) (openapi.Spec, error) { + if opts.Endpoint.URL == nil { + return opts.loadPublished(ctx, nil) } dir, err := opts.ResolveSetting(ctx, config.DirectorySetting) if err != nil { - return spec, wait, err + return openapi.Spec{}, err } - if mkdirErr := os.MkdirAll(string(dir), 0o700); mkdirErr != nil || !writable(dir) { - return spec, wait, fmt.Errorf("%w: %s is not writable, set %s to a directory that is", + s := store{dir: dir.Join("api-docs")} + if mkdirErr := os.MkdirAll(s.indexDir(), 0o700); mkdirErr != nil || !writable(s.indexDir()) { + return openapi.Spec{}, fmt.Errorf("%w: %s is not writable, set %s to a directory that is", ErrNoConfigDir, dir, config.DirectorySetting.EnvKey()) } - if customEnv != "" { - var custom xurl.URL - if err = custom.UnmarshalText([]byte(customEnv)); err != nil { - return spec, wait, fmt.Errorf("%s: %w", urlEnv, err) - } - return opts.download(ctx, custom.URL, dir.Join(fmt.Sprintf("api-docs-custom-%s.json", urlHash(custom.URL))), true) - } - if opts.Endpoint.URL != nil && !opts.Dev { - served := opts.Endpoint.JoinPath("api", "openapi") - spec, wait, err = opts.download(ctx, served, dir.Join(fmt.Sprintf("api-docs-%s.json", urlHash(served))), true) - if httpErr, ok := errors.AsType[http.Error](err); !ok || (!httpErr.IsUnauthorized() && !httpErr.IsNotFound()) { - return spec, wait, err - } - slog.DebugContext(ctx, fmt.Sprintf("%s serves no API docs at /api/openapi, reading those of the docs site", opts.Endpoint)) + spec, err := s.load(ctx, opts.Client, opts.Endpoint) + if _, ok := errors.AsType[fallbackError](err); ok { + return opts.loadPublished(ctx, err) } + return spec, err +} - url, path, conditionalGet := releasedUrl, dir.Join("api-docs.json"), false +func (opts Options) loadPublished(ctx context.Context, why error) (openapi.Spec, error) { + url := releasedUrl if cliVersion, parseErr := version.Parse(opts.Version); opts.Dev || parseErr != nil || !cliVersion.IsRelease() { - url, path, conditionalGet = devUrl, dir.Join("api-docs-dev.json"), true + url = devUrl + } + if why != nil { + slog.WarnContext(ctx, fmt.Sprintf("%s, so this command reads the API docs from %s, which may not match it", why, url)) } - return opts.download(ctx, url.URL, path, conditionalGet) + document, _, err := download(ctx, opts.Client, url) + if err != nil { + return openapi.Spec{}, fmt.Errorf("cannot download the API docs from %s: %w", url, err) + } + return parse(url, document) } -// urlHash names one file per URL, because the conditional GET asks only for a document newer than the -// stored one: after a switch to the docs of an older meshStack, it would keep those of the newer one. -func urlHash(url *gourl.URL) string { - sum := sha256.Sum256([]byte(url.String())) - return hex.EncodeToString(sum[:6]) +func download(ctx context.Context, client http.Client, url xurl.URL, options ...http.RequestOption) ([]byte, gohttp.Header, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + var header gohttp.Header + // No option sets an Accept-Encoding, so that net/http asks for gzip and decompresses the answer. + document, err := http.ReadAllBody(client.DoRequest(ctx, http.MethodGet, url.URL, + append(options, http.Retryable(0), http.WithCapturedResponseHeader(&header))...)) + return document, header, err } -func (opts Options) download(ctx context.Context, url *gourl.URL, path string, conditionalGet bool) (spec openapi.Spec, wait func(), err error) { - wait, err = opts.Client.Download(ctx, url, path, http.WithConditionalGet(conditionalGet)) +func parse(url xurl.URL, document []byte) (openapi.Spec, error) { + spec, err := openapi.Parse(bytes.NewReader(document)) if err != nil { - return spec, wait, fmt.Errorf("cannot download the API docs from %s: %w", url, err) + return spec, fmt.Errorf("the API docs from %s: %w", url, err) } - document, err := os.ReadFile(path) //nolint:gosec // G304: the path is the config directory's, joined above - if err != nil { - return spec, wait, err - } - spec, err = openapi.Parse(bytes.NewReader(document)) - if err != nil { - // Removed, because a conditional GET would keep it for good: a captive portal's page, say, - // stored with the time of its download, is newer than the document. - return spec, wait, fmt.Errorf("%s: %w, so it is removed for the next command to download again", - path, errors.Join(err, os.Remove(path))) - } - return spec, wait, nil + return spec, nil } -func writable(dir config.Directory) bool { - if !dir.Exists() { - return false - } - probe, err := os.CreateTemp(string(dir), ".writable-*") +func writable(dir string) bool { + probe, err := os.CreateTemp(dir, ".writable-*") if err != nil { return false } diff --git a/internal/apidocs/apidocs_test.go b/internal/apidocs/apidocs_test.go deleted file mode 100644 index f10cbe6f..00000000 --- a/internal/apidocs/apidocs_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package apidocs - -import ( - gohttp "net/http" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "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/testutil/fakemeshstack" -) - -func TestLoad(t *testing.T) { - apiDocs, err := os.ReadFile("../../client/openapi/testdata/spec.json") - require.NoError(t, err) - meshStack := fakemeshstack.Start(t, fakemeshstack.Options{ApiDocs: apiDocs}) - docsSite := fakemeshstack.Start(t, fakemeshstack.Options{ApiDocs: apiDocs}) - docsOfDevelop := devUrl - devUrl = xurl.MustParsef("%s%s", docsSite.URL, fakemeshstack.ApiDocsPath) - t.Cleanup(func() { devUrl = docsOfDevelop }) - t.Setenv(urlEnv, "") - - load := func(t *testing.T, dev bool) error { - t.Helper() - t.Setenv(config.DirectorySetting.EnvKey(), t.TempDir()) - _, wait, err := Load(t.Context(), Options{ - Client: http.NewClient(fakemeshstack.UserAgent), - Endpoint: xurl.MustParsef("%s", meshStack.URL), - Dev: dev, - }) - wait() - return err - } - answer := func(status int) { - meshStack.Route("GET "+fakemeshstack.MeshStackApiDocsPath, func(w gohttp.ResponseWriter, _ *gohttp.Request) { - w.WriteHeader(status) - }) - } - paths := func(s *fakemeshstack.Server) (paths []string) { - for _, request := range s.TakeRequests() { - paths = append(paths, request.URL.Path) - } - return paths - } - - t.Run("reads the API docs of the meshStack it acts against", func(t *testing.T) { - require.NoError(t, load(t, false)) - - assert.Equal(t, []string{fakemeshstack.MeshStackApiDocsPath}, paths(meshStack)) - assert.Empty(t, paths(docsSite)) - }) - - t.Run("reads those of the docs site where the meshStack is older than its API docs", func(t *testing.T) { - for _, status := range []int{gohttp.StatusUnauthorized, gohttp.StatusNotFound} { - answer(status) - - require.NoError(t, load(t, false)) - - assert.Equal(t, []string{fakemeshstack.MeshStackApiDocsPath}, paths(meshStack)) - assert.Equal(t, []string{fakemeshstack.ApiDocsPath}, paths(docsSite)) - } - }) - - t.Run("fails where the meshStack refuses its API docs otherwise", func(t *testing.T) { - answer(gohttp.StatusForbidden) - - require.ErrorContains(t, load(t, false), "403") - - assert.Empty(t, paths(docsSite)) - }) - - t.Run("reads those of the docs site with --dev, without asking the meshStack", func(t *testing.T) { - meshStack.TakeRequests() - - require.NoError(t, load(t, true)) - - assert.Empty(t, paths(meshStack)) - assert.Equal(t, []string{fakemeshstack.ApiDocsPath}, paths(docsSite)) - }) -} diff --git a/internal/apidocs/store.go b/internal/apidocs/store.go new file mode 100644 index 00000000..92a4da2a --- /dev/null +++ b/internal/apidocs/store.go @@ -0,0 +1,151 @@ +package apidocs + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + gohttp "net/http" + "os" + "path/filepath" + "strings" + + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/json" +) + +type fallbackError struct { + error +} + +type store struct { + dir string +} + +type endpointIndex struct { + Endpoint string `json:"endpoint"` + Document string `json:"document"` + ETag string `json:"etag,omitzero"` +} + +func (s store) indexDir() string { + return filepath.Join(s.dir, "endpoints") +} + +func (s store) indexPath(endpoint xurl.URL) string { + hash := sha256.Sum256([]byte(endpoint.String())) + return filepath.Join(s.indexDir(), hex.EncodeToString(hash[:8])+".json") +} + +func (s store) documentPath(hash string) string { + return filepath.Join(s.dir, hash+".json") +} + +func (s store) load(ctx context.Context, client http.Client, endpoint xurl.URL) (openapi.Spec, error) { + url := xurl.URL{URL: endpoint.JoinPath("api", "openapi")} + indexPath := s.indexPath(endpoint) + stored, storedDocument := s.read(ctx, indexPath) + var options []http.RequestOption + if storedDocument != nil && stored.ETag != "" { + options = append(options, http.WithHeader("If-None-Match", stored.ETag)) + } + + document, header, err := download(ctx, client, url, options...) + httpErr, isHttpErr := errors.AsType[http.Error](err) + switch { + case isHttpErr && httpErr.StatusCode == gohttp.StatusNotModified: + slog.DebugContext(ctx, fmt.Sprintf("The API docs of %s are up to date", endpoint)) + return parse(url, storedDocument) + // A meshStack from before /api/openapi answers 401. + case isHttpErr && (httpErr.StatusCode == gohttp.StatusUnauthorized || httpErr.StatusCode == gohttp.StatusNotFound): + return openapi.Spec{}, fallbackError{fmt.Errorf("%s serves no API docs", endpoint)} + case err != nil && storedDocument != nil: + slog.WarnContext(ctx, fmt.Sprintf("Cannot load the API docs from %s, so this command reads the ones stored before: %s", url, err)) + return parse(url, storedDocument) + case err != nil: + return openapi.Spec{}, fallbackError{fmt.Errorf("cannot load the API docs from %s: %w", url, err)} + } + + spec, err := parse(url, document) + if err != nil { + return spec, err + } + hash := sha256.Sum256(document) + index := endpointIndex{Endpoint: endpoint.String(), Document: hex.EncodeToString(hash[:]), ETag: header.Get("ETag")} + if storeErr := s.put(ctx, indexPath, index, document, stored.Document); storeErr != nil { + slog.WarnContext(ctx, fmt.Sprintf("Cannot store the API docs of %s: %s", endpoint, storeErr)) + } + return spec, nil +} + +func (s store) read(ctx context.Context, indexPath string) (endpointIndex, []byte) { + var index endpointIndex + if err := json.UnmarshalFrom(ctx, indexPath, &index); err != nil { + return endpointIndex{}, nil + } + document, err := os.ReadFile(s.documentPath(index.Document)) + if hash := sha256.Sum256(document); err != nil || hex.EncodeToString(hash[:]) != index.Document { + return index, nil + } + return index, document +} + +func (s store) put(ctx context.Context, indexPath string, index endpointIndex, document []byte, previous string) error { + if err := writeFile(s.documentPath(index.Document), document); err != nil { + return err + } + if err := json.MarshalTo(ctx, indexPath, index); err != nil { + return err + } + if previous == "" || previous == index.Document { + return nil + } + return s.removeUnreferenced(ctx) +} + +func (s store) removeUnreferenced(ctx context.Context) error { + indexes, err := filepath.Glob(filepath.Join(s.indexDir(), "*.json")) + if err != nil { + return err + } + referenced := map[string]bool{} + for _, path := range indexes { + var index endpointIndex + if readErr := json.UnmarshalFrom(ctx, path, &index); readErr != nil { + return readErr + } + referenced[index.Document] = true + } + documents, err := filepath.Glob(s.documentPath("*")) + if err != nil { + return err + } + var errs []error + for _, path := range documents { + if !referenced[strings.TrimSuffix(filepath.Base(path), ".json")] { + errs = append(errs, os.Remove(path)) + } + } + return errors.Join(errs...) +} + +func writeFile(path string, content []byte) (err error) { + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + defer func() { + if err != nil { + _ = os.Remove(tmp.Name()) + } + }() + _, writeErr := tmp.Write(content) + if err = errors.Join(writeErr, tmp.Close()); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 13bf060c..4474632d 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -8,7 +8,7 @@ import ( "time" "github.com/meshcloud/meshstack-cli/client" - "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" ) @@ -75,7 +75,7 @@ func (s Session) existenceOf(ctx context.Context, workspace meshstack.Workspace) reading.getWorkspace = func() (meshstack.Workspace, error) { return readFrom, nil } - read, err := client.New(ctx, s.CurrentProfile.Endpoint, s.httpClient, reading).Workspace.Read(ctx, string(workspace)) + read, err := client.New(ctx, s.CurrentProfile.Endpoint, s.httpClient, reading).Workspace.Read(ctx, client.MeshObjectName(workspace)) switch { case err != nil: slog.DebugContext(ctx, fmt.Sprintf("Cannot tell whether workspace %s exists: %s", workspace, err.Error())) diff --git a/internal/auth/cache.go b/internal/auth/cache.go index 70a83756..23b6b1a9 100644 --- a/internal/auth/cache.go +++ b/internal/auth/cache.go @@ -1,6 +1,7 @@ package auth import ( + "bytes" "context" "encoding/json/jsontext" "errors" @@ -9,6 +10,7 @@ import ( "log/slog" "os" "reflect" + "sync" "github.com/meshcloud/meshstack-cli/internal/auth/credential" "github.com/meshcloud/meshstack-cli/internal/json" @@ -18,6 +20,8 @@ import ( const cacheVersion = 1 +var warnOfFailedCacheWrite sync.Once + // 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 { @@ -33,21 +37,17 @@ type Credential struct { // Sources must never carry a secret, as a status shows them. Sources []string - // Stored is set for the credential the profile stores, and not for one that settings provide. - Stored bool - // bringsItsToken is set for an API token that settings provide. A manual credential's identity - // is its endpoint alone, so the profile's cache would match it and replace that token with the - // one a login stored. - bringsItsToken bool - - profile profile.Name + locker lock.Locker cachePath string } func CacheFor(p *profile.Profile, cred credential.Credential) Credential { - path := p.ConfigDir.CredentialsCacheJsonFor(p.Name, cred.Name()) - return Credential{Credential: cred, profile: p.Name, locker: lock.New(path), cachePath: path} + var path string + if _, isToken := cred.(*credential.Manual); !isToken && p.Name != "" { + path = p.ConfigDir.CredentialsCacheJsonFor(p.Name, cred.Name()) + } + return Credential{Credential: cred, locker: lock.New(path), cachePath: path} } func (c Credential) Load(ctx context.Context) error { @@ -75,7 +75,9 @@ func (c Credential) Modify(ctx context.Context, action func() error) error { err = action() if matched { if writeErr := c.write(ctx); writeErr != nil { - slog.DebugContext(ctx, fmt.Sprintf("Cannot update cache %s, continuing with the token in memory: %s", c.cachePath, writeErr.Error())) + warnOfFailedCacheWrite.Do(func() { + slog.WarnContext(ctx, fmt.Sprintf("Cannot update cache %s, continuing with the token in memory: %s", c.cachePath, writeErr.Error())) + }) } } return err @@ -119,7 +121,7 @@ func (c Credential) WriteOver(ctx context.Context) (restore func(context.Context } func (c Credential) read(ctx context.Context) (matched bool, err error) { - if c.bringsItsToken { + if c.cachePath == "" { return false, nil } var file cacheFile @@ -133,21 +135,24 @@ func (c Credential) read(ctx context.Context) (matched bool, err error) { switch { case file.Version != cacheVersion: // Not an error: the cache only saves minting a token, and a login writes a compatible one. - slog.WarnContext(ctx, fmt.Sprintf("Ignoring cache %s of version %d, as this meshstack reads version %d; run 'meshstack login --profile %s' to update it", - c.cachePath, file.Version, cacheVersion, c.profile)) + slog.WarnContext(ctx, fmt.Sprintf("Ignoring cache %s of version %d, as this meshstack reads version %d; a login updates it", + c.cachePath, file.Version, cacheVersion)) case file.Identity != c.Identity().Hash: // Debug, not Warn: a credential from the environment next to the profile's own is normal. slog.DebugContext(ctx, fmt.Sprintf("Ignoring cache %s, it was minted for another identity", c.cachePath)) default: matched = true credential.WithCacheOf(c.Credential, func(cache reflect.Value) { - err = json.Unmarshal(file.Cache, cache.Addr().Interface()) + err = json.UnmarshalRead(bytes.NewReader(file.Cache), cache.Addr().Interface()) }) } return } func (c Credential) write(ctx context.Context) (err error) { + if c.cachePath == "" { + return nil + } file := cacheFile{ Version: cacheVersion, Identity: c.Identity().Hash, diff --git a/internal/auth/cache_test.go b/internal/auth/cache_test.go index 80b13843..6be8430d 100644 --- a/internal/auth/cache_test.go +++ b/internal/auth/cache_test.go @@ -5,7 +5,6 @@ import ( "os" "path/filepath" "testing" - "uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -32,7 +31,7 @@ func TestCache(t *testing.T) { } token := jsontest.MustUnmarshal[jwt.JWT](t, jwtJson) apiKey := func(clientId string) *credential.ApiKey { - return &credential.ApiKey{Endpoint: p.Endpoint, ClientId: uuid.MustParse(clientId), ClientSecret: "super-test-secret"} + return &credential.ApiKey{Endpoint: p.Endpoint, ClientId: clientId, ClientSecret: "super-test-secret"} } const stored, fromEnvironment = "11111111-45bf-42ba-a965-2097b9d0d181", "22222222-45bf-42ba-a965-2097b9d0d181" reloadedToken := func(t *testing.T) jwt.JWT { @@ -76,7 +75,7 @@ func TestCache(t *testing.T) { require.NoError(t, auth.CacheFor(p, loggedIn).Load(t.Context())) assert.Nil(t, loggedIn.Cache) assert.Contains(t, captured.String(), "level=WARN") - assert.Contains(t, captured.String(), "run 'meshstack login --profile cache-test' to update it") + assert.Contains(t, captured.String(), "a login updates it") loggedIn.Cache = withToken(token) require.NoError(t, auth.CacheFor(p, loggedIn).Write(t.Context())) diff --git a/internal/auth/credential/apikey.go b/internal/auth/credential/apikey.go index bb61217a..8e9c91d7 100644 --- a/internal/auth/credential/apikey.go +++ b/internal/auth/credential/apikey.go @@ -4,10 +4,9 @@ import ( "context" "errors" "fmt" - "uuid" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/types/xurl" - "github.com/meshcloud/meshstack-cli/internal/http" "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" ) @@ -15,8 +14,8 @@ var _ Credential = &ApiKey{} type ApiKey struct { Endpoint xurl.URL - ClientId uuid.UUID `json:"client_id"` - ClientSecret string `json:"client_secret"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` Cache *struct { Token jwt.JWT `json:"token,omitzero"` @@ -42,16 +41,16 @@ func (apiKey *ApiKey) RefreshCachedToken(ctx context.Context, client http.Client loginEndpoint := apiKey.Endpoint.JoinPath("api", "login") payload := struct { - ClientId uuid.UUID `json:"clientId"` - ClientSecret string `json:"clientSecret"` + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` }{apiKey.ClientId, apiKey.ClientSecret} - answer, err := client.DoRequest[struct { + answer, err := http.UnmarshalJsonBody[struct { AccessToken jwt.JWT `json:"access_token"` - }](ctx, http.MethodPost, loginEndpoint, - http.Retryable(), - http.WithJsonPayload(payload, "application/json"), - ) + }](client.DoRequest(ctx, http.MethodPost, loginEndpoint, + http.Retryable(http.RetryOnTransient), + http.WithJsonRequest(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) diff --git a/internal/auth/credential/credential.go b/internal/auth/credential/credential.go index 1b5e214d..f0b1bcfb 100644 --- a/internal/auth/credential/credential.go +++ b/internal/auth/credential/credential.go @@ -7,7 +7,7 @@ import ( "reflect" "strings" - "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" ) diff --git a/internal/auth/credential/identity.go b/internal/auth/credential/identity.go index b3eddb29..3686cb00 100644 --- a/internal/auth/credential/identity.go +++ b/internal/auth/credential/identity.go @@ -3,9 +3,10 @@ package credential import ( "crypto/sha256" "encoding/hex" - "encoding/json/v2" "fmt" "reflect" + + "github.com/meshcloud/meshstack-cli/internal/json" ) type Identity struct { diff --git a/internal/auth/credential/manual.go b/internal/auth/credential/manual.go index 55a27dbc..a65759b8 100644 --- a/internal/auth/credential/manual.go +++ b/internal/auth/credential/manual.go @@ -4,8 +4,8 @@ import ( "context" "fmt" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/types/xurl" - "github.com/meshcloud/meshstack-cli/internal/http" "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" ) @@ -13,16 +13,7 @@ var _ Credential = &Manual{} type Manual struct { Endpoint xurl.URL `json:"endpoint"` - Cache *struct { - Token jwt.JWT `json:"token"` - } `json:"-"` -} - -func NewManual(endpoint xurl.URL, token jwt.JWT) *Manual { - manual := &Manual{Endpoint: endpoint} - newCache(manual) - manual.Cache.Token = token - return manual + Token jwt.JWT `json:"token"` } func (manual *Manual) Name() Name { @@ -34,12 +25,12 @@ func (manual *Manual) Identity() Identity { } func (manual *Manual) CachedToken(_ context.Context, _ getWorkspaceFunc) (jwt.JWT, bool) { - if manual.Cache == nil { - return jwt.JWT{}, false - } - return manual.Cache.Token, true + return manual.Token, true } func (manual *Manual) RefreshCachedToken(_ context.Context, _ http.Client, _ getWorkspaceFunc) error { + if !manual.Token.GetClaim(jwt.ExpiryClaim).Expired(0) { + return nil + } return fmt.Errorf("manual method cannot be refreshed; provide new with 'meshstack login --endpoint %s --apitoken [--stdin]'", manual.Endpoint) } diff --git a/internal/auth/credential/oidclogin.go b/internal/auth/credential/oidclogin.go index bf177aff..958f7989 100644 --- a/internal/auth/credential/oidclogin.go +++ b/internal/auth/credential/oidclogin.go @@ -8,8 +8,8 @@ import ( gohttp "net/http" "time" + "github.com/meshcloud/meshstack-cli/client/http" "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" diff --git a/internal/auth/credential_apikey.go b/internal/auth/credential_apikey.go index 616a6d69..a3b738c2 100644 --- a/internal/auth/credential_apikey.go +++ b/internal/auth/credential_apikey.go @@ -5,13 +5,12 @@ import ( "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]{ +var ApiKeyClientIdSetting = setting.Setting[string]{ Env: "MESHSTACK_API_KEY", Short: func(envKey string) string { return fmt.Sprintf("The client id of a meshStack API key to authenticate with, together with its secret from %s. Also read from %s.", ApiKeyClientSecretSetting.EnvKey(), envKey) @@ -19,7 +18,7 @@ var ApiKeyClientIdSetting = setting.Setting[uuid.UUID]{ Long: func(envKey string) string { return fmt.Sprintf("The client id of a meshStack API key to authenticate with, together with its secret, also read from `%s`.", envKey) }, - Parse: setting.ParseTextUnmarshaler[uuid.UUID], + Parse: setting.ParseText[string], } var ApiKeyClientSecretSetting = setting.Setting[string]{ diff --git a/internal/auth/credential_manual.go b/internal/auth/credential_manual.go index 453a593b..46c6d994 100644 --- a/internal/auth/credential_manual.go +++ b/internal/auth/credential_manual.go @@ -30,7 +30,7 @@ func (s Session) resolveManualCredential(ctx context.Context, settingSources set return Credential{}, apiTokenErr } slog.DebugContext(ctx, fmt.Sprintf("Using setting %s as manual credential", ApiTokenSetting.EnvKey())) - manual := CacheFor(s.CurrentProfile, credential.NewManual(s.CurrentProfile.Endpoint, apiToken)) - manual.Sources, manual.bringsItsToken = []string{source.Describe(ApiTokenSetting.EnvKey())}, true + manual := CacheFor(s.CurrentProfile, &credential.Manual{Endpoint: s.CurrentProfile.Endpoint, Token: apiToken}) + manual.Sources = []string{source.Describe(ApiTokenSetting.EnvKey())} return manual, nil } diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index 5202a838..9091c251 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/meshcloud/meshstack-cli/internal/auth/credential" + "github.com/meshcloud/meshstack-cli/internal/profile" "github.com/meshcloud/meshstack-cli/internal/setting" ) @@ -53,9 +54,21 @@ func (s Session) resolveCredentials(ctx context.Context, opts ResolveSessionOpti func (s Session) storedCredential(ctx context.Context, noSourceErrs []error) (Credential, error) { currentProfile := s.CurrentProfile + if currentProfile.Name == "" { + var names []string + for _, p := range s.profiles.ForEndpoint(currentProfile.Endpoint) { + names = append(names, "'"+string(p.Name)+"'") + } + noCredential := fmt.Errorf("no credential for the endpoint %s; run '%s'", currentProfile.Endpoint, currentProfile.LoginHint()) + if len(names) > 0 { + noCredential = fmt.Errorf("no credential for the endpoint %s, and none of its profiles %s is the current one; use --profile or %s to choose one", + currentProfile.Endpoint, strings.Join(names, ", "), profile.NameSetting.EnvKey()) + } + return Credential{}, errors.Join(append([]error{noCredential}, noSourceErrs...)...) + } if currentProfile.Credential == "" { return Credential{}, errors.Join(append([]error{ - fmt.Errorf("profile '%s' selects no credential; run 'meshstack login --profile %s'", currentProfile, currentProfile), + fmt.Errorf("profile '%s' selects no credential; run '%s'", currentProfile, currentProfile.LoginHint()), }, noSourceErrs...)...) } creds, err := currentProfile.Credentials(ctx) @@ -64,11 +77,11 @@ func (s Session) storedCredential(ctx context.Context, noSourceErrs []error) (Cr } current := creds.ByName(currentProfile.Credential) if current == nil { - return Credential{}, fmt.Errorf("profile '%s' selects credential '%s', but %s contains none; run 'meshstack login --profile %s'", - currentProfile, currentProfile.Credential, creds.FilePath, currentProfile) + return Credential{}, fmt.Errorf("profile '%s' selects credential '%s', but %s contains none; run '%s'", + currentProfile, currentProfile.Credential, creds.FilePath, currentProfile.LoginHint()) } slog.DebugContext(ctx, fmt.Sprintf("Using credential %s of profile %s", currentProfile.Credential, currentProfile)) stored := CacheFor(currentProfile, current) - stored.Sources, stored.Stored = []string{"file " + creds.FilePath}, true + stored.Sources = []string{"file " + creds.FilePath} return stored, nil } diff --git a/internal/auth/login.go b/internal/auth/login.go index ccb87644..43759442 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -8,8 +8,8 @@ import ( "sync" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/internal/auth/credential" - "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" @@ -22,7 +22,7 @@ type StoreFunc func(context.Context) error // fails, it holds nothing. An unlock before the credential is stored also takes back the tokens // that the login cached. func Login(ctx context.Context, withAuth credential.Name, opts ResolveSessionOptions) (_ Session, _ StoreFunc, unlock func() error, err error) { - session, profiles, err := newSession(ctx, opts, true) + profiles, err := profile.LoadProfiles(ctx, profile.LoadProfilesOptions{SettingSources: opts.SettingSources, ExclusiveLock: true}) if err != nil { return Session{}, nil, nil, err } @@ -31,6 +31,11 @@ func Login(ctx context.Context, withAuth credential.Name, opts ResolveSessionOpt err = errors.Join(err, profiles.Unlock()) } }() + currentProfile, err := profiles.Resolve(ctx, opts.SettingSources, true) + if err != nil { + return Session{}, nil, nil, err + } + session := newSessionFor(ctx, currentProfile, opts) var creds profile.Credentials creds, err = session.CurrentProfile.Credentials(ctx) @@ -57,7 +62,7 @@ func Login(ctx context.Context, withAuth credential.Name, opts ResolveSessionOpt slog.DebugContext(ctx, fmt.Sprintf("Setting credential %s in profile", withAuth)) session.CurrentProfile.Credential = withAuth - resolved.Sources, resolved.Stored = []string{"file " + creds.FilePath}, true + resolved.Sources = []string{"file " + creds.FilePath} session.Credential = resolved restoreCache, err := session.Credential.WriteOver(ctx) if err != nil { @@ -120,7 +125,7 @@ func (s Session) resolveWorkspaceForLogin(ctx context.Context, opts ResolveSessi if err != nil { return r, err } - r.Items, err = c.Workspace.List(ctx) + r.Items, err = meshstack.ListWorkspaces(ctx, c.Workspace) if httpError, ok := errors.AsType[http.Error](err); ok && httpError.IsForbidden() { slog.DebugContext(ctx, "meshStack refused the workspace list: "+httpError.Error()) err = meshstack.NoWorkspaceToWorkInError{MayNotList: true} diff --git a/internal/auth/logout.go b/internal/auth/logout.go index 5add4c77..4827484b 100644 --- a/internal/auth/logout.go +++ b/internal/auth/logout.go @@ -36,11 +36,7 @@ func endSession(ctx context.Context, p *profile.Profile, oidcLogin *credential.O if oidcLogin.Cache == nil || oidcLogin.Cache.RefreshToken == "" { return errors.New("its refresh token is not cached") } - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - oidcClient, err := oidc.NewClient(ctx, httpClient, oidcLogin.Issuer, oidcLogin.ClientId) + oidcClient, err := oidc.NewClient(ctx, opts.HttpClient(), oidcLogin.Issuer, oidcLogin.ClientId) if err != nil { return err } diff --git a/internal/auth/session.go b/internal/auth/session.go index 4f0d5507..974e1393 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -8,9 +8,9 @@ import ( "sync" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/internal/auth/credential" - "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" @@ -23,6 +23,7 @@ type Session struct { Client func() (client.Client, error) MeshInfo func() (client.MeshInfo, error) + profiles profile.Profiles getWorkspace func() (meshstack.Workspace, error) httpClient http.Client } @@ -40,10 +41,12 @@ type ( ) func ResolveSession(ctx context.Context, opts ResolveSessionOptions) (Session, error) { - session, _, err := newSession(ctx, opts, false) + currentProfile, profiles, err := profile.ResolveProfile(ctx, opts.SettingSources) if err != nil { return Session{}, err } + session := newSessionFor(ctx, currentProfile, opts) + session.profiles = profiles resolved, err := session.resolveCredentials(ctx, opts) if err != nil { return Session{}, err @@ -61,10 +64,11 @@ func ResolveSession(ctx context.Context, opts ResolveSessionOptions) (Session, e return session.withCredential(ctx, resolved, opts) } -// Scope names the profile, and the workspace where the settings choose it: only a browser login -// works in that workspace, while an API key works in the one that owns it. -func (s Session) Scope() string { - scope := "profile " + string(s.CurrentProfile.Name) +func (s Session) String() string { + scope := string(s.Credential.Name()) + " profile " + string(s.CurrentProfile.Name) + if s.CurrentProfile.Name == "" { + scope = string(s.Credential.Name()) + " for " + s.CurrentProfile.Endpoint.String() + } if _, browserLogin := s.Credential.Credential.(*credential.OidcLogin); browserLogin { if workspace, err := s.getWorkspace(); err == nil && workspace != meshstack.NoWorkspace { scope += " working in workspace " + string(workspace) @@ -74,10 +78,7 @@ func (s Session) Scope() string { } func StoredSession(ctx context.Context, p *profile.Profile, opts ResolveSessionOptions) (Session, error) { - session, err := newSessionFor(ctx, p, opts) - if err != nil { - return Session{}, err - } + session := newSessionFor(ctx, p, opts) stored, err := session.storedCredential(ctx, nil) if err != nil { return Session{}, err @@ -88,49 +89,14 @@ func StoredSession(ctx context.Context, p *profile.Profile, opts ResolveSessionO return session.withCredential(ctx, stored, opts) } -// newSession leaves the profiles locked with exclusiveLock only where it succeeds. -func newSession(ctx context.Context, opts ResolveSessionOptions, exclusiveLock bool) (_ Session, _ profile.Profiles, err error) { - currentProfile, profiles, err := profile.ResolveProfile(ctx, profile.ResolveProfileOptions{ - SettingSources: opts.SettingSources, - ExclusiveLock: exclusiveLock, - }) - if err != nil { - return Session{}, profile.Profiles{}, err - } - defer func() { - if err != nil { - err = errors.Join(err, profiles.Unlock()) - } - }() - - endpoint, err := opts.ResolveSetting(ctx, meshstack.EndpointSetting, currentProfile.EndpointSource()) - if err != nil { - return Session{}, profile.Profiles{}, err - } else if !endpoint.Equal(currentProfile.Endpoint) { - // this prevents accidentally sending credentials to the wrong endpoint - return Session{}, profile.Profiles{}, fmt.Errorf("profile '%s' has the endpoint '%s', which does not match the endpoint '%s' of this command; "+ - "use --profile to choose a profile for that endpoint, or create one with 'meshstack login --endpoint %s --profile '", - currentProfile, currentProfile.Endpoint, endpoint, endpoint) - } - - session, err := newSessionFor(ctx, currentProfile, opts) - return session, profiles, err -} - // HttpClient is for a request that the meshStack client does not make, such as the download of the // API docs. It carries the user agent of the front end, as the session's client does. -func (opts ResolveSessionOptions) HttpClient() (http.Client, error) { - if err := opts.Validate(); err != nil { - return http.Client{}, err - } - return http.NewClient(opts.UserAgent), nil +func (opts ResolveSessionOptions) HttpClient() http.Client { + return http.NewClient(opts.UserAgent) } -func newSessionFor(ctx context.Context, currentProfile *profile.Profile, opts ResolveSessionOptions) (Session, error) { - httpClient, err := opts.HttpClient() - if err != nil { - return Session{}, err - } +func newSessionFor(ctx context.Context, currentProfile *profile.Profile, opts ResolveSessionOptions) Session { + httpClient := opts.HttpClient() return Session{ CurrentProfile: currentProfile, httpClient: httpClient, @@ -141,7 +107,7 @@ func newSessionFor(ctx context.Context, currentProfile *profile.Profile, opts Re getWorkspace: func() (meshstack.Workspace, error) { return meshstack.NoWorkspace, nil }, - }, nil + } } func (s Session) withCredential(ctx context.Context, cred Credential, opts ResolveSessionOptions) (Session, error) { @@ -177,8 +143,8 @@ func (s Session) buildClient(ctx context.Context, opts ResolveSessionOptions) (c return client.Client{}, workspaceErr } endpoint := s.CurrentProfile.Endpoint - slog.DebugContext(ctx, fmt.Sprintf("Building client for endpoint %s with user agent %s authenticated by %s, workspace %s", - endpoint, s.httpClient.UserAgent, s.Credential.Name(), workspace)) + slog.DebugContext(ctx, fmt.Sprintf("Building client for endpoint %s from %s, authenticated by %s, workspace %s", + endpoint, s.httpClient, s.Credential.Name(), workspace)) c := client.New(ctx, endpoint, s.httpClient, s) if skipVersionCheck, err := opts.ResolveSetting(ctx, meshstack.SkipVersionCheckSetting); err != nil { return client.Client{}, err @@ -199,16 +165,14 @@ func (s Session) buildClient(ctx context.Context, opts ResolveSessionOptions) (c // ResolveWorkspace resolves the workspace that a session of opts works in, as ResolveSession does, // and is NoWorkspace where no setting names one and the profile has no default workspace. func ResolveWorkspace(ctx context.Context, opts ResolveSessionOptions) (meshstack.Workspace, error) { - stored, _, err := profile.ResolveProfile(ctx, profile.ResolveProfileOptions{SettingSources: opts.SettingSources, StoredOnly: true}) - var defaultWorkspace setting.Source - switch { - case err == nil: - if !opts.IgnoreDefaultWorkspace { - defaultWorkspace = stored.WorkspaceSource() - } - case !errors.Is(err, profile.ErrNoStoredProfile): + currentProfile, _, err := profile.ResolveProfile(ctx, opts.SettingSources) + if err != nil { return meshstack.NoWorkspace, err } + var defaultWorkspace setting.Source + if !opts.IgnoreDefaultWorkspace { + defaultWorkspace = currentProfile.WorkspaceSource() + } workspace, err := opts.ResolveSetting(ctx, meshstack.WorkspaceSetting, defaultWorkspace) if errors.Is(err, setting.ErrNoSourceProvidedValue) { return meshstack.NoWorkspace, nil diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index 0b9408e6..5418ff01 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -8,16 +8,15 @@ import ( "net/url" "testing" "time" - "uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/internal/auth" "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/logs" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/profile" @@ -54,7 +53,7 @@ func TestSessionResolutionSaysWhyItFails(t *testing.T) { t.Run("without any credential", func(t *testing.T) { _, err := auth.ResolveSession(t.Context(), testSessionOpts) - require.ErrorContains(t, err, "selects no credential") + require.ErrorContains(t, err, "no credential for the endpoint "+server.URL+"; run 'meshstack login --endpoint "+server.URL+"'") require.ErrorContains(t, err, auth.ApiTokenSetting.EnvKey()) require.ErrorContains(t, err, auth.ApiKeyClientIdSetting.EnvKey()) require.ErrorContains(t, err, auth.ApiKeyClientSecretSetting.EnvKey()) @@ -118,7 +117,7 @@ func TestSessionOfAStoredApiKey(t *testing.T) { storeLogin(t, credential.ApiKeyName) t.Setenv(profile.NameSetting.EnvKey(), "") - current, _, err := profile.ResolveProfile(t.Context(), profile.ResolveProfileOptions{}) + current, _, err := profile.ResolveProfile(t.Context(), nil) require.NoError(t, err) assert.Equal(t, profile.Name("dev"), current.Name) }) @@ -202,9 +201,8 @@ func setApiKeyEnv(t *testing.T, key fakemeshstack.ApiKey) { // ask 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 ask(ctx context.Context, server *fakemeshstack.Server, session auth.Session) error { - _, err := http.NewClient(fakemeshstack.UserAgent).WithAuthorization(session). - DoRequest[[]byte](ctx, gohttp.MethodGet, must(url.Parse(server.URL+"/api/meshobjects/meshworkspaces"))) - return err + return http.IgnoreResponse(http.NewClient(fakemeshstack.UserAgent).WithAuthorization(session). + DoRequest(ctx, gohttp.MethodGet, must(url.Parse(server.URL+"/api/meshobjects/meshworkspaces")))) } func requireAnswer(t *testing.T, server *fakemeshstack.Server, session auth.Session) { @@ -224,7 +222,7 @@ func TestARefreshFinishesAndIsCachedThoughItsContextIsCancelledMidway(t *testing Name: "slow", Endpoint: xurl.MustParsef("%s", server.URL), Credential: credential.ApiKeyName, ConfigDir: config.Directory(t.TempDir()), } - apiKey := &credential.ApiKey{Endpoint: p.Endpoint, ClientId: uuid.MustParse(testApiKey1.ClientId), ClientSecret: testApiKey1.ClientSecret} + apiKey := &credential.ApiKey{Endpoint: p.Endpoint, ClientId: testApiKey1.ClientId, ClientSecret: testApiKey1.ClientSecret} creds, err := p.Credentials(t.Context()) require.NoError(t, err) creds.Set(apiKey) @@ -239,7 +237,7 @@ func TestARefreshFinishesAndIsCachedThoughItsContextIsCancelledMidway(t *testing require.NoError(t, err) listed := make(chan error, 1) go func() { - _, err := c.Workspace.List(ctx) + _, err := meshstack.ListWorkspaces(ctx, c.Workspace) listed <- err }() <-requested @@ -299,16 +297,19 @@ func TestSessionWarnsWhenTheEnvironmentOverridesTheStoredLogin(t *testing.T) { t.Run("another profile named in the environment overrides the current one", func(t *testing.T) { t.Setenv(profile.NameSetting.EnvKey(), "dev") + setApiKeyEnv(t, testApiKey1) + storeLogin(t, credential.ApiKeyName) + t.Setenv(profile.NameSetting.EnvKey(), "default") captured := logs.Capture(t) - _, _, err := profile.ResolveProfile(t.Context(), profile.ResolveProfileOptions{}) + _, _, err := profile.ResolveProfile(t.Context(), nil) require.NoError(t, err) lines := captured.Lines(slog.LevelWarn) require.Len(t, lines, 1) - assert.Contains(t, lines[0], "'dev', which overrides 'default' from the current profile") + assert.Contains(t, lines[0], "'default', which overrides 'dev' from the current profile") }) t.Run("a workspace in the environment overrides the default workspace", func(t *testing.T) { - current, profiles, err := profile.ResolveProfile(t.Context(), profile.ResolveProfileOptions{}) + current, profiles, err := profile.ResolveProfile(t.Context(), nil) require.NoError(t, err) current.DefaultWorkspace = "stored-workspace" require.NoError(t, profiles.Store(t.Context())) @@ -319,6 +320,6 @@ func TestSessionWarnsWhenTheEnvironmentOverridesTheStoredLogin(t *testing.T) { require.NoError(t, err) lines := captured.Lines(slog.LevelWarn) require.Len(t, lines, 1) - assert.Contains(t, lines[0], "'env-workspace', which overrides 'stored-workspace' from the default workspace in profile default") + assert.Contains(t, lines[0], "'env-workspace', which overrides 'stored-workspace' from the default workspace in profile dev") }) } diff --git a/internal/auth/status.go b/internal/auth/status.go index 8278183d..5ad7854f 100644 --- a/internal/auth/status.go +++ b/internal/auth/status.go @@ -9,7 +9,6 @@ import ( "maps" "slices" "time" - "uuid" "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/internal/auth/credential" @@ -22,7 +21,8 @@ import ( // CredentialStatus never carries a secret. A part that cannot be read is logged as a warning and // left at its zero value. type CredentialStatus struct { - Profile profile.Name `json:"profile"` + Profile profile.Name `json:"profile,omitzero"` + LoginHint string `json:"-"` Endpoint xurl.URL `json:"endpoint"` Kind credential.Name `json:"credential"` Sources []string `json:"sources"` @@ -91,17 +91,18 @@ func BrowserLoginEnded(ctx context.Context, p *profile.Profile) bool { } type ApiKeyStatus struct { - ClientId uuid.UUID `json:"clientId"` + ClientId string `json:"clientId"` } // Status reads only the stored credential and its cached token, and calls no meshStack. It never // renews a browser login, which would rotate its refresh token. func (s Session) Status(ctx context.Context) CredentialStatus { status := CredentialStatus{ - Profile: s.CurrentProfile.Name, - Endpoint: s.CurrentProfile.Endpoint, - Kind: s.Credential.Name(), - Sources: s.Credential.Sources, + Profile: s.CurrentProfile.Name, + LoginHint: s.CurrentProfile.LoginHint(), + Endpoint: s.CurrentProfile.Endpoint, + Kind: s.Credential.Name(), + Sources: s.Credential.Sources, } workspace, err := s.getWorkspace() if err != nil && !errors.Is(err, setting.ErrNoSourceProvidedValue) { @@ -139,7 +140,7 @@ func (s Session) unusedCredentials(ctx context.Context, workspace meshstack.Work } for _, name := range credential.Names { cred := stored.ByName(name) - if cred == nil || name == s.Credential.Name() && s.Credential.Stored { + if cred == nil || cred.Identity() == s.Credential.Identity() { continue } if err := CacheFor(s.CurrentProfile, cred).Load(ctx); err != nil { @@ -148,7 +149,7 @@ func (s Session) unusedCredentials(ctx context.Context, workspace meshstack.Work entry := UnusedCredential{Kind: name, Selected: name == s.CurrentProfile.Credential} switch cred := cred.(type) { case *credential.ApiKey: - entry.ClientId = cred.ClientId.String() + entry.ClientId = cred.ClientId case *credential.OidcLogin: if token, found := oidcTokenFor(cred, workspace); found { entry.Token = tokenStatusOf(token) diff --git a/internal/auth/status_test.go b/internal/auth/status_test.go index 1986a487..2aefc3c9 100644 --- a/internal/auth/status_test.go +++ b/internal/auth/status_test.go @@ -2,11 +2,9 @@ package auth_test import ( "encoding/base64" - "encoding/json/v2" "log/slog" "testing" "time" - "uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -15,6 +13,7 @@ import ( "github.com/meshcloud/meshstack-cli/internal/auth" "github.com/meshcloud/meshstack-cli/internal/auth/credential" "github.com/meshcloud/meshstack-cli/internal/config" + "github.com/meshcloud/meshstack-cli/internal/json" "github.com/meshcloud/meshstack-cli/internal/logs" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/oidc" @@ -62,13 +61,13 @@ func TestStatusOfABrowserLogin(t *testing.T) { session, err := auth.StoredSession(t.Context(), p, testSessionOpts) require.NoError(t, err) - assert.Equal(t, "profile dev working in workspace ops", session.Scope()) + assert.Equal(t, "oidcLogin profile dev working in workspace ops", session.String()) }) t.Run("lists the stored credentials besides the one in use", func(t *testing.T) { creds, err := p.Credentials(t.Context()) require.NoError(t, err) - creds.Set(&credential.ApiKey{Endpoint: p.Endpoint, ClientId: uuid.MustParse(testApiKey2.ClientId), ClientSecret: testApiKey2.ClientSecret}) + creds.Set(&credential.ApiKey{Endpoint: p.Endpoint, ClientId: testApiKey2.ClientId, ClientSecret: testApiKey2.ClientSecret}) require.NoError(t, creds.Store(t.Context())) assert.Equal(t, []auth.UnusedCredential{{Kind: credential.ApiKeyName, ClientId: testApiKey2.ClientId}}, status(t).Unused) @@ -86,7 +85,7 @@ func TestStatusOfAnApiKeyIsReadFromFilesAndTheEnvironment(t *testing.T) { assert.Equal(t, []string{"env MESHSTACK_API_KEY", "env MESHSTACK_API_SECRET"}, status.Sources) require.NotNil(t, status.ApiKey) - assert.Equal(t, testApiKey1.ClientId, status.ApiKey.ClientId.String()) + assert.Equal(t, testApiKey1.ClientId, status.ApiKey.ClientId) assert.Nil(t, status.Token, "no token is cached before the key is first used") assert.Empty(t, captured.Records(slog.LevelWarn)) } diff --git a/internal/auth/version_check.go b/internal/auth/version_check.go index 7ceb31ed..4eccc47d 100644 --- a/internal/auth/version_check.go +++ b/internal/auth/version_check.go @@ -8,11 +8,11 @@ import ( "log/slog" "time" + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/client/types/version" "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 ( @@ -20,7 +20,7 @@ const ( releaseCheckTimeout = 10 * time.Second ) -// warnIfNewerReleasePresent lives here rather than in internal/version, which the depguard rule +// warnIfNewerReleasePresent lives here rather than in client/types/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) @@ -52,9 +52,9 @@ func warnIfNewerReleasePresent(ctx context.Context, configDir config.Directory, 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 { + latestRelease, err := http.UnmarshalJsonBody[struct { TagName string `json:"tag_name"` - }](ctxRequest, http.MethodGet, apiUrl.URL, http.Retryable()) + }](client.DoRequest(ctxRequest, http.MethodGet, apiUrl.URL, http.Retryable(http.RetryOnTransient))) 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 { diff --git a/internal/auth/workspace_test.go b/internal/auth/workspace_test.go index c72234d3..4f5e8f1b 100644 --- a/internal/auth/workspace_test.go +++ b/internal/auth/workspace_test.go @@ -7,10 +7,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/internal/auth" "github.com/meshcloud/meshstack-cli/internal/auth/credential" - "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" diff --git a/internal/http/auth.go b/internal/http/auth.go deleted file mode 100644 index d0a6cea8..00000000 --- a/internal/http/auth.go +++ /dev/null @@ -1,98 +0,0 @@ -package http - -import ( - "context" - "errors" - "fmt" - "log/slog" - gohttp "net/http" - "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) - - // Scope names what meshStack grants access by, so that a 403 says it. Empty names nothing. - Scope() string -} - -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) (R, error) { - result, err := withBearerToken(ctx, c.Authorization, method, url, func(token RequestOption) (R, error) { - return c.Client.DoRequest[R](ctx, method, url, append(options, token)...) - }) - if httpErr, ok := errors.AsType[Error](err); ok && httpErr.IsForbidden() && c.Authorization.Scope() != "" { - err = fmt.Errorf("auth scope %s: %w", c.Authorization.Scope(), err) - } - return result, err -} - -// Authorize is for a request that this client does not send itself, such as one a reverse proxy -// passes on. Such a request gets no second try after a 401. -func (c AuthorizedClient) Authorize(r *gohttp.Request) error { - token, err := c.Authorization.GetBearerToken(r.Context()) - if err != nil { - return err - } - r.Header.Set("Authorization", "Bearer "+string(token)) - return nil -} - -func withBearerToken[R any](ctx context.Context, auth Authorization, method string, url *url.URL, send func(token RequestOption) (R, error)) (result R, err error) { - token, tokenErr := auth.GetBearerToken(ctx) - if tokenErr != nil { - return result, tokenErr - } - result, err = send(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 := auth.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 send(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) Scope() string { - return "" -} - -func (token BearerToken) asRequestOption() RequestOption { - return withHeader("Authorization", fmt.Sprintf("Bearer %s", token)) -} diff --git a/internal/http/download.go b/internal/http/download.go deleted file mode 100644 index b403a0ae..00000000 --- a/internal/http/download.go +++ /dev/null @@ -1,171 +0,0 @@ -package http - -import ( - "context" - "errors" - "fmt" - "io" - "io/fs" - "log/slog" - gohttp "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "time" - - "github.com/meshcloud/meshstack-cli/internal/json" -) - -const ( - // A command starts without an open connection, and DNS, TCP, TLS and a 304 from CloudFront took - // 55 to 100ms, so a shorter wait would give up on a fast network as well. - waitForNewerDownload = 200 * time.Millisecond - downloadTimeout = 2 * time.Minute - downloadCheckInterval = 24 * time.Hour -) - -type ( - DownloadOption func(opts *downloadOptions) - - downloadOptions struct { - conditionalGet bool - } -) - -// WithConditionalGet asks for a newer document on every download. Without it, Download asks -// once per downloadCheckInterval, to save the round trip. -func WithConditionalGet(conditionalGet bool) DownloadOption { - return func(opts *downloadOptions) { - opts.conditionalGet = conditionalGet - } -} - -// Download waits at most waitForNewerDownload for a newer document where path holds one already. -// After that, the caller reads the stored document, and wait lets the download finish for the next -// command. -func (c Client) Download(ctx context.Context, url *url.URL, path string, options ...DownloadOption) (wait func(), err error) { - var opts downloadOptions - for _, option := range options { - option(&opts) - } - noWait := func() {} - stored, statErr := os.Stat(path) - if statErr != nil { - downloadCtx, cancel := context.WithTimeout(ctx, downloadTimeout) - defer cancel() - if err := c.download(downloadCtx, url, path, nil); err != nil { - return noWait, err - } - return noWait, opts.recordCheck(ctx, path) - } - if due, err := opts.checkDue(ctx, path); err != nil || !due { - return noWait, err - } - - done := make(chan error, 1) - go func() { - // Derived from ctx, so that Ctrl-C ends the wait: until the command returns, main's - // signal.NotifyContext swallows every further Ctrl-C. - downloadCtx, cancel := context.WithTimeout(ctx, downloadTimeout) - defer cancel() - err := c.download(downloadCtx, url, path, []RequestOption{ - withHeader("If-Modified-Since", stored.ModTime().UTC().Format(gohttp.TimeFormat)), - }) - // A cancelled download leaves the check due, so that the next command tries again. - if ctx.Err() != nil { - done <- nil - return - } - done <- errors.Join(err, opts.recordCheck(ctx, path)) - }() - warnIfFailed := func(err error) { - if err != nil { - slog.WarnContext(ctx, fmt.Sprintf("Cannot update %s, which is from %s: %s", path, stored.ModTime().Format(time.DateTime), err)) - } - } - select { - case err := <-done: - warnIfFailed(err) - return noWait, nil - case <-time.After(waitForNewerDownload): - slog.WarnContext(ctx, fmt.Sprintf("Cannot update %s within %s, so this command reads the one from %s. "+ - "The command ends once the download has finished.", path, waitForNewerDownload, stored.ModTime().Format(time.DateTime))) - return func() { warnIfFailed(<-done) }, nil - } -} - -type downloadCheck struct { - LastCheck time.Time `json:"lastCheck"` -} - -func lastCheckJson(path string) string { - return strings.TrimSuffix(path, filepath.Ext(path)) + ".lastcheck.json" -} - -func (opts downloadOptions) checkDue(ctx context.Context, path string) (bool, error) { - if opts.conditionalGet { - return true, nil - } - var check downloadCheck - if err := json.UnmarshalFrom(ctx, lastCheckJson(path), &check); errors.Is(err, fs.ErrNotExist) { - return true, nil - } else if err != nil { - return false, err - } - if untilNext := downloadCheckInterval - time.Since(check.LastCheck); untilNext > 0 { - slog.DebugContext(ctx, fmt.Sprintf("Not checking %s for a newer one, the next check is due in %s", path, untilNext.Round(time.Minute))) - return false, nil - } - return true, nil -} - -func (opts downloadOptions) recordCheck(ctx context.Context, path string) error { - if opts.conditionalGet { - return nil - } - return json.MarshalTo(ctx, lastCheckJson(path), downloadCheck{time.Now()}) -} - -func (c Client) download(ctx context.Context, url *url.URL, path string, options []RequestOption) (err error) { - // No option sets an Accept-Encoding, so that net/http asks for gzip and decompresses the answer: - // the API docs then come as 0.5MB rather than 4.7MB. CloudFront serves brotli as well, but it - // saves only 4 to 8% over gzip, too little for a dependency. - res, err := c.send(ctx, gohttp.MethodGet, url, options) - if err != nil { - return err - } - defer func() { - _ = res.Body.Close() - }() - if res.StatusCode == gohttp.StatusNotModified { - slog.DebugContext(ctx, path+" is up to date") - return nil - } - if !isSuccess(res.StatusCode) { - _, err = c.readBodyAndCheckSuccess(ctx, res) - return err - } - - // The document is renamed into place, so that no reader sees half of it. - tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp") - if err != nil { - return err - } - defer func() { - if removeErr := os.Remove(tmp.Name()); !errors.Is(removeErr, os.ErrNotExist) { - err = errors.Join(err, removeErr) - } - }() - _, copyErr := io.Copy(tmp, res.Body) - if err = errors.Join(copyErr, tmp.Close()); err != nil { - return fmt.Errorf("cannot download %s: %w", url, err) - } - if lastModified, parseErr := gohttp.ParseTime(res.Header.Get("Last-Modified")); parseErr == nil { - if err = os.Chtimes(tmp.Name(), time.Time{}, lastModified); err != nil { - return err - } - } - slog.DebugContext(ctx, fmt.Sprintf("downloaded %s to %s", url, path)) - return os.Rename(tmp.Name(), path) -} diff --git a/internal/http/download_test.go b/internal/http/download_test.go deleted file mode 100644 index 45e73223..00000000 --- a/internal/http/download_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package http_test - -import ( - "compress/gzip" - "log/slog" - gohttp "net/http" - "os" - "path/filepath" - "strings" - "testing" - "testing/synctest" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/meshcloud/meshstack-cli/internal/http" - "github.com/meshcloud/meshstack-cli/internal/logs" -) - -type fakeDocument struct { - body string - modified time.Time - missing bool - release chan struct{} - answers []int - // contentEncodings has one entry per document sent, and none per 304 or 404. - contentEncodings []string -} - -func (d *fakeDocument) serve(resp gohttp.ResponseWriter, req *gohttp.Request) { - if d.release != nil { - <-d.release - } - since, sinceErr := gohttp.ParseTime(req.Header.Get("If-Modified-Since")) - switch { - case d.missing: - d.answers = append(d.answers, gohttp.StatusNotFound) - resp.WriteHeader(gohttp.StatusNotFound) - case sinceErr == nil && !since.Before(d.modified): - d.answers = append(d.answers, gohttp.StatusNotModified) - resp.WriteHeader(gohttp.StatusNotModified) - default: - d.answers = append(d.answers, gohttp.StatusOK) - resp.Header().Set("Last-Modified", d.modified.Format(gohttp.TimeFormat)) - if !strings.Contains(req.Header.Get("Accept-Encoding"), "gzip") { - d.contentEncodings = append(d.contentEncodings, "identity") - _, _ = resp.Write([]byte(d.body)) - return - } - d.contentEncodings = append(d.contentEncodings, "gzip") - resp.Header().Set("Content-Encoding", "gzip") - compressed := gzip.NewWriter(resp) - _, _ = compressed.Write([]byte(d.body)) - _ = compressed.Close() - } -} - -func TestDownload(t *testing.T) { - path := filepath.Join(t.TempDir(), "doc.json") - document := &fakeDocument{body: `{"version": 1}`, modified: time.Date(2026, 9, 30, 13, 49, 36, 0, time.UTC)} - download := func(t *testing.T, options ...http.DownloadOption) (wait func()) { - t.Helper() - client := newTestClientWithServer(t, document.serve) - wait, err := client.Download(t.Context(), client.ServerUrl, path, options...) - require.NoError(t, err) - return wait - } - - t.Run("stores the document, and checks once a day for a newer one, even after a failed check", func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - captured := logs.Capture(t) - - download(t)() - assert.Equal(t, []string{"gzip"}, document.contentEncodings, "the 4.7MB of the API docs come as 0.5MB") - assert.JSONEq(t, `{"version": 1}`, readDocument(t, path), "the stored document is decompressed") - stat, err := os.Stat(path) - require.NoError(t, err) - assert.True(t, document.modified.Equal(stat.ModTime()), "the mtime is the Last-Modified") - - synctest.Sleep(23 * time.Hour) - download(t)() - assert.Equal(t, []int{gohttp.StatusOK}, document.answers, "a check within a day is not due") - - synctest.Sleep(time.Hour) - download(t)() - assert.Equal(t, []int{gohttp.StatusOK, gohttp.StatusNotModified}, document.answers) - assert.Empty(t, captured.Records(slog.LevelWarn)) - - synctest.Sleep(24 * time.Hour) - document.missing = true - download(t)() - download(t)() - assert.Equal(t, []int{gohttp.StatusOK, gohttp.StatusNotModified, gohttp.StatusNotFound}, document.answers) - require.Len(t, captured.Records(slog.LevelWarn), 1) - assert.Contains(t, captured.Lines(slog.LevelWarn)[0], "Cannot update "+path) - assert.JSONEq(t, `{"version": 1}`, readDocument(t, path)) - }) - }) - - t.Run("goes on with the stored document while a slow download finishes", func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - captured := logs.Capture(t) - document.missing = false - document.body, document.modified = `{"version": 2}`, document.modified.Add(time.Hour) - document.release = make(chan struct{}) - - wait := download(t, http.WithConditionalGet(true)) - assert.JSONEq(t, `{"version": 1}`, readDocument(t, path), "the caller reads the stored document") - require.Len(t, captured.Records(slog.LevelWarn), 1) - assert.Contains(t, captured.Lines(slog.LevelWarn)[0], "The command ends once the download has finished.") - - close(document.release) - wait() - assert.JSONEq(t, `{"version": 2}`, readDocument(t, path), "the next command reads the new document") - }) - }) - - t.Run("fails without a stored document to fall back to, and leaves nothing behind", func(t *testing.T) { - client := newTestClientWithServer(t, (&fakeDocument{missing: true}).serve) - dir := t.TempDir() - - _, err := client.Download(t.Context(), client.ServerUrl, filepath.Join(dir, "doc.json")) - - require.ErrorContains(t, err, "404") - entries, err := os.ReadDir(dir) - require.NoError(t, err) - assert.Empty(t, entries) - }) -} - -func readDocument(t *testing.T, path string) string { - t.Helper() - content, err := os.ReadFile(filepath.Clean(path)) - require.NoError(t, err) - return string(content) -} diff --git a/internal/http/http_client.go b/internal/http/http_client.go deleted file mode 100644 index 4a893532..00000000 --- a/internal/http/http_client.go +++ /dev/null @@ -1,149 +0,0 @@ -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{Transport: newTransport(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 -}() - -func newTransport(silenceTimeout time.Duration) gohttp.RoundTripper { - transport := gohttp.DefaultTransport.(*gohttp.Transport).Clone() //nolint:forcetypeassert // net/http declares it a *Transport - transport.ResponseHeaderTimeout = silenceTimeout - return stallGuard{Next: transport, Timeout: silenceTimeout} -} - -type Client struct { - *gohttp.Client - - UserAgent UserAgent -} - -// NewClient panics on a UserAgent that does not validate, as a request has to name its front end. -func NewClient(userAgent UserAgent) Client { - if err := userAgent.Validate(); err != nil { - panic(err) - } - return Client{sharedClient, userAgent} -} - -// DoRequest sends one request and parses the answer as JSON, or returns it as it came, empty -// included, for an R of []byte. 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 raw, ok := any(&result).(*[]byte); ok { - *raw = body - return - } - if len(body) == 0 { - // Only a call typed DoRequest[any], such as a delete, expects no content. Any other call - // fails here, because its caller would dereference a nil result or read it as "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) { - res, err := c.send(ctx, method, url, options) - if err != nil { - return nil, err - } - defer func() { - _ = res.Body.Close() - }() - return c.readBodyAndCheckSuccess(ctx, res) -} - -func (c Client) send(ctx context.Context, method string, url *url.URL, options []RequestOption) (*gohttp.Response, error) { - options = slices.Insert(options, 0, withHeader("User-Agent", c.UserAgent.String())) - opts := requestOptions{} - for _, option := range options { - option(&opts) - } - req, err := c.buildRequest(ctx, method, url, opts) - if err != nil { - return nil, err - } - return c.Do(req) -} - -func isSuccess(statusCode int) bool { - return statusCode >= 200 && statusCode <= 299 -} - -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 isSuccess(res.StatusCode) { - 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 deleted file mode 100644 index ddd57ca1..00000000 --- a/internal/http/http_client_test.go +++ /dev/null @@ -1,436 +0,0 @@ -// 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" - "testing/synctest" - "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/logs" - "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" - "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" -) - -func TestHttpClient(t *testing.T) { - t.Run("DoRequest success", func(t *testing.T) { - captured := logs.Capture(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, "fakemeshstack/test", 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(`level=DEBUG msg=request url=%s/get method=GET headers="User-Agent=fakemeshstack/test" body=`, client.ServerUrl), - `level=DEBUG msg=response status=200 body="\"some-answer\""`, - }, captured.Lines(slog.LevelDebug)) - }) - - 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) - }) - _, 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) { - synctest.Test(t, func(t *testing.T) { - captured := logs.Capture(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}) - start := time.Now() - - _, err := client.DoRequest[any](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("get")) - - require.NoError(t, err) - assert.Equal(t, time.Second, time.Since(start), "the retry waited as long as it logged") - if retryableStatusCode == 429 { - assert.Equal(t, 0, retryTestBackoff.Called) - } else { - assert.Equal(t, 1, retryTestBackoff.Called) - } - assert.Equal(t, []string{ - fmt.Sprintf(`level=WARN msg="retrying request" status=%d method=GET path=/get attempt=1/3 waitTime=1s`, retryableStatusCode), - }, captured.Lines(slog.LevelWarn)) - }) - }) - } - }) - - t.Run("DoRequest with 2 retries exhausted", func(t *testing.T) { - captured := logs.Capture(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{ - fmt.Sprintf(`level=DEBUG msg=request url=%s/get method=GET headers="User-Agent=fakemeshstack/test" body=`, client.ServerUrl), - `level=WARN msg="retrying request" status=502 method=GET path=/get attempt=1/2 waitTime=0s`, - `level=WARN msg="retrying request" status=502 method=GET path=/get attempt=2/2 waitTime=0s`, - `level=DEBUG msg=response status=502 body=`, - }, captured.Lines(slog.LevelDebug)) - }) - - t.Run("DoRequest with context cancelled during backoff", func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - const cancelAfter = 1 * time.Second - backoff := retryTestBackoff{WaitTime: 10 * time.Second} - client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, _ *gohttp.Request) { - resp.WriteHeader(gohttp.StatusBadGateway) - }), http.RetryOptions{MaxRetries: 3, Backoff: &backoff}) - // The bubble's clock only moves once the 502 has arrived and the backoff waits, so this - // cancels inside the wait. Cancelling from the handler raced the 502 back to the client. - ctx, cancel := context.WithCancel(t.Context()) - time.AfterFunc(cancelAfter, cancel) - start := time.Now() - - _, err := client.DoRequest[any](ctx, gohttp.MethodGet, client.ServerUrl.JoinPath("get")) - - require.ErrorIs(t, err, context.Canceled) - assert.Equal(t, 1, backoff.Called) - assert.Equal(t, cancelAfter, time.Since(start), "the cancellation ended the backoff rather than waiting it out") - }) - }) - - // GET is the only method the client replays unasked, so MeshObjectClient marks its - // idempotent PUT and DELETE with Retryable. - t.Run("DoRequest replays another method than GET only when the caller marked it Retryable, body included", func(t *testing.T) { - var bodies []string - client := withTestRetry(newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { - body, _ := io.ReadAll(req.Body) - bodies = append(bodies, string(body)) - if len(bodies) == 1 { - resp.WriteHeader(gohttp.StatusServiceUnavailable) - return - } - resp.WriteHeader(gohttp.StatusNoContent) - }), http.RetryOptions{MaxRetries: 3, Backoff: &retryTestBackoff{}}) - payload := http.WithJsonPayload(map[string]string{"key": "value"}, "application/json") - - for _, method := range []string{gohttp.MethodPatch, gohttp.MethodPost, gohttp.MethodPut, gohttp.MethodDelete} { - bodies = nil - _, err := client.DoRequest[any](t.Context(), method, client.ServerUrl, payload) - require.Error(t, err, method) - assert.Len(t, bodies, 1, "%s may change something, so replaying it needs the caller's word", method) - - bodies = nil - _, err = client.DoRequest[any](t.Context(), method, client.ServerUrl, payload, http.Retryable()) - require.NoError(t, err, method) - assert.Equal(t, []string{`{"key":"value"}`, `{"key":"value"}`}, bodies, method) - } - }) - - 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) - }) - }) - - t.Run("DoRequest of []byte returns the retried answer as it came, empty included", func(t *testing.T) { - auth := &refreshableAuthorization{token: "stale"} - answer := "plain text" - client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { - if req.Header.Get("Authorization") == "Bearer stale" { - resp.WriteHeader(gohttp.StatusUnauthorized) - _, _ = io.WriteString(resp, "expired") - return - } - _, _ = io.WriteString(resp, answer) - }) - body, err := client.WithAuthorization(auth).DoRequest[[]byte](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("get")) - require.NoError(t, err) - assert.Equal(t, "plain text", string(body)) - - answer = "" - body, err = client.DoRequest[[]byte](t.Context(), gohttp.MethodDelete, client.ServerUrl.JoinPath("delete")) - require.NoError(t, err) - assert.Empty(t, body) - }) -} - -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) Scope() string { - return "" -} - -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) { - var got url.Values - client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { - got = req.URL.Query() - _, _ = resp.Write([]byte(`"ok"`)) - }) - queryFrom := func(t *testing.T, queries ...any) url.Values { - t.Helper() - var options []http.RequestOption - for _, query := range queries { - options = append(options, http.WithUrlQuery(query)) - } - _, err := client.DoRequest[string](t.Context(), gohttp.MethodGet, client.ServerUrl.JoinPath("list"), options...) - require.NoError(t, err) - return got - } - - t.Run("a map or url.Values goes as given, a zero value and a repeated parameter included", func(t *testing.T) { - query := url.Values{"dry": {"true"}, "tag": {"a", "b"}, "empty": {""}} - assert.Equal(t, query, queryFrom(t, query)) - assert.Equal(t, url.Values{"page": {"0"}, "status": {"SUCCEEDED"}}, queryFrom(t, map[string]any{"page": 0, "status": "SUCCEEDED"})) - }) - - t.Run("a second query adds to the first", func(t *testing.T) { - assert.Equal(t, url.Values{"buildingBlockDefinitionUuid": {"abc"}, "page": {"2"}}, - queryFrom(t, map[string]string{"buildingBlockDefinitionUuid": "abc"}, map[string]any{"page": 2})) - }) - - t.Run("a struct names its fields by json tag and drops the zero ones", func(t *testing.T) { - type filter struct { - Identifier *string `json:"identifier"` - Name string `json:"name"` - Restricted *bool `json:"restricted"` - ExcludeTitle []string `json:"excludeTitle"` - Other []string `json:"other"` - } - assert.Equal(t, url.Values{"identifier": {"abc"}, "excludeTitle": {"Workspace Created", "Tenant Deleted"}}, - queryFrom(t, filter{Identifier: new("abc"), ExcludeTitle: []string{"Workspace Created", "", "Tenant Deleted"}}), - "a slice is the parameter repeated, and drops an empty string like a zero field") - assert.Empty(t, queryFrom(t, &struct { - Identifier *string `json:"identifier"` - }{}), "a pointer to a struct drops its nil fields") - }) -} - -func TestFormPayloadOption(t *testing.T) { - var header gohttp.Header - var form url.Values - var answer string - client := newTestClientWithServer(t, func(resp gohttp.ResponseWriter, req *gohttp.Request) { - assert.NoError(t, req.ParseForm()) - header, form = req.Header, req.PostForm - _, _ = io.WriteString(resp, answer) - }) - postForm := func(t *testing.T, payload any) { - t.Helper() - answer = `"ok"` - _, err := client.DoRequest[string](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("token"), http.WithFormPayload(payload)) - require.NoError(t, err) - } - - 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"` - } - 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, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {"the-rotating-one"}, "client_id": {"meshstack-cli"}}, form, - "a field the grant does not use must not be sent empty") - }) - - t.Run("no payload sends no body", func(t *testing.T) { - postForm(t, nil) - assert.Empty(t, form) - assert.Empty(t, header.Get("Content-Type")) - }) - - t.Run("a form sends a URL and a JWT 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"` - Unset *xurl.URL `json:"unset_uri"` - } - var idToken jwt.JWT - require.NoError(t, idToken.UnmarshalText([]byte(unsignedJwt(`{"sub":"someone"}`)))) - - postForm(t, logoutRequest{RedirectUri: xurl.MustParsef("http://127.0.0.1:31234/callback"), IdToken: idToken}) - assert.Equal(t, url.Values{"post_logout_redirect_uri": {"http://127.0.0.1:31234/callback"}, "id_token_hint": {idToken.String()}}, form, - "a URL nobody set is dropped like any other zero value") - }) - - type tokenResponse struct { - Issuer xurl.URL `json:"issuer"` - AccessToken jwt.JWT `json:"access_token"` - } - t.Run("the answer parses into the types the caller declared", func(t *testing.T) { - accessToken := unsignedJwt(`{"MC_CUSTOMER":"my-workspace"}`) - answer = fmt.Sprintf(`{"issuer":"https://sso.example.com/realms/meshfed","access_token":%q}`, accessToken) - - got, err := client.DoRequest[tokenResponse](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("token")) - require.NoError(t, err) - 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) { - answer = `{"access_token":"an-opaque-token"}` - - _, err := client.DoRequest[tokenResponse](t.Context(), gohttp.MethodPost, client.ServerUrl.JoinPath("token")) - assert.ErrorContains(t, err, "not a JWT") - }) -} - -func unsignedJwt(claims string) string { - return "e30." + base64.RawURLEncoding.EncodeToString([]byte(claims)) + ".not-a-signature" -} - -type TestClient struct { - http.Client - - ServerUrl *url.URL -} - -func newTestClientWithServer(t *testing.T, handlerFunc gohttp.HandlerFunc) TestClient { - t.Helper() - // In memory, so that a test can run inside a synctest bubble. - server := httptest.NewTestServer(t, handlerFunc) - client := server.Client() - serverUrl, err := url.Parse(server.URL) - require.NoError(t, err) - return TestClient{http.Client{Client: client, UserAgent: fakemeshstack.UserAgent}, 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 -} - -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/logging.go b/internal/http/logging.go deleted file mode 100644 index d6bfadb4..00000000 --- a/internal/http/logging.go +++ /dev/null @@ -1,154 +0,0 @@ -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] { - 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 deleted file mode 100644 index ae7accb0..00000000 --- a/internal/http/logging_internal_test.go +++ /dev/null @@ -1,58 +0,0 @@ -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 TestLoggedBodyRedactsCredentialsAndKeepsTheRest(t *testing.T) { - const secret = "s3cr3t" - tests := map[string]string{ - "api key login": `{"clientId":"an-id","clientSecret":"` + secret + `","at":1234567890123456789}`, - "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) - }) - } - - rendered := loggedBody{bytes.NewBufferString(tests["api key login"])}.String() - assert.Contains(t, rendered, "an-id", "what is no credential stays") - assert.Contains(t, rendered, "1234567890123456789", "a large integer stays exact") -} - -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 deleted file mode 100644 index 8bcbf189..00000000 --- a/internal/http/logging_test.go +++ /dev/null @@ -1,38 +0,0 @@ -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/options.go b/internal/http/options.go deleted file mode 100644 index d53b09a3..00000000 --- a/internal/http/options.go +++ /dev/null @@ -1,182 +0,0 @@ -package http - -import ( - "context" - "encoding/json/jsontext" - "fmt" - "maps" - gohttp "net/http" - "net/url" - "reflect" - - "github.com/meshcloud/meshstack-cli/internal/json" -) - -type ( - 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 sends each field of query as a query parameter named by its `json` tag. A struct -// passed by value leaves out every field that has the zero value, so an unset filter needs neither -// a pointer nor `omitempty`. A map is sent as given, so a deliberate zero such as page=0 stays. -// Each value is sent as the JSON literal it marshals to, with a string unquoted, and an array as the -// parameter repeated. Nested objects 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) - } - skipZero := reflect.ValueOf(structOrMap).Kind() == reflect.Struct - result := url.Values{} - for key, value := range converted { - values := []jsontext.Value{value} - if value.Kind() == '[' { - if err := json.Unmarshal(value, &values); err != nil { - return nil, fmt.Errorf("cannot read %s of type %T as an array: %w", key, structOrMap, err) - } - } - for _, value := range values { - 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 WithHeaders(header gohttp.Header) RequestOption { - return appendRequestModifier(func(req *gohttp.Request) error { - for key, values := range header { - req.Header.Del(key) - for _, value := range values { - req.Header.Add(key, value) - } - } - return nil - }) -} - -// WithBody sends the bytes as they are, so the caller names their type in a Content-Type header. -func WithBody(body []byte) RequestOption { - return func(opts *requestOptions) { - if body == nil { - return - } - opts.requestPayload = func() ([]byte, error) { - return body, nil - } - } -} - -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_test.go b/internal/http/retry_test.go deleted file mode 100644 index fa172761..00000000 --- a/internal/http/retry_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package http - -import ( - "context" - "errors" - "fmt" - "net" - gohttp "net/http" - "net/http/httptest" - "sync/atomic" - "syscall" - "testing" - "testing/synctest" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -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) { - 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}, - {"capped at 5 minutes", "600", 5 * time.Minute}, - {"empty header", "", 1 * time.Second}, - {"unparseable header", "not-a-number-or-date", 1 * time.Second}, - {"HTTP-date in the past", bubbleStart.Add(-10 * time.Second).Format(gohttp.TimeFormat), 1 * time.Second}, - {"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)) - }) - }) - } -} - -type roundTripperFunc func(*gohttp.Request) (*gohttp.Response, error) - -func (f roundTripperFunc) RoundTrip(req *gohttp.Request) (*gohttp.Response, error) { - return f(req) -} - -func TestRetryStopsOnceTheContextIsDone(t *testing.T) { - interrupted := errors.New("interrupt signal received") - ctx, cancel := context.WithCancelCause(t.Context()) - cancel(interrupted) - req, err := gohttp.NewRequestWithContext(ctx, MethodGet, "http://meshstack.invalid", nil) - require.NoError(t, err) - - calls := 0 - retrying := &retryRoundTripper{ - Next: roundTripperFunc(func(req *gohttp.Request) (*gohttp.Response, error) { - calls++ - return nil, context.Cause(req.Context()) - }), - MaxRetries: 3, - ShouldRetryRequest: func(*gohttp.Request) bool { return true }, - ShouldRetryResponse: func(*gohttp.Response, error) RetryBackoff { return ExponentialBackoff{} }, - } - - _, err = retrying.RoundTrip(req) //nolint:bodyclose // no response comes back - require.ErrorIs(t, err, interrupted) - assert.Equal(t, 1, calls) -} - -func TestRetryGivesUpOnAServerThatDoesNotAnswer(t *testing.T) { - var calls atomic.Int32 - server := httptest.NewServer(gohttp.HandlerFunc(func(_ gohttp.ResponseWriter, r *gohttp.Request) { - calls.Add(1) - <-r.Context().Done() - })) - defer server.Close() - client := &gohttp.Client{Transport: newTransport(10 * time.Millisecond)} - RetryOptions{MaxRetries: 3, Backoff: ExponentialBackoff{}}.ApplyTo(client) - - _, err := client.Get(server.URL) //nolint:bodyclose,noctx // no response comes back - - require.ErrorContains(t, err, "timeout awaiting response headers") - assert.Equal(t, int32(1), calls.Load()) -} - -func TestRetryGivesUpOnARefusedConnection(t *testing.T) { - server := httptest.NewServer(gohttp.NotFoundHandler()) - server.Close() - var calls atomic.Int32 - client := &gohttp.Client{Transport: roundTripperFunc(func(req *gohttp.Request) (*gohttp.Response, error) { - calls.Add(1) - return gohttp.DefaultTransport.RoundTrip(req) - })} - RetryOptions{MaxRetries: 3, Backoff: ExponentialBackoff{}}.ApplyTo(client) - - _, err := client.Get(server.URL) //nolint:bodyclose,noctx // no response comes back - - require.ErrorIs(t, err, syscall.ECONNREFUSED) - assert.Equal(t, int32(1), calls.Load()) -} - -func TestRetryRecoversFromABrokenConnection(t *testing.T) { - for name, breakConnection := range map[string]func(*net.TCPConn){ - "closed": func(*net.TCPConn) {}, - "reset": func(conn *net.TCPConn) { _ = conn.SetLinger(0) }, - } { - t.Run(name, func(t *testing.T) { - var calls atomic.Int32 - server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, _ *gohttp.Request) { - if calls.Add(1) > 1 { - return - } - conn, _, err := gohttp.NewResponseController(w).Hijack() - if !assert.NoError(t, err) { - return - } - breakConnection(conn.(*net.TCPConn)) //nolint:forcetypeassert // httptest serves plain TCP - _ = conn.Close() - })) - defer server.Close() - client := &gohttp.Client{Transport: newTransport(time.Minute)} - RetryOptions{MaxRetries: 3, Backoff: ExponentialBackoff{}}.ApplyTo(client) - - resp, err := client.Get(server.URL) //nolint:noctx // the test server answers at once - - require.NoError(t, err) - _ = resp.Body.Close() - assert.Equal(t, gohttp.StatusOK, resp.StatusCode) - assert.Equal(t, int32(2), calls.Load()) - }) - } -} diff --git a/internal/http/stall.go b/internal/http/stall.go deleted file mode 100644 index d0108034..00000000 --- a/internal/http/stall.go +++ /dev/null @@ -1,52 +0,0 @@ -package http - -import ( - "context" - "fmt" - "io" - gohttp "net/http" - "time" -) - -// stallGuard ends a request whose response body has sent nothing for Timeout. It bounds the -// silence rather than the whole body, so a long answer that is still arriving is read to the end. -type stallGuard struct { - Next gohttp.RoundTripper - Timeout time.Duration -} - -func (g stallGuard) RoundTrip(req *gohttp.Request) (*gohttp.Response, error) { - ctx, cancel := context.WithCancelCause(req.Context()) - resp, err := g.Next.RoundTrip(req.WithContext(ctx)) - if err != nil { - cancel(nil) - return resp, err - } - stalled := fmt.Errorf("response body stalled: nothing received for %s", g.Timeout) - body := &stallGuardedBody{ReadCloser: resp.Body, timeout: g.Timeout, cancel: cancel} - body.timer = time.AfterFunc(g.Timeout, func() { cancel(stalled) }) - resp.Body = body - return resp, nil -} - -type stallGuardedBody struct { - io.ReadCloser - - timeout time.Duration - timer *time.Timer - cancel context.CancelCauseFunc -} - -func (b *stallGuardedBody) Read(p []byte) (int, error) { - n, err := b.ReadCloser.Read(p) - b.timer.Reset(b.timeout) - return n, err -} - -func (b *stallGuardedBody) Close() error { - b.timer.Stop() - // Closing the body before cancelling hands a fully read connection back to the pool. - err := b.ReadCloser.Close() - b.cancel(nil) - return err -} diff --git a/internal/http/stall_test.go b/internal/http/stall_test.go deleted file mode 100644 index dd379d87..00000000 --- a/internal/http/stall_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package http - -import ( - "bufio" - "context" - "fmt" - "io" - "net" - gohttp "net/http" - "testing" - "testing/synctest" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestStallGuardEndsABodyThatStopsArriving(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - client := newPipeClient(t, time.Minute, 100, func(w io.Writer) { - _, _ = io.WriteString(w, "partial") - }) - - resp, err := client.Get("http://meshstack.invalid") //nolint:noctx // the stall guard is what ends it - require.NoError(t, err) - defer func() { - _ = resp.Body.Close() - }() - start := time.Now() - body, err := io.ReadAll(resp.Body) - - require.ErrorContains(t, err, "response body stalled: nothing received for 1m0s") - assert.Equal(t, "partial", string(body)) - assert.Equal(t, time.Minute, time.Since(start), "the body was ended as soon as it had been silent for the timeout") - }) -} - -func TestStallGuardReadsABodyThatKeepsArriving(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - client := newPipeClient(t, time.Minute, 5, func(w io.Writer) { - for range 5 { - synctest.Sleep(40 * time.Second) - _, _ = io.WriteString(w, ".") - } - }) - - resp, err := client.Get("http://meshstack.invalid") //nolint:noctx // the server answers in full - require.NoError(t, err) - defer func() { - _ = resp.Body.Close() - }() - start := time.Now() - body, err := io.ReadAll(resp.Body) - - require.NoError(t, err) - assert.Equal(t, ".....", string(body)) - assert.Greater(t, time.Since(start), time.Minute, "the body took longer than the timeout, but was never silent for as long") - }) -} - -// newPipeClient serves its one connection in memory. A socket would keep the bubble's clock from -// advancing, since the clock only moves while every goroutine waits on something inside the bubble. -func newPipeClient(t *testing.T, timeout time.Duration, contentLength int, writeBody func(io.Writer)) *gohttp.Client { - t.Helper() - clientConn, serverConn := net.Pipe() - go func() { - defer func() { - _ = serverConn.Close() - }() - if _, err := gohttp.ReadRequest(bufio.NewReader(serverConn)); !assert.NoError(t, err) { - return - } - _, _ = fmt.Fprintf(serverConn, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", contentLength) - writeBody(serverConn) - _, _ = io.Copy(io.Discard, serverConn) - }() - transport := &gohttp.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) { - return clientConn, nil - }} - return &gohttp.Client{Transport: stallGuard{Next: transport, Timeout: timeout}} -} diff --git a/internal/json/marshal.go b/internal/json/marshal.go index 762bb4fe..e8dff6b5 100644 --- a/internal/json/marshal.go +++ b/internal/json/marshal.go @@ -6,24 +6,24 @@ import ( "encoding/json/v2" "errors" "fmt" + "io" "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), -) +type MarshalerTo = json.MarshalerTo + +func Marshal(in any, opts ...Options) ([]byte, error) { + return json.Marshal(in, opts...) +} + +func MarshalWrite(out io.Writer, in any, opts ...Options) error { + return json.MarshalWrite(out, in, opts...) +} -func Marshal(payload any) ([]byte, error) { - return json.Marshal(payload, wireCompatibility) +func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error { + return json.MarshalEncode(out, in, opts...) } type MarshalOption func(opts *marshalOptions) @@ -67,6 +67,5 @@ func MarshalTo(ctx context.Context, file string, payload any, options ...Marshal slog.DebugContext(ctx, "Marshaled json to "+file) } }() - encoder := jsontext.NewEncoder(out, jsontext.WithIndent(" ")) - return json.MarshalEncode(encoder, payload, wireCompatibility) + return json.MarshalWrite(out, payload, json.Deterministic(true), jsontext.WithIndent(" ")) } diff --git a/internal/json/options.go b/internal/json/options.go new file mode 100644 index 00000000..685aee51 --- /dev/null +++ b/internal/json/options.go @@ -0,0 +1,40 @@ +package json + +import ( + "encoding/json/jsontext" + "encoding/json/v2" +) + +type ( + Options = json.Options + Unmarshalers = json.Unmarshalers +) + +func JoinOptions(opts ...Options) Options { + return json.JoinOptions(opts...) +} + +func Deterministic(v bool) Options { + return json.Deterministic(v) +} + +func FormatNilSliceAsNull(v bool) Options { + return json.FormatNilSliceAsNull(v) +} + +func FormatNilMapAsNull(v bool) Options { + return json.FormatNilMapAsNull(v) +} + +func WithUnmarshalers(unmarshalers ...*Unmarshalers) Options { + joined := json.JoinUnmarshalers(unmarshalers...) + if joined == nil { + // json/v2 dereferences a nil *Unmarshalers when it decodes into an any. + return nil + } + return json.WithUnmarshalers(joined) +} + +func UnmarshalFromFunc[T any](fn func(*jsontext.Decoder, T) error) *Unmarshalers { + return json.UnmarshalFromFunc(fn) +} diff --git a/internal/json/unmarshal.go b/internal/json/unmarshal.go index 34f241b8..45ae0735 100644 --- a/internal/json/unmarshal.go +++ b/internal/json/unmarshal.go @@ -6,15 +6,26 @@ import ( "encoding/json/v2" "errors" "fmt" + "io" "log/slog" "os" ) -func Unmarshal(in []byte, out any) error { - return json.Unmarshal(in, out) +type UnmarshalerFrom = json.UnmarshalerFrom + +func Unmarshal(in []byte, out any, opts ...Options) error { + return json.Unmarshal(in, out, opts...) +} + +func UnmarshalRead(in io.Reader, out any, opts ...Options) error { + return json.UnmarshalRead(in, out, opts...) +} + +func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error { + return json.UnmarshalDecode(in, out, opts...) } -func UnmarshalFrom(ctx context.Context, file string, out any, unmarshalers ...*json.Unmarshalers) (err error) { +func UnmarshalFrom(ctx context.Context, file string, out any, opts ...Options) (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) @@ -29,15 +40,12 @@ func UnmarshalFrom(ctx context.Context, file string, out any, unmarshalers ...*j slog.DebugContext(ctx, "Unmarshaled json from "+file) } }() - err = json.UnmarshalDecode(jsontext.NewDecoder(f), &out, - json.WithUnmarshalers(json.JoinUnmarshalers(unmarshalers...)), - ) - return + return UnmarshalRead(f, out, opts...) } -func ModifyAfterUnmarshal[T any](modifier func(target *T)) *json.Unmarshalers { +func ModifyAfterUnmarshal[T any](modifier func(target *T)) Options { var decoding bool - return json.UnmarshalFromFunc(func(decoder *jsontext.Decoder, t *T) error { + return WithUnmarshalers(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". @@ -45,10 +53,10 @@ func ModifyAfterUnmarshal[T any](modifier func(target *T)) *json.Unmarshalers { } decoding = true defer func() { decoding = false }() - if err := json.UnmarshalDecode(decoder, t); err != nil { + if err := UnmarshalDecode(decoder, t); err != nil { return err } modifier(t) return nil - }) + })) } diff --git a/internal/lock/lock.go b/internal/lock/lock.go index c0eea362..fbeb9bed 100644 --- a/internal/lock/lock.go +++ b/internal/lock/lock.go @@ -13,6 +13,9 @@ import ( const retryDelay = 25 * time.Millisecond func New(path string) Locker { + if path == "" { + return Locker{m: &sync.RWMutex{}} + } return Locker{m: &sync.RWMutex{}, pathLock: path + ".lock"} } @@ -55,6 +58,9 @@ func (l Locker) lock(ctx context.Context, read bool) (unlock func() error, err e if err := inMemory.SpinLock(ctx); err != nil { return nil, err } + if l.pathLock == "" { + return sync.OnceValue(inMemory.Unlock), nil + } file := l.fileLocker(read) if err := file.SpinLock(ctx); err != nil { return nil, errors.Join(err, inMemory.Unlock()) diff --git a/internal/meshstack/settings.go b/internal/meshstack/settings.go index 61dbd1dc..a0d55ae7 100644 --- a/internal/meshstack/settings.go +++ b/internal/meshstack/settings.go @@ -16,7 +16,13 @@ var EndpointSetting = setting.Setting[xurl.URL]{ return fmt.Sprintf("The URL of the meshStack API, such as `https://api.example.meshcloud.io`, "+ "also read from `%s`, else taken from the current profile.", envKey) }, - Parse: setting.ParseTextUnmarshaler[xurl.URL], + Parse: func(s string) (xurl.URL, error) { + endpoint, err := setting.ParseTextUnmarshaler[xurl.URL](s) + if err == nil && (endpoint.Path != "" || endpoint.RawQuery != "" || endpoint.Fragment != "" || endpoint.User != nil) { + err = fmt.Errorf("an endpoint is a scheme and a host, such as https://api.example.meshcloud.io, and '%s' has more than that", endpoint.Redacted()) + } + return endpoint, err + }, } // SkipVersionCheckSetting skips both the minimum backend version check and the check for a newer diff --git a/internal/meshstack/workspaces.go b/internal/meshstack/workspaces.go index b07a2c0d..2115283d 100644 --- a/internal/meshstack/workspaces.go +++ b/internal/meshstack/workspaces.go @@ -86,3 +86,13 @@ func (w MeshWorkspace) String() string { func (w MeshWorkspace) Label() string { return w.String() } + +func ListWorkspaces(ctx context.Context, workspaces client.MeshWorkspaceClient) (listed []client.MeshWorkspace, err error) { + for workspace, err := range workspaces.List(ctx, client.MeshWorkspaceFilter{}) { + if err != nil { + return listed, err + } + listed = append(listed, workspace) + } + return listed, nil +} diff --git a/internal/oidc/client.go b/internal/oidc/client.go index f5482ac6..460f35b2 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -1,15 +1,17 @@ package oidc import ( + "bytes" "context" "errors" "fmt" + "io" "log/slog" "net/url" "time" + "github.com/meshcloud/meshstack-cli/client/http" "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" @@ -32,8 +34,8 @@ type ClientConfig struct { } 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")) + clientConfig, err := http.UnmarshalJsonBody[ClientConfig](httpClient.DoRequest(ctx, http.MethodGet, + issuer.JoinPath(".well-known", "openid-configuration"))) if err != nil { return Client{}, err } @@ -92,12 +94,11 @@ func (c Client) RevokeRefreshToken(ctx context.Context, refreshToken string) err if c.RevocationEndpoint == nil { return fmt.Errorf("%s advertises no revocation_endpoint", c.Issuer) } - _, err := c.doPost[any](ctx, c.RevocationEndpoint.URL, map[string]any{ + return http.IgnoreResponse(c.post(ctx, c.RevocationEndpoint.URL, map[string]any{ "client_id": c.Id, "token": refreshToken, "token_type_hint": "refresh_token", - }) - return err + })) } func (c Client) requestToken(ctx context.Context, payload map[string]any) (Token, error) { @@ -109,16 +110,20 @@ func (c Client) requestToken(ctx context.Context, payload map[string]any) (Token return token, 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)) +func (c Client) doPost[R any](ctx context.Context, endpoint *url.URL, payload map[string]any) (R, error) { + return http.UnmarshalJsonBody[R](c.post(ctx, endpoint, payload)) +} + +func (c Client) post(ctx context.Context, endpoint *url.URL, payload map[string]any) (io.ReadCloser, error) { + body, err := c.DoRequest(ctx, http.MethodPost, endpoint, http.WithFormRequest(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) + if unmarshalErr := json.UnmarshalRead(bytes.NewReader(httpErr.ResponseBody), &protocolErr); unmarshalErr != nil { + return body, errors.Join(httpErr, unmarshalErr) } - return result, errors.Join(httpErr, protocolErr) + return body, errors.Join(httpErr, protocolErr) } - return + return body, err } type protocolError struct { diff --git a/internal/oidc/client_test.go b/internal/oidc/client_test.go index 53981140..de6cb87f 100644 --- a/internal/oidc/client_test.go +++ b/internal/oidc/client_test.go @@ -11,8 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/client/types/xurl" - "github.com/meshcloud/meshstack-cli/internal/http" "github.com/meshcloud/meshstack-cli/internal/oidc" "github.com/meshcloud/meshstack-cli/internal/oidc/scope" "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" diff --git a/internal/oidc/jwt/jwt.go b/internal/oidc/jwt/jwt.go index 638b98b3..68d33c8f 100644 --- a/internal/oidc/jwt/jwt.go +++ b/internal/oidc/jwt/jwt.go @@ -5,8 +5,9 @@ import ( "encoding" "encoding/base64" "encoding/json/jsontext" - "encoding/json/v2" "fmt" + + "github.com/meshcloud/meshstack-cli/internal/json" ) var ( @@ -33,7 +34,7 @@ func (jwt JWT) MarshalText() (text []byte, err error) { //goland:noinspection GoMixedReceiverTypes func (jwt *JWT) UnmarshalText(text []byte) error { - jwt.encoded = text + jwt.encoded = bytes.Clone(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)) diff --git a/internal/oidc/jwt/jwt_test.go b/internal/oidc/jwt/jwt_test.go index 3cbf16d8..f0000c13 100644 --- a/internal/oidc/jwt/jwt_test.go +++ b/internal/oidc/jwt/jwt_test.go @@ -2,13 +2,13 @@ package jwt import ( _ "embed" - "encoding/json/v2" "testing" "testing/synctest" "time" "github.com/stretchr/testify/assert" + "github.com/meshcloud/meshstack-cli/internal/json" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/oidc/scope" "github.com/meshcloud/meshstack-cli/internal/testutil/jsontest" diff --git a/internal/profile/lock_test.go b/internal/profile/lock_test.go index 2064a710..75ab43bc 100644 --- a/internal/profile/lock_test.go +++ b/internal/profile/lock_test.go @@ -64,15 +64,6 @@ func TestTheExclusiveLockOfTheProfiles(t *testing.T) { assert.False(t, lockIsFree(t, dir), "the other command still holds the lock") }) - t.Run("is released where ResolveProfile fails", func(t *testing.T) { - t.Setenv(NameSetting.EnvKey(), "missing") - - _, _, err := ResolveProfile(t.Context(), ResolveProfileOptions{StoredOnly: true, ExclusiveLock: true}) - - require.ErrorIs(t, err, ErrNoStoredProfile) - assert.True(t, lockIsFree(t, dir)) - }) - t.Run("is released where the profiles are invalid", func(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "profiles.json"), []byte(`{"version":0}`), 0o600)) diff --git a/internal/profile/name.go b/internal/profile/name.go index 65df9457..771ff1e9 100644 --- a/internal/profile/name.go +++ b/internal/profile/name.go @@ -20,11 +20,11 @@ var NameSetting = setting.Setting[Name]{ "A profile is a named bundle of endpoint and credential, written by "+ "`meshstack login` into the meshStack CLI's configuration directory. Flags and environment "+ "variables take precedence over the values in a profile.\n\n"+ - "With no name given, the profile is the one whose endpoint matches the endpoint in use, else the one "+ - "the last `meshstack login` selected, else `default`.", envKey) + "With no name given, the profile is the one the last `meshstack login` selected, if it has the "+ + "endpoint in use, else the only profile with that endpoint. Without such a profile, the endpoint and "+ + "the credential come from the other settings alone.", envKey) }, - Default: setting.StaticDefault("default"), - Parse: setting.ParseTextUnmarshaler[Name], + Parse: setting.ParseTextUnmarshaler[Name], } var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 47c34a57..d351bc3a 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -25,13 +25,25 @@ func (p Profile) String() string { return string(p.Name) } -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) { - return p.Endpoint.String(), nil - }, - }} +func (p Profile) LoginHint() string { + if p.Name == "" { + return "meshstack login --endpoint " + p.Endpoint.String() + } + return "meshstack login --profile " + string(p.Name) +} + +func (ps *Profiles) LoginHint(name Name, endpoint xurl.URL) string { + if stored, ok := ps.Profiles[name]; ok { + return stored.LoginHint() + } + hint := "meshstack login" + if endpoint.URL != nil { + hint += " --endpoint " + endpoint.String() + } + if name != "" { + hint += " --profile " + string(name) + } + return hint } func (p Profile) WorkspaceSource() setting.FallbackSource { diff --git a/internal/profile/profiles.go b/internal/profile/profiles.go index bf49e52c..0d2a9cab 100644 --- a/internal/profile/profiles.go +++ b/internal/profile/profiles.go @@ -5,14 +5,10 @@ import ( "errors" "fmt" "io/fs" - "log/slog" "slices" - "strings" "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 ( @@ -37,8 +33,6 @@ type LoadProfilesOptions struct { ExclusiveLock bool } -// LoadProfiles only reads, and resolves nothing but the configuration directory: ResolveProfile -// creates the first profile, as it creates any other. func LoadProfiles(ctx context.Context, opts LoadProfilesOptions) (profiles Profiles, err error) { profiles.configDir, err = opts.ResolveSetting(ctx, config.DirectorySetting) if err != nil { @@ -173,31 +167,6 @@ func (ps *Profiles) add(p Profile) *Profile { return &p } -func (ps *Profiles) addCurrent(ctx context.Context, opts ResolveProfileOptions, name Name) (*Profile, error) { - stored := ps.Selection().Profiles - added := ps.add(Profile{Name: name}) - ps.CurrentProfile = name - - endpoint, err := opts.ResolveSetting(ctx, meshstack.EndpointSetting) - noEndpoint := errors.Is(err, setting.ErrNoSourceProvidedValue) - switch { - case opts.EndpointOptional && noEndpoint: - return added, nil - case noEndpoint && len(stored) > 0: - storedNames := make([]string, len(stored)) - for i, p := range stored { - storedNames[i] = string(p.Name) - } - return nil, fmt.Errorf("there is no profile '%s' among %s, and creating it needs an endpoint: %w", name, strings.Join(storedNames, ", "), err) - case err != nil: - return nil, err - } - added.Endpoint = endpoint - - slog.DebugContext(ctx, fmt.Sprintf("Profile %s resolved to %+v", name, *added)) - return added, 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)) diff --git a/internal/profile/resolve.go b/internal/profile/resolve.go index 92d7a208..bec853ec 100644 --- a/internal/profile/resolve.go +++ b/internal/profile/resolve.go @@ -4,136 +4,65 @@ import ( "context" "errors" "fmt" - "log/slog" - "sync" "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/setting" ) -type ( - SettingSources = setting.Sources - ResolveProfileOptions struct { - SettingSources +type SettingSources = setting.Sources - // EndpointOptional lets a profile that ResolveProfile creates go without an endpoint, for a - // command that only shows it. - EndpointOptional bool - // StoredOnly makes ResolveProfile return ErrNoStoredProfile rather than create a profile, for a - // command that only acts on a stored one. - StoredOnly bool - // ExclusiveLock is LoadProfilesOptions.ExclusiveLock for the profiles ResolveProfile - // returns, and holds nothing where it fails. - ExclusiveLock bool - } -) - -var ErrNoStoredProfile = errors.New("no stored profile") - -// ResolveProfile returns a *Profile that 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) { - var loaded Profiles - current, profiles, err := resolveProfile(ctx, opts, sync.OnceValues(func() (Profiles, error) { - var err error - loaded, err = LoadProfiles(ctx, LoadProfilesOptions{SettingSources: opts.SettingSources, ExclusiveLock: opts.ExclusiveLock}) - return loaded, err - })) +func ResolveProfile(ctx context.Context, sources SettingSources) (*Profile, Profiles, error) { + profiles, err := LoadProfiles(ctx, LoadProfilesOptions{SettingSources: sources}) if err != nil { - err = errors.Join(err, loaded.Unlock()) + return nil, profiles, err } - return current, profiles, err + resolved, err := profiles.Resolve(ctx, sources, false) + return resolved, profiles, err } -func resolveProfile(ctx context.Context, opts ResolveProfileOptions, loadProfiles func() (Profiles, error)) (*Profile, Profiles, error) { - // 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 - }, - }, Stored: true} - - // A front end source that selects the profile reads what to select from out of the context, - // as the one for the workspace does. Being a fallback source of the front end, it ranks above - // both of the sources here, so it has to take a unique endpoint match without asking. - ctxWithSelection := SetSelectionInContext(ctx, sync.OnceValues(func() (Selection, error) { - profiles, err := loadProfiles() - if err != nil { - return Selection{}, err - } - return profiles.SelectionFor(ctx, opts) - })) - - name, err := opts.ResolveSetting(ctxWithSelection, NameSetting, - endpointMatchingSource, - currentProfileSource, - ) +func (ps *Profiles) Resolve(ctx context.Context, sources SettingSources, forLogin bool) (*Profile, error) { + selection, err := ps.SelectionFor(ctx, sources) if err != nil { - return nil, Profiles{}, err + return nil, err } - - profiles, err := loadProfiles() - if err != nil { - return nil, profiles, err - } - if currentProfile, ok := profiles.Profiles[name]; ok { - return currentProfile, profiles, nil - } - if opts.StoredOnly { - return nil, profiles, fmt.Errorf("%w is named '%s'", ErrNoStoredProfile, name) + nameSetting := NameSetting + if forLogin { + nameSetting.Default = setting.StaticDefault("default") } - if len(profiles.Profiles) == 0 { - slog.InfoContext(ctx, fmt.Sprintf("Initializing first-time use profile '%s'", name)) - } else { - slog.InfoContext(ctx, fmt.Sprintf("Creating profile '%s'", name)) - } - created, err := profiles.addCurrent(ctx, opts, name) - return created, profiles, err -} - -func (ps *Profiles) findProfileNameByMatchingEndpoint(ctx context.Context, opts ResolveProfileOptions) (string, error) { - endpoint, found, err := opts.resolveEndpointIfAny(ctx) - if err != nil || !found { - return "", err + name, err := sources.ResolveSetting(SetSelectionInContext(ctx, selection), nameSetting, selection.nameSource()) + if err != nil && !errors.Is(err, setting.ErrNoSourceProvidedValue) { + return nil, err } - if matchingProfiles := ps.MatchingEndpoint(endpoint); 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 + stored, isStored := ps.Profiles[name] + switch { + case isStored && selection.Endpoint != nil && !selection.Endpoint.Equal(stored.Endpoint): + return nil, fmt.Errorf("profile '%s' has the endpoint %s, which does not match the endpoint %s of this command; "+ + "use --profile to choose a profile of that endpoint, or run '%s'", name, stored.Endpoint, selection.Endpoint, ps.LoginHint("", *selection.Endpoint)) + case isStored: + return stored, nil + case name != "" && !forLogin: + return nil, fmt.Errorf("there is no profile '%s'; run '%s' to create it", name, ps.LoginHint(name, xurl.URL{})) + case selection.Endpoint == nil: + _, noEndpoint := sources.ResolveSetting(ctx, meshstack.EndpointSetting) + if forLogin { + return nil, fmt.Errorf("the new profile '%s' needs an endpoint: %w", name, noEndpoint) + } + return nil, errors.Join(noEndpoint, fmt.Errorf("or run '%s'", ps.LoginHint("", xurl.URL{}))) + case forLogin: + return ps.add(Profile{Name: name, Endpoint: *selection.Endpoint}), nil } - return "", nil + return &Profile{Endpoint: *selection.Endpoint, ConfigDir: ps.configDir}, nil } -func (ps *Profiles) SelectionFor(ctx context.Context, opts ResolveProfileOptions) (Selection, error) { +func (ps *Profiles) SelectionFor(ctx context.Context, sources SettingSources) (Selection, error) { selection := ps.Selection() - endpoint, found, err := opts.resolveEndpointIfAny(ctx) - if found { + endpoint, err := sources.ResolveSetting(ctx, meshstack.EndpointSetting) + switch { + case err == nil: selection.Endpoint = &endpoint + case !errors.Is(err, setting.ErrNoSourceProvidedValue): + return selection, err } - return selection, err -} - -func (opts ResolveProfileOptions) resolveEndpointIfAny(ctx context.Context) (xurl.URL, bool, 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 xurl.URL{}, false, nil - } - return endpoint, err == nil, err + return selection, nil } diff --git a/internal/profile/resolve_test.go b/internal/profile/resolve_test.go index 7c6361fa..42f9b940 100644 --- a/internal/profile/resolve_test.go +++ b/internal/profile/resolve_test.go @@ -5,13 +5,11 @@ import ( "os" "path/filepath" "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/setting" @@ -20,82 +18,45 @@ import ( var testEndpoint = xurl.MustParsef("https://localhost:1337") -func TestResolveProfileOnAFreshConfigDirectory(t *testing.T) { +func TestALoginCreatesItsProfile(t *testing.T) { givenNoMeshstackEnvironment(t) configDir := filepath.Join(t.TempDir(), "missing") t.Setenv(config.DirectorySetting.EnvKey(), configDir) - t.Setenv(meshstack.EndpointSetting.EnvKey(), testEndpoint.String()) - defaultProfile := &Profile{Name: "default", Endpoint: testEndpoint} - dev := &Profile{Name: "dev", Endpoint: testEndpoint} - - t.Run("creates the default profile, though the directory is missing", func(t *testing.T) { - current, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) - - require.NoError(t, err) - assert.EqualExportedValues(t, defaultProfile, withoutConfigDir(current)) - assertProfiles(t, profiles, defaultProfile) - require.NoError(t, profiles.Store(t.Context())) - }) - - t.Run("creates a missing profile the environment names, and selects it", func(t *testing.T) { - t.Setenv(NameSetting.EnvKey(), "dev") - created, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) - - require.NoError(t, err) - assert.EqualExportedValues(t, dev, withoutConfigDir(created)) - assertProfiles(t, profiles, dev, defaultProfile) - - created.DefaultWorkspace = "my-workspace-ab12c" - require.NoError(t, profiles.Store(t.Context())) - }) - - t.Run("what a login stores through the returned profile survives the next run", func(t *testing.T) { - reloaded, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) - + resolveForLogin := func(t *testing.T) (*Profile, Profiles, error) { + t.Helper() + profiles, err := LoadProfiles(t.Context(), LoadProfilesOptions{}) require.NoError(t, err) - dev.DefaultWorkspace = "my-workspace-ab12c" - assert.EqualExportedValues(t, dev, withoutConfigDir(reloaded)) - assertProfiles(t, profiles, dev, defaultProfile) - }) - - t.Run("a profile name from a front end source wins over the environment", func(t *testing.T) { - t.Setenv(NameSetting.EnvKey(), "from-the-environment") + resolved, err := profiles.Resolve(t.Context(), nil, true) + return resolved, profiles, err + } - current, _, err := ResolveProfile(t.Context(), ResolveProfileOptions{SettingSources: profileNameFromFrontend("from-the-front-end")}) + t.Run("a new profile needs an endpoint", func(t *testing.T) { + _, _, err := resolveForLogin(t) - require.NoError(t, err) - assert.Equal(t, Name("from-the-front-end"), current.Name) + require.ErrorIs(t, err, setting.ErrNoSourceProvidedValue) + require.ErrorContains(t, err, "the new profile 'default' needs an endpoint") }) - t.Run("a stored profile is found without its name or endpoint in the environment", func(t *testing.T) { - t.Setenv(NameSetting.EnvKey(), "dev-local") + t.Run("without a name it is the profile default, though the directory is missing", func(t *testing.T) { // 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: testEndpoint} - created, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) - require.NoError(t, err) - assert.EqualExportedValues(t, devLocal, withoutConfigDir(created)) - require.NoError(t, profiles.Store(t.Context())) - storeApiKey(t, created) - - // 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, _, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) + created, profiles, err := resolveForLogin(t) require.NoError(t, err) - assert.EqualExportedValues(t, devLocal, withoutConfigDir(reloaded)) - assertApiKey(t, reloaded, "08be9109-45bf-42ba-a965-2097b9d0d181", "super-test-secret") + assert.EqualExportedValues(t, &Profile{Name: "default", Endpoint: testEndpoint}, withoutConfigDir(created)) + assert.Same(t, created, profiles.Profiles["default"]) + assert.Equal(t, config.Directory(configDir), created.ConfigDir) + require.NoError(t, profiles.SetCurrent(t.Context(), created.Name)) }) - t.Run("Add puts a profile into the config directory of the profiles", func(t *testing.T) { - profiles, err := LoadProfiles(t.Context(), LoadProfilesOptions{}) - require.NoError(t, err) - - added := profiles.add(Profile{Name: "dev", Endpoint: testEndpoint, ConfigDir: "elsewhere"}) + t.Run("a name in the environment creates a second profile next to the stored one", func(t *testing.T) { + t.Setenv(meshstack.EndpointSetting.EnvKey(), testEndpoint.String()) + t.Setenv(NameSetting.EnvKey(), "dev") + created, profiles, err := resolveForLogin(t) - assert.Equal(t, config.Directory(configDir), added.ConfigDir) - assert.Same(t, added, profiles.Profiles["dev"]) + require.NoError(t, err) + assert.Equal(t, Name("dev"), created.Name) + assert.Len(t, profiles.Profiles, 2) }) t.Run("a null profile is refused", func(t *testing.T) { @@ -122,45 +83,73 @@ func TestResolveProfileOfAStoredConfigDirectory(t *testing.T) { resolveWithSelection := func(t *testing.T) *Profile { t.Helper() asked = nil - selected, _, err := ResolveProfile(t.Context(), ResolveProfileOptions{SettingSources: selectionAnswering("default")}) + selected, _, err := ResolveProfile(t.Context(), selectionAnswering("default")) require.NoError(t, err) return selected } - t.Run("reads the current profile and its credentials", func(t *testing.T) { - currentProfile, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) + t.Run("without a name or an endpoint it is the current profile, with its credentials", func(t *testing.T) { + currentProfile, profiles, err := ResolveProfile(t.Context(), nil) require.NoError(t, err) - devLocal := &Profile{Name: "dev-local", Endpoint: xurl.MustParsef("https://localhost:1337"), Credential: "apiKey"} + devLocal := &Profile{Name: "dev-local", Endpoint: testEndpoint, Credential: "apiKey"} assert.EqualExportedValues(t, devLocal, withoutConfigDir(currentProfile)) - assertProfiles(t, profiles, - devLocal, - &Profile{Name: "default", Endpoint: xurl.MustParsef("https://api.dev.meshcloud.io/")}, - &Profile{Name: "empty"}, - ) + assert.Len(t, profiles.Profiles, 3) assertApiKey(t, currentProfile, "08be9109-45bf-42ba-a965-2097b9d0d181", "super-test-secret") + }) - // Storing what was just loaded leaves the files as they are, so testdata stays the fixture. - require.NoError(t, profiles.Store(t.Context())) + t.Run("an unknown name is an error", func(t *testing.T) { + t.Setenv(NameSetting.EnvKey(), "typo") + + _, _, err := ResolveProfile(t.Context(), nil) + + require.ErrorContains(t, err, "there is no profile 'typo'; run 'meshstack login --profile typo' to create it") }) - t.Run("StoredOnly creates no profile", func(t *testing.T) { - t.Setenv(NameSetting.EnvKey(), "missing") + t.Run("a name from a front end source wins over the environment", func(t *testing.T) { + t.Setenv(NameSetting.EnvKey(), "dev-local") - _, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{StoredOnly: true}) + current, _, err := ResolveProfile(t.Context(), profileNameFromFrontend("default")) - require.ErrorIs(t, err, ErrNoStoredProfile) - require.ErrorContains(t, err, "'missing'") - assert.NotContains(t, profiles.Profiles, Name("missing")) + require.NoError(t, err) + assert.Equal(t, Name("default"), current.Name) }) - t.Run("a missing profile without an endpoint names the stored ones", func(t *testing.T) { - t.Setenv(NameSetting.EnvKey(), "typo") + t.Run("a named profile must have the endpoint given", func(t *testing.T) { + t.Setenv(NameSetting.EnvKey(), "dev-local") + t.Setenv(meshstack.EndpointSetting.EnvKey(), "https://api.dev.meshcloud.io") - _, _, err := ResolveProfile(t.Context(), ResolveProfileOptions{}) + _, _, err := ResolveProfile(t.Context(), nil) - require.ErrorIs(t, err, setting.ErrNoSourceProvidedValue) - require.ErrorContains(t, err, "there is no profile 'typo' among default, dev-local, empty") + require.ErrorContains(t, err, "profile 'dev-local' has the endpoint https://localhost:1337, which does not match the endpoint https://api.dev.meshcloud.io") + }) + + t.Run("an endpoint of another profile than the current one takes the only profile that has it", func(t *testing.T) { + t.Setenv(meshstack.EndpointSetting.EnvKey(), "https://API.dev.meshcloud.io/") + + resolved, _, err := ResolveProfile(t.Context(), nil) + + require.NoError(t, err) + assert.Equal(t, Name("default"), resolved.Name) + }) + + t.Run("an endpoint of no stored profile gives a nameless profile, which is not stored", func(t *testing.T) { + t.Setenv(meshstack.EndpointSetting.EnvKey(), "https://localhost:4711") + + nameless, profiles, err := ResolveProfile(t.Context(), nil) + + require.NoError(t, err) + assert.EqualExportedValues(t, &Profile{Endpoint: xurl.MustParsef("https://localhost:4711")}, withoutConfigDir(nameless)) + assert.Len(t, profiles.Profiles, 3) + assert.Equal(t, "meshstack login --endpoint https://localhost:4711", nameless.LoginHint()) + }) + + t.Run("an endpoint with a path is refused", func(t *testing.T) { + t.Setenv(meshstack.EndpointSetting.EnvKey(), testEndpoint.String()+"/api") + + _, _, err := ResolveProfile(t.Context(), nil) + + require.ErrorContains(t, err, "an endpoint is a scheme and a host") }) t.Run("without an endpoint the selection offers every profile, ordered by name", func(t *testing.T) { @@ -179,36 +168,12 @@ func TestResolveProfileOfAStoredConfigDirectory(t *testing.T) { assert.Len(t, asked[0].Profiles, 3) }) - t.Run("an endpoint matching no profile leaves no candidate", func(t *testing.T) { - t.Setenv(meshstack.EndpointSetting.EnvKey(), testEndpoint.String()+"/other") - resolveWithSelection(t) - require.Len(t, asked, 1) - assert.Empty(t, asked[0].Candidates()) - }) - t.Run("a profile named in the environment needs no selection", func(t *testing.T) { t.Setenv(NameSetting.EnvKey(), "empty") assert.Equal(t, Name("empty"), resolveWithSelection(t).Name) assert.Empty(t, asked) }) - // This step fails where LoadProfiles resolves the profile name itself: it then reaches the - // selection before the context carries it. - t.Run("a first-time use offers an empty selection", func(t *testing.T) { - t.Setenv(config.DirectorySetting.EnvKey(), t.TempDir()) - t.Setenv(meshstack.EndpointSetting.EnvKey(), testEndpoint.String()) - asked = nil - - created, profiles, err := ResolveProfile(t.Context(), ResolveProfileOptions{SettingSources: selectionAnswering("")}) - - require.NoError(t, err) - require.Len(t, asked, 1) - assert.Empty(t, asked[0].Profiles) - expected := &Profile{Name: "default", Endpoint: testEndpoint} - assert.EqualExportedValues(t, expected, withoutConfigDir(created)) - assertProfiles(t, profiles, expected) - }) - t.Run("the selection is not in the context outside the profile name resolution", func(t *testing.T) { _, err := SelectionFromContext(t.Context()) @@ -241,48 +206,15 @@ func profileNameFromFrontend(name Name) SettingSources { )}} } -func storeApiKey(t *testing.T, p *Profile) { - t.Helper() - creds, err := p.Credentials(t.Context()) - require.NoError(t, err) - creds.Set(&credential.ApiKey{ - Endpoint: p.Endpoint, - ClientId: uuid.MustParse("08be9109-45bf-42ba-a965-2097b9d0d181"), - ClientSecret: "super-test-secret", - }) - require.NoError(t, creds.Store(t.Context())) -} - func assertApiKey(t *testing.T, p *Profile, clientId, clientSecret string) { 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, clientId, creds.ApiKey.ClientId) assert.Equal(t, clientSecret, creds.ApiKey.ClientSecret) } -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 = "" diff --git a/internal/profile/select.go b/internal/profile/select.go index cf5fac6f..b3c8396f 100644 --- a/internal/profile/select.go +++ b/internal/profile/select.go @@ -5,30 +5,26 @@ import ( "context" "fmt" "maps" - "net/url" "slices" - "strings" "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/setting" ) -type ( - selectionContextKey int - SelectionFunc func() (Selection, error) -) +type selectionContextKey struct{} // SelectionFromContext returns what a front end picks a profile from. It is only in the context // while NameSetting is being resolved, so a lookup for any other setting gets an error. func SelectionFromContext(ctx context.Context) (Selection, error) { - selection, found := ctx.Value(selectionContextKey(0)).(SelectionFunc) + selection, found := ctx.Value(selectionContextKey{}).(Selection) if !found { return Selection{}, fmt.Errorf("no profile selection available in this context; it is only provided while resolving %s", NameSetting.EnvKey()) } - return selection() + return selection, nil } -func SetSelectionInContext(ctx context.Context, selection SelectionFunc) context.Context { - return context.WithValue(ctx, selectionContextKey(0), selection) +func SetSelectionInContext(ctx context.Context, selection Selection) context.Context { + return context.WithValue(ctx, selectionContextKey{}, selection) } type Selection struct { @@ -46,9 +42,8 @@ func (ps *Profiles) Selection() Selection { return Selection{Profiles: sorted, Current: ps.CurrentProfile} } -// MatchingEndpoint are copies of the profiles for endpoint, ordered by name, so that a caller can -// read them while the profiles change. -func (ps *Profiles) MatchingEndpoint(endpoint xurl.URL) (matching []Profile) { +// ForEndpoint returns copies, so that a caller can read them while the profiles change. +func (ps *Profiles) ForEndpoint(endpoint xurl.URL) (matching []Profile) { selection := ps.Selection() selection.Endpoint = &endpoint for _, p := range selection.Candidates() { @@ -57,51 +52,6 @@ func (ps *Profiles) MatchingEndpoint(endpoint xurl.URL) (matching []Profile) { return matching } -// EndpointHolding returns the scheme and host of requestURL where no profile holds it: the endpoint -// a profile for it would need. -func (ps *Profiles) EndpointHolding(requestURL *url.URL) (endpoint xurl.URL, held bool) { - for _, p := range ps.Profiles { - if _, holds := p.Endpoint.PathTo(requestURL); holds && (endpoint.URL == nil || len(p.Endpoint.Path) > len(endpoint.Path)) { - endpoint = p.Endpoint - } - } - if endpoint.URL == nil { - return xurl.URL{URL: &url.URL{Scheme: requestURL.Scheme, Host: requestURL.Host}}, false - } - return endpoint, true -} - -func (ps *Profiles) Holding(requestURL *url.URL) (p Profile, current bool, err error) { - endpoint, held := ps.EndpointHolding(requestURL) - if !held { - return Profile{}, false, fmt.Errorf("no stored profile has the endpoint %s of this URL; run 'meshstack login --endpoint %s' to create one", - endpoint, endpoint) - } - return ps.ForEndpoint(endpoint, requestURL) -} - -// ForEndpoint takes the current one of the profiles sharing endpoint, and fails where none of them -// is current rather than guess, as they can hold different credentials. -func (ps *Profiles) ForEndpoint(endpoint xurl.URL, requestURL *url.URL) (p Profile, current bool, err error) { - matching := ps.MatchingEndpoint(endpoint) - isCurrent := func(p Profile) bool { return p.Name == ps.CurrentProfile } - switch { - case len(matching) == 0: - return Profile{}, false, fmt.Errorf("no stored profile has the endpoint %s; run 'meshstack login --endpoint %s' to create one", endpoint, endpoint) - case len(matching) == 1: - return matching[0], isCurrent(matching[0]), nil - } - if i := slices.IndexFunc(matching, isCurrent); i >= 0 { - return matching[i], true, nil - } - names := make([]string, 0, len(matching)) - for _, p := range matching { - names = append(names, "'"+string(p.Name)+"'") - } - return Profile{}, false, fmt.Errorf("profiles %s all have the endpoint %s of %s, and none of them is the current profile; use --profile or %s to choose one", - strings.Join(names, ", "), endpoint, requestURL.Redacted(), NameSetting.EnvKey()) -} - func (s Selection) Candidates() []*Profile { if s.Endpoint == nil { return s.Profiles @@ -111,6 +61,20 @@ func (s Selection) Candidates() []*Profile { }) } +func (s Selection) nameSource() setting.FallbackSource { + var name Name + description := "current profile" + if candidates := s.Candidates(); slices.ContainsFunc(candidates, func(p *Profile) bool { return p.Name == s.Current }) { + name = s.Current + } else if s.Endpoint != nil && len(candidates) == 1 { + name, description = candidates[0].Name, "only profile with the endpoint "+s.Endpoint.String() + } + return setting.FallbackSource{Stored: true, Source: setting.LookupSource{ + Description: description, + Func: func(context.Context) (string, error) { return string(name), nil }, + }} +} + func (p Profile) Label() string { if p.Endpoint.URL == nil { return fmt.Sprintf("%s (no endpoint)", p.Name) diff --git a/internal/profile/select_test.go b/internal/profile/select_test.go index 70a8bbff..0a3be6d8 100644 --- a/internal/profile/select_test.go +++ b/internal/profile/select_test.go @@ -1,82 +1,50 @@ package profile import ( - "net/url" "os" - "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/internal/config" + "github.com/meshcloud/meshstack-cli/internal/meshstack" ) -func TestProfilesHoldingARequestURL(t *testing.T) { +func TestProfilesSharingAnEndpoint(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,"currentProfile":"local","profiles":{ "local": {"endpoint":"https://localhost:1337"}, "local-2": {"endpoint":"https://localhost:1337"}, - "a": {"endpoint":"https://meshstack.example.com/a"}, - "a-b": {"endpoint":"https://meshstack.example.com/a/b/"}, "shared-1": {"endpoint":"https://shared.example.com"}, "shared-2": {"endpoint":"https://shared.example.com"} }}`), 0o600)) - profiles, err := LoadProfiles(t.Context(), LoadProfilesOptions{}) - require.NoError(t, err) - holding := func(t *testing.T, requestURL string) (Name, bool, error) { - t.Helper() - parsed, err := url.Parse(requestURL) - require.NoError(t, err) - p, current, err := profiles.Holding(parsed) - return p.Name, current, err - } - - t.Run("the scheme and host match in any letter case, and of the profiles sharing them the current one is picked", func(t *testing.T) { - name, current, err := holding(t, "https://LOCALHOST:1337/api/meshobjects") - - require.NoError(t, err) - assert.Equal(t, Name("local"), name) - assert.True(t, current) - }) - - t.Run("a URL on another port or scheme matches no profile, and the error says how to log in to it", func(t *testing.T) { - for _, requestURL := range []string{"https://localhost:1338/api", "http://localhost:1337/api"} { - _, _, err := holding(t, requestURL) - - endpoint := strings.TrimSuffix(requestURL, "/api") - require.ErrorContains(t, err, "no stored profile has the endpoint "+endpoint) - require.ErrorContains(t, err, "'meshstack login --endpoint "+endpoint+"'") - } - }) - - t.Run("an endpoint path matches whole segments only", func(t *testing.T) { - _, _, err := holding(t, "https://meshstack.example.com/ab/api") - require.ErrorContains(t, err, "no stored profile has the endpoint https://meshstack.example.com ") - }) + t.Run("the current one of them is taken", func(t *testing.T) { + t.Setenv(meshstack.EndpointSetting.EnvKey(), "https://localhost:1337") - t.Run("a profile other than the current one is picked, and is not current", func(t *testing.T) { - name, current, err := holding(t, "https://meshstack.example.com/a/api") + resolved, _, err := ResolveProfile(t.Context(), nil) require.NoError(t, err) - assert.Equal(t, Name("a"), name) - assert.False(t, current) + assert.Equal(t, Name("local"), resolved.Name) }) - t.Run("the profile with the longest endpoint that holds the URL is picked", func(t *testing.T) { - name, _, err := holding(t, "https://meshstack.example.com/a/b/api") + t.Run("none of them is taken where none is current", func(t *testing.T) { + t.Setenv(meshstack.EndpointSetting.EnvKey(), "https://shared.example.com") + + resolved, profiles, err := ResolveProfile(t.Context(), nil) require.NoError(t, err) - assert.Equal(t, Name("a-b"), name) + assert.Empty(t, resolved.Name) + assert.Equal(t, []Name{"shared-1", "shared-2"}, names(pointersTo(profiles.ForEndpoint(resolved.Endpoint)))) }) +} - t.Run("profiles sharing the endpoint, none of them current, need a name", func(t *testing.T) { - _, _, err := holding(t, "https://shared.example.com/api") - - require.ErrorContains(t, err, "'shared-1', 'shared-2'") - require.ErrorContains(t, err, NameSetting.EnvKey()) - }) +func pointersTo(profiles []Profile) (pointers []*Profile) { + for i := range profiles { + pointers = append(pointers, &profiles[i]) + } + return pointers } diff --git a/internal/testutil/fakemeshstack/fakemeshstack.go b/internal/testutil/fakemeshstack/fakemeshstack.go index 80f524f3..e7278e1c 100644 --- a/internal/testutil/fakemeshstack/fakemeshstack.go +++ b/internal/testutil/fakemeshstack/fakemeshstack.go @@ -12,7 +12,8 @@ package fakemeshstack import ( "bytes" "cmp" - "encoding/json/v2" + "crypto/sha256" + "fmt" "io" gohttp "net/http" "net/http/httptest" @@ -24,7 +25,8 @@ import ( "testing" "time" - "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/internal/json" ) // DefaultVersion is at least client.MinMeshStackVersion, or the CLI refuses the backend. @@ -38,9 +40,8 @@ const Token = "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9." //nolint:gosec // var UserAgent = http.UserAgent{GitHubRepo: "meshcloud/fakemeshstack", Version: "test"} const ( - ApiDocsPath = "/api/meshstack-openapi-docs.json" - MeshStackApiDocsPath = "/api/openapi" - defaultPageSize = 50 + ApiDocsPath = "/api/openapi" + defaultPageSize = 50 ) type Options struct { @@ -63,9 +64,7 @@ type Options struct { PageSize int TfStates map[TfState][]byte - // ApiDocs is served at ApiDocsPath and MeshStackApiDocsPath as last modified when New was called, - // so a conditional GET gets a 304. - ApiDocs []byte + ApiDocs []byte } type ApiKey struct { @@ -144,13 +143,7 @@ func New(options Options) *Server { } s.routes.HandleFunc("/api/terraform/state/workspace/{workspace}/buildingBlock/{uuid}", s.authorized(s.tfState)) if options.ApiDocs != nil { - modified := time.Now() - for _, path := range []string{ApiDocsPath, MeshStackApiDocsPath} { - s.routes.HandleFunc("GET "+path, func(w gohttp.ResponseWriter, r *gohttp.Request) { - w.Header().Set("Content-Type", "application/json") - gohttp.ServeContent(w, r, "", modified, bytes.NewReader(options.ApiDocs)) - }) - } + s.routes.HandleFunc("GET "+ApiDocsPath, ServeApiDocs(options.ApiDocs)) } if options.Issuer != "" { s.registerKeycloak() @@ -158,6 +151,15 @@ func New(options Options) *Server { return s } +func ServeApiDocs(document []byte) gohttp.HandlerFunc { + etag := fmt.Sprintf(`"%x"`, sha256.Sum256(document)) + return func(w gohttp.ResponseWriter, r *gohttp.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", etag) + gohttp.ServeContent(w, r, "", time.Time{}, bytes.NewReader(document)) + } +} + func Start(t *testing.T, options Options) *Server { t.Helper() s := New(options) diff --git a/internal/testutil/fakemeshstack/keycloak.go b/internal/testutil/fakemeshstack/keycloak.go index f2f087a3..268500dc 100644 --- a/internal/testutil/fakemeshstack/keycloak.go +++ b/internal/testutil/fakemeshstack/keycloak.go @@ -4,12 +4,13 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" - "encoding/json/v2" gohttp "net/http" "net/url" "strings" "sync" "time" + + "github.com/meshcloud/meshstack-cli/internal/json" ) const cliClientId = "meshstack-cli" diff --git a/internal/testutil/jsontest/jsontest.go b/internal/testutil/jsontest/jsontest.go index 34d41225..7baf1e25 100644 --- a/internal/testutil/jsontest/jsontest.go +++ b/internal/testutil/jsontest/jsontest.go @@ -1,6 +1,7 @@ package jsontest import ( + "bytes" "testing" "github.com/stretchr/testify/require" @@ -10,6 +11,6 @@ import ( func MustUnmarshal[T any](t *testing.T, in []byte) (out T) { t.Helper() - require.NoError(t, json.Unmarshal(in, &out)) + require.NoError(t, json.UnmarshalRead(bytes.NewReader(in), &out)) return } diff --git a/internal/tfstate/apiproxy/apiproxy.go b/internal/tfstate/apiproxy/apiproxy.go deleted file mode 100644 index b2944a78..00000000 --- a/internal/tfstate/apiproxy/apiproxy.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package apiproxy passes requests on to the meshStack API. It stands apart from internal/tfstate -// because httputil.ReverseProxy takes a transport rather than a client, and forbidigo keeps the -// transport to internal/http and this package. -package apiproxy - -import ( - gohttp "net/http" - "net/http/httputil" - "net/url" - "strings" - - "github.com/meshcloud/meshstack-cli/internal/http" -) - -// New replaces the Authorization a request came with by that of client, and passes a request -// without one on as it came. The User-Agent names the command first, as it built the request, and -// then the front end of client, through which it went. -func New(client http.AuthorizedClient, endpoint *url.URL, onError func(gohttp.ResponseWriter, *gohttp.Request, error)) gohttp.Handler { - proxy := &httputil.ReverseProxy{ - Rewrite: func(out *httputil.ProxyRequest) { - out.SetURL(endpoint) - out.Out.Header.Set("User-Agent", strings.TrimSpace(out.In.Header.Get("User-Agent")+" "+client.UserAgent.String())) - }, - Transport: client.Transport, - ErrorHandler: onError, - } - return gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { - if r.Header.Get("Authorization") != "" { - r = r.Clone(r.Context()) - if err := client.Authorize(r); err != nil { - onError(w, r, err) - return - } - } - proxy.ServeHTTP(w, r) - }) -} diff --git a/internal/tfstate/backups.go b/internal/tfstate/backups.go index 2098fcb6..efc0b89b 100644 --- a/internal/tfstate/backups.go +++ b/internal/tfstate/backups.go @@ -5,14 +5,13 @@ import ( "os" "path/filepath" "time" - "uuid" ) type Backups string // save names the file by the time down to the nanosecond, so that two writes within one second keep // a backup each. -func (b Backups) save(buildingBlock uuid.UUID, state []byte, at time.Time) (path string, err error) { +func (b Backups) save(buildingBlock string, state []byte, at time.Time) (path string, err error) { if err = os.MkdirAll(string(b), 0o700); err != nil { return "", err } diff --git a/internal/tfstate/buildingblock.go b/internal/tfstate/buildingblock.go index 8d49ef6b..ae021b9c 100644 --- a/internal/tfstate/buildingblock.go +++ b/internal/tfstate/buildingblock.go @@ -2,41 +2,37 @@ package tfstate import ( "context" - "encoding/json/v2" "fmt" - "uuid" "github.com/meshcloud/meshstack-cli/client" ) -type buildingBlock struct { - Metadata client.MeshBuildingBlockV2Metadata `json:"metadata"` - Status *client.MeshBuildingBlockV2Status `json:"status"` -} - -func readBuildingBlock(ctx context.Context, raw *client.RawClient, buildingBlockUuid uuid.UUID) (block buildingBlock, err error) { - read, err := raw.Get[client.MeshBuildingBlockV2](ctx, buildingBlockUuid) - if err != nil { - return block, err +func readBuildingBlock(ctx context.Context, blocks client.MeshBuildingBlockV2Client, buildingBlockUuid string) (client.MeshBuildingBlockV2, error) { + block, err := blocks.Read(ctx, buildingBlockUuid) + switch { + case err != nil: + return client.MeshBuildingBlockV2{}, err + case block == nil: + return client.MeshBuildingBlockV2{}, fmt.Errorf("building block %s not found", buildingBlockUuid) } - return block, json.Unmarshal(read, &block) + return *block, nil } // OpenStore opens the state under the building block's own workspace where workspace is empty, // because that is where the runner stores it. -func OpenStore(ctx context.Context, raw *client.RawClient, workspace string, buildingBlockUuid uuid.UUID) (Store, error) { +func OpenStore(ctx context.Context, meshStack client.Client, workspace string, buildingBlockUuid string) (Store, error) { if workspace == "" { - block, err := readBuildingBlock(ctx, raw, buildingBlockUuid) + block, err := readBuildingBlock(ctx, meshStack.BuildingBlockV2, buildingBlockUuid) if err != nil { return Store{}, err } workspace = block.Metadata.OwnedByWorkspace } - return NewStore(raw, workspace, buildingBlockUuid), nil + return NewStore(meshStack.HttpClient, meshStack.Endpoint, workspace, buildingBlockUuid), nil } -func NoRunOf(ctx context.Context, raw *client.RawClient, buildingBlockUuid uuid.UUID) error { - block, err := readBuildingBlock(ctx, raw, buildingBlockUuid) +func NoRunOf(ctx context.Context, blocks client.MeshBuildingBlockV2Client, buildingBlockUuid string) error { + block, err := readBuildingBlock(ctx, blocks, buildingBlockUuid) if err != nil { return err } diff --git a/internal/tfstate/lock.go b/internal/tfstate/lock.go index 9b5bd114..3aa8a97b 100644 --- a/internal/tfstate/lock.go +++ b/internal/tfstate/lock.go @@ -2,14 +2,13 @@ package tfstate import ( "context" - "encoding/json/v2" "errors" "fmt" "time" - "uuid" "github.com/meshcloud/meshstack-cli/client" - "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/internal/json" ) type Lock struct { @@ -41,23 +40,18 @@ func (l Lock) String() string { } // NoUnfinishedRun passes a lock that a person holds. -func (l Lock) NoUnfinishedRun(ctx context.Context, raw *client.RawClient) error { +func (l Lock) NoUnfinishedRun(ctx context.Context, runs client.MeshBuildingBlockRunClient) error { if l.Holder.RunUuid == "" { return nil } - runUuid, err := uuid.Parse(l.Holder.RunUuid) - if err != nil { - return fmt.Errorf("the lock belongs to run %q, which is not a valid uuid: %w", l.Holder.RunUuid, err) - } - read, err := raw.Get[client.MeshBuildingBlockRun](ctx, runUuid) - if err != nil { + runUuid := l.Holder.RunUuid + run, err := runs.Read(ctx, runUuid) + switch { + case err != nil: return err - } - var run client.MeshBuildingBlockRun - if err = json.Unmarshal(read, &run); err != nil { - return err - } - if run.IsRunning() { + case run == nil: + return fmt.Errorf("building block run %s holds the lock, but meshStack does not know it", runUuid) + case run.IsRunning(): return fmt.Errorf("building block run %s holds the lock, is %s and is still writing the state", runUuid, run.Status) } return nil diff --git a/internal/tfstate/proxy.go b/internal/tfstate/proxy.go index 6a35ed81..09b172f5 100644 --- a/internal/tfstate/proxy.go +++ b/internal/tfstate/proxy.go @@ -5,23 +5,23 @@ import ( "crypto/rand" "crypto/subtle" "encoding/base64" - "encoding/json/v2" "errors" "fmt" "io" "log/slog" "net" gohttp "net/http" + "net/http/httputil" "net/url" "strconv" "sync" "time" + "github.com/meshcloud/meshstack-cli/client/http" "github.com/meshcloud/meshstack-cli/internal/auth" - "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/internal/json" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/profile" - "github.com/meshcloud/meshstack-cli/internal/tfstate/apiproxy" ) const ( @@ -103,9 +103,8 @@ func (p *Proxy) Serve(ctx context.Context, run func(env []string)) (requests int // With an API key as well, the provider since v0.26.0 refuses to pick one of the two. auth.ApiKeyClientIdSetting.EnvKey() + "=", auth.ApiKeyClientSecretSetting.EnvKey() + "=", - // The provider since v0.26.0 refuses a stored profile whose endpoint is not the proxy's, and - // creates a profile it does not find without storing it. - profile.NameSetting.EnvKey() + "=" + execProfile, + // A profile named in the environment has another endpoint than the proxy. + profile.NameSetting.EnvKey() + "=", }) err = server.Shutdown(context.WithoutCancel(ctx)) @@ -145,8 +144,6 @@ func (p *Proxy) ServeHTTP(w gohttp.ResponseWriter, r *gohttp.Request) { } } -const execProfile = "meshstack-tfstate-exec" - // newApiToken wraps password in the shape of a JWT that expires in a year, because the provider // since v0.26.0 refuses a token that is no JWT, or that has expired. Nobody checks its signature. func newApiToken(password string) string { @@ -171,8 +168,12 @@ func (p *Proxy) forward(w gohttp.ResponseWriter, r *gohttp.Request) { p.refuse(w, r, gohttp.StatusForbidden, fmt.Errorf("%w, so the command cannot %s %s", ErrReadOnlyApi, r.Method, r.URL.Path)) return } - apiproxy.New(p.Api, p.Endpoint, func(w gohttp.ResponseWriter, r *gohttp.Request, err error) { - p.fail(w, r, fmt.Errorf("cannot pass %s %s on to meshStack: %w", r.Method, r.URL.Path, err)) + (&httputil.ReverseProxy{ + Rewrite: func(out *httputil.ProxyRequest) { out.SetURL(p.Endpoint) }, + Transport: p.Api.ProxyTransport(), + ErrorHandler: func(w gohttp.ResponseWriter, r *gohttp.Request, err error) { + p.fail(w, r, fmt.Errorf("cannot pass %s %s on to meshStack: %w", r.Method, r.URL.Path, err)) + }, }).ServeHTTP(w, r) } @@ -283,7 +284,7 @@ func (p *Proxy) write(w gohttp.ResponseWriter, r *gohttp.Request) { p.refuse(w, r, gohttp.StatusInternalServerError, fmt.Errorf("cannot back up the stored state, so it stays as it is: %w", err)) return } - slog.InfoContext(ctx, "Saved the stored state of building block "+p.Store.BuildingBlock.String()+" to "+path) + slog.InfoContext(ctx, "Saved the stored state of building block "+p.Store.BuildingBlock+" to "+path) } lockId := r.URL.Query().Get("ID") diff --git a/internal/tfstate/proxy_test.go b/internal/tfstate/proxy_test.go index 072bc81a..f60fc153 100644 --- a/internal/tfstate/proxy_test.go +++ b/internal/tfstate/proxy_test.go @@ -15,12 +15,12 @@ import ( "sync" "testing" "time" - "uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/internal/oidc/jwt" "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" ) @@ -32,16 +32,7 @@ const ( nextState = `{"version":4,"serial":2,"lineage":"lineage-1"}` ) -var buildingBlockUuid = uuid.MustParse("b1d2c3e4-0000-4000-8000-000000000001") - -type requester struct { - endpoint *url.URL -} - -func (r requester) DoRequest(ctx context.Context, method, path string, opts ...http.RequestOption) ([]byte, error) { - return http.NewClient(fakemeshstack.UserAgent).WithAuthorization(http.BearerToken(fakemeshstack.Token)). - DoRequest[[]byte](ctx, method, r.endpoint.JoinPath(path), opts...) -} +var buildingBlockUuid = "b1d2c3e4-0000-4000-8000-000000000001" type proxyUnderTest struct { *Proxy @@ -50,7 +41,7 @@ type proxyUnderTest struct { problems []error } -var storedState = fakemeshstack.TfState{Workspace: "my-workspace", BuildingBlockUuid: buildingBlockUuid.String()} +var storedState = fakemeshstack.TfState{Workspace: "my-workspace", BuildingBlockUuid: buildingBlockUuid} func newProxy(t *testing.T, stored string, writable bool) *proxyUnderTest { t.Helper() @@ -63,7 +54,7 @@ func newProxy(t *testing.T, stored string, writable bool) *proxyUnderTest { require.NoError(t, err) p := &proxyUnderTest{meshStack: meshStack} p.Proxy = &Proxy{ - Store: NewStore(requester{endpoint}, "my-workspace", buildingBlockUuid), + Store: NewStore(http.NewClient(fakemeshstack.UserAgent).WithAuthorization(http.BearerToken(fakemeshstack.Token)), xurl.URL{URL: endpoint}, "my-workspace", buildingBlockUuid), Writable: writable, Backups: Backups(filepath.Join(t.TempDir(), "not", "yet", "there")), OnProblem: func(_ context.Context, err error) { p.problems = append(p.problems, err) }, @@ -118,7 +109,7 @@ func (p *proxyUnderTest) backups(t *testing.T) []string { require.NoError(t, err) var contents []string for _, file := range files { - assert.True(t, strings.HasPrefix(file, buildingBlockUuid.String()+"-"), file) + assert.True(t, strings.HasPrefix(file, buildingBlockUuid+"-"), file) info, err := fs.Stat(backups, file) require.NoError(t, err) assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) @@ -286,7 +277,7 @@ func TestAReadOnlyProxy(t *testing.T) { assert.Equal(t, gohttp.StatusOK, w.Code) assert.JSONEq(t, firstState, w.Body.String()) - assert.Equal(t, []string{"GET /api/terraform/state/workspace/my-workspace/buildingBlock/" + buildingBlockUuid.String()}, p.requests()) + assert.Equal(t, []string{"GET /api/terraform/state/workspace/my-workspace/buildingBlock/" + buildingBlockUuid}, p.requests()) }) t.Run("answers tofu's lock calls itself, so that a plan never makes a run wait", func(t *testing.T) { @@ -347,9 +338,8 @@ func TestServeCountsTheRequestsUntilTheCommandEnds(t *testing.T) { var apiToken jwt.JWT require.NoError(t, apiToken.UnmarshalText([]byte(vars["MESHSTACK_API_TOKEN"])), "the provider since v0.26.0 takes only a JWT") assert.False(t, apiToken.GetClaim(jwt.ExpiryClaim).Expired(time.Hour)) - authorization := gohttp.Header{} - authorization.Set("Authorization", "Basic "+basicAuth(vars["TF_HTTP_USERNAME"], vars["TF_HTTP_PASSWORD"])) - served, err = http.NewClient(fakemeshstack.UserAgent).DoRequest[[]byte](t.Context(), http.MethodGet, address, http.WithHeaders(authorization)) + authorization := http.WithHeader("Authorization", "Basic "+basicAuth(vars["TF_HTTP_USERNAME"], vars["TF_HTTP_PASSWORD"])) + served, err = http.ReadAllBody(http.NewClient(fakemeshstack.UserAgent).DoRequest(t.Context(), http.MethodGet, address, authorization)) require.NoError(t, err) }) @@ -417,7 +407,7 @@ func (l *lockOf) serve(w gohttp.ResponseWriter, r *gohttp.Request) { func TestAWritableProxyPassesTheLockOn(t *testing.T) { p := newProxy(t, firstState, true) lock := &lockOf{} - p.meshStack.Route("/api/terraform/state/workspace/my-workspace/buildingBlock/"+buildingBlockUuid.String()+"/lock", lock.serve) + p.meshStack.Route("/api/terraform/state/workspace/my-workspace/buildingBlock/"+buildingBlockUuid+"/lock", lock.serve) var log lockedLog previous := slog.Default() t.Cleanup(func() { slog.SetDefault(previous) }) @@ -452,7 +442,7 @@ func TestAWritableProxyPassesTheLockOn(t *testing.T) { }) t.Run("passes a write that meshStack refuses for another's lock back as 423, and logs the holder rather than a problem", func(t *testing.T) { - writeInMeshStack := "POST /api/terraform/state/workspace/my-workspace/buildingBlock/" + buildingBlockUuid.String() + writeInMeshStack := "POST /api/terraform/state/workspace/my-workspace/buildingBlock/" + buildingBlockUuid p.meshStack.Route(writeInMeshStack, func(w gohttp.ResponseWriter, _ *gohttp.Request) { w.WriteHeader(gohttp.StatusLocked) _, _ = w.Write([]byte(lockInfo("theirs"))) @@ -478,7 +468,7 @@ func TestAWritableProxyPassesTheLockOn(t *testing.T) { }) t.Run("passes a 5xx of meshStack on for tofu to retry, and logs it rather than a problem", func(t *testing.T) { - lockInMeshStack := "POST /api/terraform/state/workspace/my-workspace/buildingBlock/" + buildingBlockUuid.String() + "/lock" + lockInMeshStack := "POST /api/terraform/state/workspace/my-workspace/buildingBlock/" + buildingBlockUuid + "/lock" p.meshStack.Route(lockInMeshStack, func(w gohttp.ResponseWriter, _ *gohttp.Request) { w.WriteHeader(gohttp.StatusInternalServerError) }) @@ -486,7 +476,7 @@ func TestAWritableProxyPassesTheLockOn(t *testing.T) { assert.Equal(t, gohttp.StatusInternalServerError, p.sendTo(gohttp.MethodPost, lockPath, lockInfo("mine")).Code) assert.Empty(t, p.problems, "tofu fails the command itself where its retries fail as well") - assert.Contains(t, log.String(), "cannot lock the state of building block "+buildingBlockUuid.String()) + assert.Contains(t, log.String(), "cannot lock the state of building block "+buildingBlockUuid) }) t.Run("releases the lock in meshStack", func(t *testing.T) { diff --git a/internal/tfstate/store.go b/internal/tfstate/store.go index 46872449..1660e835 100644 --- a/internal/tfstate/store.go +++ b/internal/tfstate/store.go @@ -4,13 +4,14 @@ package tfstate import ( "context" - "encoding/json/v2" + "encoding/json/jsontext" "errors" "fmt" - gohttp "net/http" - "uuid" + "net/url" - "github.com/meshcloud/meshstack-cli/internal/http" + "github.com/meshcloud/meshstack-cli/client/http" + "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/internal/json" ) // ErrNoState is meshStack's 404, which tofu's http backend takes for a state not written yet. @@ -18,24 +19,25 @@ var ErrNoState = errors.New("no state") var ErrNoLock = errors.New("no lock") -// Requester is implemented by client.RawClient. -type Requester interface { - DoRequest(ctx context.Context, method, path string, opts ...http.RequestOption) ([]byte, error) -} - type Store struct { - BuildingBlock uuid.UUID + BuildingBlock string Workspace string - raw Requester + client http.AuthorizedClient + state *url.URL } -func NewStore(raw Requester, workspace string, buildingBlock uuid.UUID) Store { - return Store{BuildingBlock: buildingBlock, Workspace: workspace, raw: raw} +// NewStore must address the state as stateEndpointFormat in +// ../building-block-runner/internal/executor/tf/tfcmd.go does. +func NewStore(client http.AuthorizedClient, endpoint xurl.URL, workspace string, buildingBlock string) Store { + return Store{ + BuildingBlock: buildingBlock, Workspace: workspace, client: client, + state: endpoint.JoinPath("api", "terraform", "state", "workspace", workspace, "buildingBlock", buildingBlock), + } } func (s Store) Get(ctx context.Context) ([]byte, error) { - state, err := s.raw.DoRequest(ctx, http.MethodGet, s.path()) + state, err := s.do(ctx, http.MethodGet, s.state) if httpErr, ok := errors.AsType[http.Error](err); ok && httpErr.IsNotFound() { return nil, ErrNoState } @@ -45,12 +47,12 @@ func (s Store) Get(ctx context.Context) ([]byte, error) { // Put and Delete send the ID of tofu's lock as tofu's http backend does, because meshStack refuses // a write without the ID of the lock it holds. func (s Store) Put(ctx context.Context, state []byte, lockId string) error { - _, err := s.raw.DoRequest(ctx, http.MethodPost, s.path(), withLockId(lockId, http.WithBody(state), jsonContent)...) + _, err := s.do(ctx, http.MethodPost, s.state, withLockId(lockId, jsonBody(state)...)...) return s.wrap("store", err) } func (s Store) Delete(ctx context.Context, lockId string) error { - _, err := s.raw.DoRequest(ctx, http.MethodDelete, s.path(), withLockId(lockId)...) + _, err := s.do(ctx, http.MethodDelete, s.state, withLockId(lockId)...) return s.wrap("delete", err) } @@ -61,24 +63,22 @@ func withLockId(lockId string, opts ...http.RequestOption) []http.RequestOption return append(opts, http.WithUrlQuery(map[string]string{"ID": lockId})) } -var jsonContent = http.WithHeaders(gohttp.Header{"Content-Type": {"application/json"}}) - // Lock and Unlock take tofu's lock info as tofu sends it. A Lock that meshStack refuses with 423 // is an [http.Error] that carries the lock info of the holder. func (s Store) Lock(ctx context.Context, info []byte) error { - _, err := s.raw.DoRequest(ctx, http.MethodPost, s.lockPath(), http.WithBody(info), jsonContent) + _, err := s.do(ctx, http.MethodPost, s.state.JoinPath("lock"), jsonBody(info)...) return s.wrap("lock", err) } // Unlock fails with a 423 of meshStack where meshStack holds a lock of another ID than the one in // info. func (s Store) Unlock(ctx context.Context, info []byte) error { - _, err := s.raw.DoRequest(ctx, http.MethodDelete, s.lockPath(), http.WithBody(info), jsonContent) + _, err := s.do(ctx, http.MethodDelete, s.state.JoinPath("lock"), jsonBody(info)...) return s.wrap("unlock", err) } func (s Store) ReadLock(ctx context.Context) (lock Lock, err error) { - read, err := s.raw.DoRequest(ctx, http.MethodGet, s.lockPath()) + read, err := s.do(ctx, http.MethodGet, s.state.JoinPath("lock")) if httpErr, ok := errors.AsType[http.Error](err); ok && httpErr.IsNotFound() { return lock, ErrNoLock } else if err != nil { @@ -87,6 +87,14 @@ func (s Store) ReadLock(ctx context.Context) (lock Lock, err error) { return lock, json.Unmarshal(read, &lock) } +func (s Store) do(ctx context.Context, method string, target *url.URL, opts ...http.RequestOption) ([]byte, error) { + if method != http.MethodGet { + // A 423 goes back to tofu at once, which names the holder of the lock and waits itself. + opts = append(opts, http.Retryable(http.RetryOnBusy)) + } + return http.ReadAllBody(s.client.DoRequest(ctx, method, target, opts...)) +} + func (s Store) wrap(action string, err error) error { if err == nil { return nil @@ -94,12 +102,10 @@ func (s Store) wrap(action string, err error) error { return fmt.Errorf("cannot %s the state of building block %s in workspace %s: %w", action, s.BuildingBlock, s.Workspace, err) } -// path must match EP_State in ../building-block-runner/tf-block-runner/tfrun/runapi.go, the address -// that the runner's backend uses. meshfed's TfHttpStateController serves it. -func (s Store) path() string { - return fmt.Sprintf("/api/terraform/state/workspace/%s/buildingBlock/%s", s.Workspace, s.BuildingBlock) -} - -func (s Store) lockPath() string { - return s.path() + "/lock" +// jsonBody sends no body for an empty one, as tofu's force-unlock sends it. +func jsonBody(body []byte) []http.RequestOption { + if len(body) == 0 { + return nil + } + return []http.RequestOption{http.WithJsonRequest(jsontext.Value(body), "")} } diff --git a/pkg/setting/source.go b/pkg/setting/source.go index 4a04ab8b..4f006b92 100644 --- a/pkg/setting/source.go +++ b/pkg/setting/source.go @@ -2,7 +2,6 @@ package setting import ( "context" - "errors" "github.com/meshcloud/meshstack-cli/internal/meshstack" "github.com/meshcloud/meshstack-cli/internal/setting" @@ -52,13 +51,3 @@ func FallbackLookupSource(matchingEnvKey, description string, lookup func(ctx co func WorkspacesFromContext(ctx context.Context) (meshstack.Workspaces, error) { return meshstack.WorkspacesFromContext(ctx) } - -// ResolveWorkspace resolves Workspace from sources and the environment, and is empty when neither -// names one. A profile's default workspace is no source here, unlike in a session. -func ResolveWorkspace(ctx context.Context, sources Sources) (string, error) { - workspace, err := sources.ResolveSetting(ctx, meshstack.WorkspaceSetting) - if errors.Is(err, setting.ErrNoSourceProvidedValue) { - return "", nil - } - return string(workspace), err -} diff --git a/skills/meshstack-cli/SKILL.md b/skills/meshstack-cli/SKILL.md index 50b87648..6a1b6d9e 100644 --- a/skills/meshstack-cli/SKILL.md +++ b/skills/meshstack-cli/SKILL.md @@ -59,8 +59,8 @@ A login stores a credential in a profile. A profile also holds an endpoint and a `MESHSTACK_PROFILE`, `MESHSTACK_WORKSPACE` and a credential in the environment (`MESHSTACK_API_KEY` with `MESHSTACK_API_SECRET`, or `MESHSTACK_API_TOKEN`) win over what a login stores, and a -command warns when they do. Unset them to use the stored profile. To ignore the stored profiles -instead, as a script or CI job should, point `MESHSTACK_CONFIG_DIR` at a directory of its own. +command warns when they do. Unset them to use the stored profile. A script or CI job needs no +profile: it sets `MESHSTACK_ENDPOINT` and a credential. ```sh meshstack auth status -p # who, which workspace, until when — check this first @@ -379,7 +379,10 @@ current login. For a meshObject endpoint it picks the latest API version and set `Content-Type` headers itself; `--api-version` picks another one. An answer outside 2xx still prints its body, but the command fails. -Do not read the OpenAPI document yourself — it is several MB. Ask `api-docs`: +Do not read the OpenAPI document yourself — it is about 1 MB. Ask `api-docs`, which reads the +document the endpoint serves at `/api/openapi`, so it describes the API of that meshStack. It needs +no login. A meshStack that serves none gets the published docs, with a warning that they may not +match it: ```sh meshstack api-docs --describe bb # every operation of a kind, latest version