diff --git a/.github/scripts/build.sh b/.github/scripts/build.sh index 7ed7eb1..549c451 100644 --- a/.github/scripts/build.sh +++ b/.github/scripts/build.sh @@ -1,32 +1,30 @@ if [ "$GOOS" == "linux" ] then set -e - go build -o cdis-data-client + go build -o calypr-cli ./cmd/calypr-cli ls -al if [ "$GITHUB_PULL_REQUEST" == "false" ]; then - mv gen3-client files && mv cdis-data-client gen3-client - zip dataclient_linux.zip gen3-client && mv dataclient_linux.zip ~/shared/. + zip calypr-cli_linux.zip calypr-cli && mv calypr-cli_linux.zip ~/shared/. aws s3 sync ~/shared s3://cdis-dc-builds/$GITHUB_BRANCH fi set +e elif [ "$GOOS" == "darwin" ] then set -e - go build -o cdis-data-client + go build -o calypr-cli ./cmd/calypr-cli ls -al if [ "$GITHUB_PULL_REQUEST" == "false" ]; then - mv gen3-client files && mv cdis-data-client gen3-client - zip dataclient_osx.zip gen3-client && mv dataclient_osx.zip ~/shared/. + zip calypr-cli_osx.zip calypr-cli && mv calypr-cli_osx.zip ~/shared/. aws s3 sync ~/shared s3://cdis-dc-builds/$GITHUB_BRANCH fi set +e elif [ "$GOOS" == "windows" ] then set -e - go build -o gen3-client.exe + go build -o calypr-cli.exe ./cmd/calypr-cli ls -al if [ "$GITHUB_PULL_REQUEST" == "false" ]; then - zip dataclient_win64.zip gen3-client.exe && mv dataclient_win64.zip ~/shared/. + zip calypr-cli_win64.zip calypr-cli.exe && mv calypr-cli_win64.zip ~/shared/. aws s3 sync ~/shared s3://cdis-dc-builds/$GITHUB_BRANCH fi set +e diff --git a/.github/workflows/build_and_test_pull.yaml b/.github/workflows/build_and_test_pull.yaml deleted file mode 100644 index df9a68e..0000000 --- a/.github/workflows/build_and_test_pull.yaml +++ /dev/null @@ -1,72 +0,0 @@ -name: "Test and build binary for pull request" -on: - pull_request: - branches: - - master -jobs: - test: - name: test - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Go 1.17 - uses: actions/setup-go@v4 - with: - go-version: '1.17' - - - name: Run Setup Script - run: | - bash ./.github/scripts/before_install.sh - env: - GITHUB_BRANCH: ${{ github.ref_name }} - ACCESS_KEY: ${{ secrets.AWS_S3_ACCESS_KEY_ID }} - SECRET_ACCESS_KEY: ${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} - - - name: Run Tests - run: go test -v github.com/uc-cdis/gen3-client/tests - - build: - env: - goarch: amd64 - needs: test - runs-on: ubuntu-latest - strategy: - matrix: - include: - - goos: linux - goarch: amd64 - zipfile: dataclient_linux.zip - - goos: darwin - goarch: amd64 - zipfile: dataclient_osx.zip - - goos: windows - goarch: amd64 - zipfile: dataclient_win64.zip - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Go 1.17 - uses: actions/setup-go@v4 - with: - go-version: '1.17' - - - name: Run Setup Script - run: | - bash .github/scripts/before_install.sh - env: - GITHUB_BRANCH: ${{ github.ref_name }} - ACCESS_KEY: ${{ secrets.AWS_S3_ACCESS_KEY_ID }} - SECRET_ACCESS_KEY: ${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} - - - - name: Run Build Script - run: | - bash .github/scripts/build.sh - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ env.goarch }} - GITHUB_BRANCH: ${{ github.ref_name }} - GITHUB_PULL_REQUEST: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/build_and_test_push.yaml b/.github/workflows/build_and_test_push.yaml deleted file mode 100644 index 4ed6a63..0000000 --- a/.github/workflows/build_and_test_push.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: "Test, build, sync to AWS, and create release (on tagged push)" -on: push - - -jobs: - - test: - name: test - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Go 1.17 - uses: actions/setup-go@v4 - with: - go-version: '1.17' - - - name: Run Setup Script - run: | - bash ./.github/scripts/before_install.sh - env: - GITHUB_BRANCH: ${{ github.ref_name }} - ACCESS_KEY: ${{ secrets.AWS_S3_ACCESS_KEY_ID }} - SECRET_ACCESS_KEY: ${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} - - - name: Run Tests - run: go test -v github.com/uc-cdis/gen3-client/tests - - diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 9164185..b9ba9fa 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -2,11 +2,8 @@ name: "Test Coverage Check" on: pull_request: - branches: - - master push: - branches: - - master + workflow_dispatch: jobs: coverage: @@ -20,7 +17,8 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.24.2' + go-version-file: go.mod + cache: true - name: Run Tests with Coverage run: | @@ -52,7 +50,7 @@ jobs: awk '/^ok[[:space:]]/ { pkg=$2; cov=$5; - gsub(/github.com\/calypr\/data-client\//, "", pkg); + gsub(/github.com\/calypr\/calypr-cli\//, "", pkg); print "| " pkg " | " cov " |" }' >> coverage-report.md @@ -81,7 +79,7 @@ jobs: awk '/^ok[[:space:]]/ { pkg=$2; cov=$5; - gsub(/github.com\/calypr\/data-client\//, "", pkg); + gsub(/github.com\/calypr\/calypr-cli\//, "", pkg); print pkg, cov }' ) diff --git a/.github/workflows/golang-ci-workflow.yaml b/.github/workflows/golang-ci-workflow.yaml deleted file mode 100644 index e94f859..0000000 --- a/.github/workflows/golang-ci-workflow.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: Golang CI Workflow - -on: push - -jobs: - ci: - runs-on: ubuntu-latest - env: - COVERAGE_PROFILE_OUTPUT_LOCATION: "./profile.cov" - steps: - - name: Checkout code / lint code / install dependencies for goveralls / run tests - uses: uc-cdis/.github/.github/actions/golang-ci@master - with: - COVERAGE_PROFILE_OUTPUT_LOCATION: ${{ env.COVERAGE_PROFILE_OUTPUT_LOCATION }} - - name: Send coverage to coveralls using goveralls - env: - COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: goveralls -coverprofile=${{ env.COVERAGE_PROFILE_OUTPUT_LOCATION }} -service=github diff --git a/.github/workflows/image_build_push.yaml b/.github/workflows/image_build_push.yaml index 4c50ae5..3301de1 100644 --- a/.github/workflows/image_build_push.yaml +++ b/.github/workflows/image_build_push.yaml @@ -7,7 +7,7 @@ jobs: name: Build Image and Push uses: uc-cdis/.github/.github/workflows/image_build_push.yaml@master with: - OVERRIDE_REPO_NAME: "gen3-client" + OVERRIDE_REPO_NAME: "calypr-cli" secrets: ECR_AWS_ACCESS_KEY_ID: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }} ECR_AWS_SECRET_ACCESS_KEY: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index baabb42..fc01885 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -11,9 +11,9 @@ jobs: name: Integration tests uses: uc-cdis/.github/.github/workflows/integration_tests.yaml@master with: - QUAY_REPO: gen3-client + QUAY_REPO: calypr-cli # Tag used in tests that depend on this repo (https://github.com/uc-cdis/gen3-code-vigil/blob/master/gen3-integration-tests/pyproject.toml) - SERVICE_TO_TEST: gen3_client + SERVICE_TO_TEST: calypr_cli secrets: CI_AWS_ACCESS_KEY_ID: ${{ secrets.CI_AWS_ACCESS_KEY_ID }} CI_AWS_SECRET_ACCESS_KEY: ${{ secrets.CI_AWS_SECRET_ACCESS_KEY }} diff --git a/Dockerfile b/Dockerfile index b191aec..753d86e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ENV CGO_ENABLED=0 ENV GOOS=linux ENV GOARCH=amd64 -WORKDIR $GOPATH/src/github.com/calypr/data-client/ +WORKDIR $GOPATH/src/github.com/calypr/calypr-cli/ COPY go.mod . COPY go.sum . @@ -15,15 +15,15 @@ COPY . . RUN COMMIT=$(git rev-parse HEAD); \ VERSION=$(git describe --always --tags); \ - printf '%s\n' 'package g3cmd'\ + printf '%s\n' 'package cmd'\ ''\ 'const ('\ ' gitcommit="'"${COMMIT}"'"'\ ' gitversion="'"${VERSION}"'"'\ - ')' > data-client/g3cmd/gitversion.go \ - && go build -o /data-client + ')' > cmd/gitversion.go \ + && go build -o /calypr-cli . FROM scratch COPY --from=build-deps /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -COPY --from=build-deps /data-client /data-client -CMD ["/data-client"] +COPY --from=build-deps /calypr-cli /calypr-cli +CMD ["/calypr-cli"] diff --git a/Makefile b/Makefile index 1d556f7..f09edb9 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,9 @@ # --- Variables --- -# The name of the resulting binary (e.g., 'data-client' if your module is called data-client) +# The name of the resulting binary. Defaults to the repo directory name. # Update this if your main package is not in the root directory. TARGET_NAME := $(shell basename $(shell pwd)) -# The default path to build the main package. Use '.' if your main package is in the root. -# Change this if your main package is in a subdirectory (e.g., ./cmd/myapp) +# The default build path. This repo keeps a root main package for `go build .`. MAIN_PACKAGE := . # The directory where the final binary will be placed @@ -62,7 +61,7 @@ coverage-check: test-coverage awk '/^ok[[:space:]]/ { \ pkg=$$2; \ cov=$$5; \ - gsub(/github.com\\/calypr\\/data-client\\//, "", pkg); \ + gsub(/github.com\\/calypr\\/calypr-cli\\//, "", pkg); \ print pkg, cov; \ }' | \ while read -r pkg cov; do \ diff --git a/README.md b/README.md index 4d16202..ace5397 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,124 @@ -# data-client +# calypr-cli -[![CI](https://github.com/calypr/data-client/actions/workflows/ci.yaml/badge.svg?branch=master)](https://github.com/calypr/data-client/actions/workflows/ci.yaml) -[![Coverage](https://codecov.io/gh/calypr/data-client/branch/develop/graph/badge.svg)](https://app.codecov.io/gh/calypr/data-client/tree/develop) -[![Go Report Card](https://goreportcard.com/badge/github.com/calypr/data-client)](https://goreportcard.com/report/github.com/calypr/data-client) -[![Release](https://img.shields.io/github/v/release/calypr/data-client?sort=semver)](https://github.com/calypr/data-client/releases) +[![CI](https://github.com/calypr/calypr-cli/actions/workflows/ci.yaml/badge.svg)](https://github.com/calypr/calypr-cli/actions/workflows/ci.yaml) +[![Coverage](https://github.com/calypr/calypr-cli/actions/workflows/coverage.yaml/badge.svg)](https://github.com/calypr/calypr-cli/actions/workflows/coverage.yaml) +[![Release](https://img.shields.io/github/v/release/calypr/calypr-cli?sort=semver)](https://github.com/calypr/calypr-cli/releases) -`data-client` is a command-line tool for downloading, uploading, and submitting data files to and from a Gen3 data commons. +`calypr-cli` is Calypr's command-line client for working with Gen3-style data +commons from a terminal. It combines the standard data transfer flows with +operator-oriented surfaces for permissions, collaborators, and portal +configuration. -Read more about what it does and how to use it in the `data-client` [user guide](https://gen3.org/resources/user/data-client/). +## What It Does -`data-client` is built on Cobra, a library providing a simple interface to create powerful modern CLI interfaces similar to git & go tools. Read more about Cobra [here](https://github.com/spf13/cobra). +The CLI currently groups into four main areas: -## Installation +| Command group | Purpose | +| --- | --- | +| `configure`, `auth` | Store credentials, create named profiles, and check connectivity | +| `upload*`, `download*`, `retry-upload` | Transfer files to and from supported backends | +| `collaborators` | Manage collaboration requests and approvals | +| `permissions`, `portal` | Operator workflows for Arborist-backed permissions and Gecko-backed portal config | -(The following instruction is for compiling and installing the `data-client` from source code. There are also binary executables can be found at [here](https://github.com/uc-cdis/cdis-data-client/releases)) +Most commands require a configured `--profile`, and the default backend is +`gen3`. -First, [install Go and the Go tools](https://golang.org/doc/install) if you have not already done so. [Set up your workspace and your GOPATH.](https://golang.org/doc/code.html) +## Operator Surfaces -Then: +Two command groups are specific to the Calypr fork rather than the legacy data +transfer client: -``` -go get -d github.com/calypr/data-client -go install -``` +| Command group | Supported surface | Deliberate non-goals | +| --- | --- | --- | +| `permissions` | Public Gen3 `/authz` routes exposed through revproxy | Raw Arborist catalog/admin CRUD and arbitrary policy mutation | +| `portal` | Public `/gecko` routes exposed through revproxy | Backend-only Gecko paths and undocumented admin-only flows | + +Detailed operator docs live here: + +- [permissions CLI guide](docs/permissions-cli.md) +- [portal CLI guide](docs/portal-cli.md) +- [developer docs](docs/DEVELOPER_DOCS.md) -_TODO: Remove after GitHub repo is renamed_ -**_For now, the above actually won't work because the GitHub repo needs to be renamed. Do this instead:_** +## Install And Build +The repo keeps a root `main` package, so the primary local build path is the +repo root: + +```bash +go build . ``` -mkdir -p $GOPATH/src/github.com/uc-cdis -cd $GOPATH/src/github.com/uc-cdis -git clone git@github.com:uc-cdis/cdis-data-client.git -mv cdis-data-client data-client -cd data-client -go get -d ./... + +To install the current checkout: + +```bash go install . ``` -Now you should have `data-client` successfully installed. For a comprehensive instruction on how to configure and use `data-client` for uploading / downloading object files, please refer to the `data-client` [user guide](https://gen3.org/resources/user/data-client/). - -## Enabling New Gen3 Object Management API +There is also a compatibility entrypoint under `./cmd/calypr-cli` if you need a +subdirectory main package explicitly: -Some Gen3 data commons support uploading files through the new Gen3 Object Management API. +```bash +go build ./cmd/calypr-cli +``` -> NOTE: The service powering this API is sometimes referred to as our object "Shepherd" +## Common Setup -To enable data-client to upload using the Gen3 Object Management API, pass the `use-shepherd=true` to `data-client configure`, e.g.: +Configure a profile with credentials and an API endpoint: -``` -$ data-client configure --profile=myprofile --cred=/path/to/cred --apiendpoint=https://example.com --use-shepherd=true +```bash +calypr-cli configure \ + --profile=myprofile \ + --cred=/path/to/credentials.json \ + --apiendpoint=https://example.com ``` -If this flag is set, the data-client will attempt to use the Gen3 Object Management API to upload files, falling back to Fence/Indexd in case of failure. +Check the resulting profile or auth state: -> You may also need to configure the version of the Gen3 Object Management API that the client will interact with. This is set to a default of Gen3 Object Management API `v2.0.0`, but can -> be raised or lowered by passing the `min-shepherd-version` flag to `data-client configure`, e.g.: -> -> ``` -> $ data-client configure --profile=myprofile --cred=/path/to/cred --apiendpoint=https://example.com --use-shepherd=true --min-shepherd-version=1.3.0 -> ``` - -### Uploading Additional File Object Metadata to Gen3 Object Management API +```bash +calypr-cli auth --profile=myprofile +``` -The Gen3 Object Management API supports uploading additional _public access_ file object metadata when uploading data files. +## Upload Behavior -> WARNING: Additional File Object Metadata is exposed publically and thus should not be controlled/sensitive data +`calypr-cli` supports the newer Gen3 object management upload flow via the +`--use-shepherd=true` configure flag. When enabled, uploads attempt that API +first and fall back to the older Fence/Indexd path if needed. -You can upload file metadata using the `data-client upload` command with the `--metadata` flag. E.g.: +Example: -``` -data-client upload --profile=my-profile --upload-path=/path/to/myfile.bam --metadata +```bash +calypr-cli configure \ + --profile=myprofile \ + --cred=/path/to/credentials.json \ + --apiendpoint=https://example.com \ + --use-shepherd=true ``` -This will tell `data-client` to look for a metadata file `myfile_metadata.json` in the same folder as `myfile.bam`. -A metadata file should be located in the same folder as the file to be uploaded, and should be named `[filename]_metadata.json`. +Uploads can also attach public file object metadata with `--metadata`. The CLI +looks for a sibling file named `[filename]_metadata.json`. -The metadata file should be a JSON file in the format: +Example: +```bash +calypr-cli upload \ + --profile=myprofile \ + --upload-path=/path/to/myfile.bam \ + --metadata ``` + +Metadata file shape: + +```json { - "authz": ["/example/authz/resource"], - "aliases": ["example_alias"], - "metadata": { - "any": { - "arbitrary": ["json", "metadata"] - } + "authz": ["/example/authz/resource"], + "aliases": ["example_alias"], + "metadata": { + "any": { + "arbitrary": ["json", "metadata"] } + } } ``` -The `aliases` and `metadata` properties are optional. Some Gen3 data commons require the `authz` property to be specified in order to upload a data file. - -If you do not know what `authz` to use, you can look at your `Profile` tab or `/identity` page of the Gen3 data commons you are uploading to. You will see a list of _authz resources_ in the format `/example/authz/resource`: these are the authz resources you have access to. +The `authz` list may be required by the target commons. The metadata in this +file is public-facing and should not contain sensitive content. diff --git a/arborist/client.go b/arborist/client.go new file mode 100644 index 0000000..f8e38eb --- /dev/null +++ b/arborist/client.go @@ -0,0 +1,169 @@ +package arborist + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/request" +) + +const DefaultOrgMemberRole = "org-member" + +type Client struct { + request.RequestInterface + Endpoint string +} + +type ClientInterface interface { + AuthMapping(ctx context.Context, out any) error + CreateOwnedDescendant(ctx context.Context, req CreateOwnedDescendantRequest, out any) error + AddOwner(ctx context.Context, req OwnerMutationRequest, out any) error + RemoveOwner(ctx context.Context, req OwnerMutationRequest) error + GetOwnershipResource(ctx context.Context, resourcePath string, includeChildren bool, includeAdmins bool, out any) error + GrantAccessUser(ctx context.Context, req AccessUserRequest, out any) error + RevokeAccessUser(ctx context.Context, req AccessUserRequest) error + GrantOrgMembership(ctx context.Context, organization string, username string, roleID string) error + RevokeOrgMembership(ctx context.Context, organization string, username string, roleID string) error +} + +type CreateOwnedDescendantRequest struct { + ParentPath string `json:"parent_path"` + Name string `json:"name"` + Template string `json:"template,omitempty"` + Description string `json:"description,omitempty"` +} + +type OwnerMutationRequest struct { + ResourcePath string `json:"resource_path"` + Username string `json:"username"` +} + +type AccessUserRequest struct { + ResourcePath string `json:"resource_path"` + Username string `json:"username"` + RoleID string `json:"role_id"` +} + +type errorResponse struct { + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +func (e *errorResponse) ErrorMessage() string { + if strings.TrimSpace(e.Message) != "" { + return e.Message + } + return e.Error +} + +func NewClient(req request.RequestInterface, creds *conf.Credential) ClientInterface { + return &Client{ + RequestInterface: req, + Endpoint: creds.APIEndpoint, + } +} + +func (c *Client) AuthMapping(ctx context.Context, out any) error { + return c.do(ctx, http.MethodGet, "authz/mapping", nil, out, "get auth mapping") +} + +func (c *Client) CreateOwnedDescendant(ctx context.Context, req CreateOwnedDescendantRequest, out any) error { + return c.do(ctx, http.MethodPost, "authz/ownership/descendant", req, out, "create owned descendant") +} + +func (c *Client) AddOwner(ctx context.Context, req OwnerMutationRequest, out any) error { + return c.do(ctx, http.MethodPost, "authz/ownership/owner", req, out, "add owner") +} + +func (c *Client) RemoveOwner(ctx context.Context, req OwnerMutationRequest) error { + return c.do(ctx, http.MethodDelete, "authz/ownership/owner", req, nil, "remove owner") +} + +func (c *Client) GetOwnershipResource(ctx context.Context, resourcePath string, includeChildren bool, includeAdmins bool, out any) error { + query := url.Values{} + query.Set("resource_path", strings.TrimSpace(resourcePath)) + if includeChildren { + query.Set("include_children", "true") + } + if includeAdmins { + query.Set("include_admins", "true") + } + return c.do(ctx, http.MethodGet, "authz/ownership/resource?"+query.Encode(), nil, out, "get ownership resource") +} + +func (c *Client) GrantAccessUser(ctx context.Context, req AccessUserRequest, out any) error { + return c.do(ctx, http.MethodPost, "authz/access/user", normalizeAccessUserRequest(req), out, "grant direct user access") +} + +func (c *Client) RevokeAccessUser(ctx context.Context, req AccessUserRequest) error { + return c.do(ctx, http.MethodDelete, "authz/access/user", normalizeAccessUserRequest(req), nil, "revoke direct user access") +} + +func (c *Client) GrantOrgMembership(ctx context.Context, organization string, username string, roleID string) error { + payload, err := NewOrgMembershipRequest(organization, username, roleID) + if err != nil { + return err + } + return c.GrantAccessUser(ctx, payload, nil) +} + +func (c *Client) RevokeOrgMembership(ctx context.Context, organization string, username string, roleID string) error { + payload, err := NewOrgMembershipRequest(organization, username, roleID) + if err != nil { + return err + } + return c.RevokeAccessUser(ctx, payload) +} + +func NewOrgMembershipRequest(organization string, username string, roleID string) (AccessUserRequest, error) { + organization = strings.Trim(strings.TrimSpace(organization), "/") + username = strings.ToLower(strings.TrimSpace(username)) + roleID = strings.TrimSpace(roleID) + if roleID == "" { + roleID = DefaultOrgMemberRole + } + if organization == "" { + return AccessUserRequest{}, fmt.Errorf("organization is required") + } + if strings.Contains(organization, "/") { + return AccessUserRequest{}, fmt.Errorf("organization must be a single Calypr organization name, not a resource path") + } + if username == "" { + return AccessUserRequest{}, fmt.Errorf("username is required") + } + return AccessUserRequest{ + ResourcePath: "/programs/" + organization + "/projects", + Username: username, + RoleID: roleID, + }, nil +} + +func normalizeAccessUserRequest(req AccessUserRequest) AccessUserRequest { + req.ResourcePath = strings.TrimSpace(req.ResourcePath) + req.Username = strings.ToLower(strings.TrimSpace(req.Username)) + req.RoleID = strings.TrimSpace(req.RoleID) + return req +} + +func (c *Client) do(ctx context.Context, method string, apiPath string, body any, out any, action string) error { + url := request.JoinURL(c.Endpoint, apiPath) + if path, query, found := strings.Cut(apiPath, "?"); found { + url = request.JoinURL(c.Endpoint, path) + "?" + query + } + rb, err := request.NewJSON(c.RequestInterface, method, url, body) + if err != nil { + return err + } + return request.DoJSON( + ctx, + c.RequestInterface, + rb, + out, + request.WithAction(action), + request.WithErrorEnvelope(&errorResponse{}), + ) +} diff --git a/arborist/client_test.go b/arborist/client_test.go new file mode 100644 index 0000000..39e98d0 --- /dev/null +++ b/arborist/client_test.go @@ -0,0 +1,195 @@ +package arborist + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "testing" + + "github.com/calypr/calypr-cli/request" +) + +type fakeRequest struct { + lastBody map[string]any + lastMethod string + lastPath string + lastQuery string + statusCode int +} + +func (f *fakeRequest) New(method, rawURL string) *request.RequestBuilder { + return (&request.Request{}).New(method, rawURL) +} + +func (f *fakeRequest) Do(ctx context.Context, rb *request.RequestBuilder) (*http.Response, error) { + f.lastMethod = rb.Method + parsed, _ := url.Parse(rb.Url) + f.lastPath = parsed.Path + f.lastQuery = parsed.RawQuery + if rb.Body != nil { + body, _ := io.ReadAll(rb.Body) + _ = json.Unmarshal(body, &f.lastBody) + } + status := f.statusCode + if status == 0 { + status = http.StatusOK + } + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(bytes.NewBufferString(`{"ok":true}`)), + }, nil +} + +func TestGrantOrgMembership(t *testing.T) { + req := &fakeRequest{} + client := &Client{RequestInterface: req, Endpoint: "https://example.org"} + + if err := client.GrantOrgMembership(context.Background(), "Ellrott_Lab", "USER@OHSU.EDU", ""); err != nil { + t.Fatalf("grant membership: %v", err) + } + if req.lastMethod != http.MethodPost { + t.Fatalf("expected POST, got %s", req.lastMethod) + } + if req.lastPath != "/authz/access/user" { + t.Fatalf("expected access user path, got %s", req.lastPath) + } + if req.lastBody["resource_path"] != "/programs/Ellrott_Lab/projects" { + t.Fatalf("expected org projects resource path, got %#v", req.lastBody["resource_path"]) + } + if req.lastBody["username"] != "user@ohsu.edu" { + t.Fatalf("expected normalized username, got %#v", req.lastBody["username"]) + } + if req.lastBody["role_id"] != "org-member" { + t.Fatalf("expected default org-member role, got %#v", req.lastBody["role_id"]) + } +} + +func TestRevokeOrgMembership(t *testing.T) { + req := &fakeRequest{} + client := &Client{RequestInterface: req, Endpoint: "https://example.org"} + + if err := client.RevokeOrgMembership(context.Background(), "cbds", "user@ohsu.edu", "writer"); err != nil { + t.Fatalf("revoke membership: %v", err) + } + if req.lastMethod != http.MethodDelete { + t.Fatalf("expected DELETE, got %s", req.lastMethod) + } + if req.lastPath != "/authz/access/user" { + t.Fatalf("expected access user path, got %s", req.lastPath) + } + if req.lastBody["resource_path"] != "/programs/cbds/projects" { + t.Fatalf("expected org projects resource path, got %#v", req.lastBody["resource_path"]) + } + if req.lastBody["role_id"] != "writer" { + t.Fatalf("expected explicit role, got %#v", req.lastBody["role_id"]) + } +} + +func TestOrgMembershipRejectsResourcePath(t *testing.T) { + if _, err := NewOrgMembershipRequest("/programs/cbds", "user@ohsu.edu", "owner"); err == nil { + t.Fatal("expected resource path organization to fail") + } +} + +func TestRouteFamilies(t *testing.T) { + tests := []struct { + name string + call func(*Client) error + wantMethod string + wantPath string + wantQuery string + }{ + { + name: "auth mapping", + call: func(c *Client) error { return c.AuthMapping(context.Background(), nil) }, + wantMethod: http.MethodGet, + wantPath: "/authz/mapping", + }, + { + name: "create owned descendant", + call: func(c *Client) error { + return c.CreateOwnedDescendant(context.Background(), CreateOwnedDescendantRequest{Name: "proj", ParentPath: "/programs/cbds/projects"}, nil) + }, + wantMethod: http.MethodPost, + wantPath: "/authz/ownership/descendant", + }, + { + name: "get ownership resource", + call: func(c *Client) error { + return c.GetOwnershipResource(context.Background(), "/programs/cbds/projects/demo", true, true, nil) + }, + wantMethod: http.MethodGet, + wantPath: "/authz/ownership/resource", + wantQuery: "include_admins=true&include_children=true&resource_path=%2Fprograms%2Fcbds%2Fprojects%2Fdemo", + }, + { + name: "grant access user", + call: func(c *Client) error { + return c.GrantAccessUser(context.Background(), AccessUserRequest{ + ResourcePath: "/programs/cbds/projects/demo", + Username: "USER@OHSU.EDU", + RoleID: "writer", + }, nil) + }, + wantMethod: http.MethodPost, + wantPath: "/authz/access/user", + }, + { + name: "revoke access user", + call: func(c *Client) error { + return c.RevokeAccessUser(context.Background(), AccessUserRequest{ + ResourcePath: "/programs/cbds/projects/demo", + Username: "USER@OHSU.EDU", + RoleID: "writer", + }) + }, + wantMethod: http.MethodDelete, + wantPath: "/authz/access/user", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &fakeRequest{} + client := &Client{RequestInterface: req, Endpoint: "https://example.org"} + if err := tt.call(client); err != nil { + t.Fatalf("call failed: %v", err) + } + if req.lastMethod != tt.wantMethod { + t.Fatalf("expected method %s, got %s", tt.wantMethod, req.lastMethod) + } + if req.lastPath != tt.wantPath { + t.Fatalf("expected path %s, got %s", tt.wantPath, req.lastPath) + } + if req.lastQuery != tt.wantQuery { + t.Fatalf("expected query %s, got %s", tt.wantQuery, req.lastQuery) + } + }) + } +} + +func TestAccessUserNormalizesRequest(t *testing.T) { + req := &fakeRequest{} + client := &Client{RequestInterface: req, Endpoint: "https://example.org"} + + if err := client.GrantAccessUser(context.Background(), AccessUserRequest{ + ResourcePath: " /programs/cbds/projects/demo ", + Username: " USER@OHSU.EDU ", + RoleID: " writer ", + }, nil); err != nil { + t.Fatalf("grant access user: %v", err) + } + + if req.lastBody["resource_path"] != "/programs/cbds/projects/demo" { + t.Fatalf("expected normalized resource path, got %#v", req.lastBody["resource_path"]) + } + if req.lastBody["username"] != "user@ohsu.edu" { + t.Fatalf("expected normalized username, got %#v", req.lastBody["username"]) + } + if req.lastBody["role_id"] != "writer" { + t.Fatalf("expected normalized role id, got %#v", req.lastBody["role_id"]) + } +} diff --git a/cmd/arborist.go b/cmd/arborist.go new file mode 100644 index 0000000..8918817 --- /dev/null +++ b/cmd/arborist.go @@ -0,0 +1,290 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/calypr/calypr-cli/arborist" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" + "github.com/spf13/cobra" +) + +type arboristCommandOptions struct { + jsonOutput bool +} + +var arboristOpts arboristCommandOptions + +var arboristCmd = &cobra.Command{ + Use: "permissions", + Aliases: []string{"arborist"}, + Short: "Manage permissions, ownership, and organization access", + Long: "Permissions and ownership power tools. These commands use the Arborist routes exposed through " + + "the configured Gen3 profile.", + RunE: groupHelpOrUnknownError, +} + +func init() { + arboristCmd.PersistentFlags().BoolVar(&arboristOpts.jsonOutput, "json", false, "Output raw JSON responses") + RootCmd.AddCommand(arboristCmd) + + registerArboristAuthCommands() + registerArboristOwnershipCommands() + registerArboristAccessCommands() + registerArboristOrgMembershipCommands() +} + +func getArboristClient() (arborist.ClientInterface, func(), error) { + if strings.TrimSpace(profile) == "" { + return nil, nil, fmt.Errorf("profile is required; use --profile") + } + logger, logCloser := logs.New(profile) + g3i, err := g3client.NewGen3Interface(profile, logger) + if err != nil { + logCloser() + return nil, nil, fmt.Errorf("access Gen3: %w", err) + } + return arborist.NewClient(g3i, g3i.Credentials().Current()), logCloser, nil +} + +func runArborist(cmd *cobra.Command, action func(context.Context, arborist.ClientInterface) (any, string, error)) error { + client, closer, err := getArboristClient() + if err != nil { + return err + } + defer closer() + + result, message, err := action(cmd.Context(), client) + if err != nil { + return err + } + if arboristOpts.jsonOutput { + if result == nil { + result = map[string]string{"message": message} + } + return writeJSON(cmd, result) + } + if strings.TrimSpace(message) != "" { + fmt.Fprintln(cmd.OutOrStdout(), message) + return nil + } + return writeJSON(cmd, result) +} + +func writeJSON(cmd *cobra.Command, value any) error { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} + +func validEmailArg(value string) (string, error) { + value = strings.ToLower(strings.TrimSpace(value)) + if !emailRegex.MatchString(value) { + return "", fmt.Errorf("invalid email address %q", value) + } + return value, nil +} + +func groupHelpOrUnknownError(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + cmd.SilenceUsage = true + return fmt.Errorf("unknown command %q for %q", strings.Join(args, " "), cmd.CommandPath()) +} + +func registerArboristAuthCommands() { + authCmd := &cobra.Command{Use: "auth", Short: "Inspect current authorization mapping", RunE: groupHelpOrUnknownError} + authCmd.AddCommand(&cobra.Command{ + Use: "mapping", + Short: "Get auth mapping for the current token", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runArborist(cmd, func(ctx context.Context, client arborist.ClientInterface) (any, string, error) { + var out any + err := client.AuthMapping(ctx, &out) + return out, "", err + }) + }, + }) + arboristCmd.AddCommand(authCmd) +} + +func registerArboristOwnershipCommands() { + ownershipCmd := &cobra.Command{Use: "ownership", Short: "Manage ownership-backed access state", RunE: groupHelpOrUnknownError} + var parentPath, childName, templateName, description string + createDescendantCmd := &cobra.Command{ + Use: "create-descendant", + Short: "Create a missing child resource and owner grants", + RunE: func(cmd *cobra.Command, args []string) error { + if parentPath == "" || childName == "" { + return fmt.Errorf("--parent and --name are required") + } + return runArborist(cmd, func(ctx context.Context, client arborist.ClientInterface) (any, string, error) { + var out any + err := client.CreateOwnedDescendant(ctx, arborist.CreateOwnedDescendantRequest{ + ParentPath: parentPath, + Name: childName, + Template: templateName, + Description: description, + }, &out) + return out, fmt.Sprintf("Created owned descendant %s under %s", childName, parentPath), err + }) + }, + } + createDescendantCmd.Flags().StringVar(&parentPath, "parent", "", "Parent resource path") + createDescendantCmd.Flags().StringVar(&childName, "name", "", "Child resource name") + createDescendantCmd.Flags().StringVar(&templateName, "template", "", "Ownership template") + createDescendantCmd.Flags().StringVar(&description, "description", "", "Resource description") + ownershipCmd.AddCommand(createDescendantCmd) + + ownershipCmd.AddCommand(ownerMutationCmd("add-owner", "Add an owner", true)) + ownershipCmd.AddCommand(ownerMutationCmd("rm-owner", "Remove an owner", false)) + ownershipCmd.AddCommand(ownershipReadCmd()) + arboristCmd.AddCommand(ownershipCmd) +} + +func registerArboristAccessCommands() { + accessCmd := &cobra.Command{ + Use: "access", + Short: "Manage direct non-owner access grants", + RunE: groupHelpOrUnknownError, + } + accessCmd.AddCommand(accessUserMutationCmd("grant-user", "Grant direct user access on a resource", true)) + accessCmd.AddCommand(accessUserMutationCmd("revoke-user", "Revoke direct user access from a resource", false)) + arboristCmd.AddCommand(accessCmd) +} + +func registerArboristOrgMembershipCommands() { + orgCmd := &cobra.Command{ + Use: "org-membership", + Short: "Manage organization membership grants", + Long: "Convenience wrapper around direct access grants. The default role is org-member, which grants " + + "project creation under the organization without granting ownership over existing projects.", + RunE: groupHelpOrUnknownError, + } + orgCmd.AddCommand(orgMembershipMutationCmd("add [email] [organization]", "Grant organization membership", true)) + orgCmd.AddCommand(orgMembershipMutationCmd("rm [email] [organization]", "Remove organization membership", false)) + arboristCmd.AddCommand(orgCmd) +} + +func ownerMutationCmd(use string, short string, add bool) *cobra.Command { + var resourcePath, username string + cmd := &cobra.Command{ + Use: use, + Short: short, + RunE: func(cmd *cobra.Command, args []string) error { + if resourcePath == "" || username == "" { + return fmt.Errorf("--resource and --user are required") + } + username, err := validEmailArg(username) + if err != nil { + return err + } + return runArborist(cmd, func(ctx context.Context, client arborist.ClientInterface) (any, string, error) { + req := arborist.OwnerMutationRequest{ResourcePath: resourcePath, Username: username} + if add { + var out any + err := client.AddOwner(ctx, req, &out) + return out, fmt.Sprintf("Added owner %s on %s", username, resourcePath), err + } + return nil, fmt.Sprintf("Removed owner %s from %s", username, resourcePath), client.RemoveOwner(ctx, req) + }) + }, + } + cmd.Flags().StringVar(&resourcePath, "resource", "", "Owned resource path") + cmd.Flags().StringVar(&username, "user", "", "Username") + return cmd +} + +func ownershipReadCmd() *cobra.Command { + var resourcePath string + var includeChildren bool + var includeAdmins bool + cmd := &cobra.Command{ + Use: "get-resource", + Short: "Read ownership and direct-access state for a resource", + RunE: func(cmd *cobra.Command, args []string) error { + if resourcePath == "" { + return fmt.Errorf("--resource is required") + } + return runArborist(cmd, func(ctx context.Context, client arborist.ClientInterface) (any, string, error) { + var out any + err := client.GetOwnershipResource(ctx, resourcePath, includeChildren, includeAdmins, &out) + return out, "", err + }) + }, + } + cmd.Flags().StringVar(&resourcePath, "resource", "", "Resource path to inspect") + cmd.Flags().BoolVar(&includeChildren, "include-children", false, "Include descendant resources in the ownership view") + cmd.Flags().BoolVar(&includeAdmins, "include-admins", false, "Include protected admin rows in the ownership view") + return cmd +} + +func accessUserMutationCmd(use string, short string, grant bool) *cobra.Command { + var resourcePath, username, roleID string + cmd := &cobra.Command{ + Use: use, + Short: short, + RunE: func(cmd *cobra.Command, args []string) error { + if resourcePath == "" || username == "" || roleID == "" { + return fmt.Errorf("--resource, --user, and --role are required") + } + username, err := validEmailArg(username) + if err != nil { + return err + } + return runArborist(cmd, func(ctx context.Context, client arborist.ClientInterface) (any, string, error) { + req := arborist.AccessUserRequest{ResourcePath: resourcePath, Username: username, RoleID: roleID} + if grant { + var out any + err := client.GrantAccessUser(ctx, req, &out) + return out, fmt.Sprintf("Granted direct access role %s to %s on %s", roleID, username, resourcePath), err + } + return nil, fmt.Sprintf("Revoked direct access role %s from %s on %s", roleID, username, resourcePath), client.RevokeAccessUser(ctx, req) + }) + }, + } + cmd.Flags().StringVar(&resourcePath, "resource", "", "Resource path") + cmd.Flags().StringVar(&username, "user", "", "Username") + cmd.Flags().StringVar(&roleID, "role", "", "Role ID") + return cmd +} + +func orgMembershipMutationCmd(use string, short string, grant bool) *cobra.Command { + var roleID string + cmd := &cobra.Command{ + Use: use, + Short: short, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + username, err := validEmailArg(args[0]) + if err != nil { + return err + } + organization := strings.TrimSpace(args[1]) + return runArborist(cmd, func(ctx context.Context, client arborist.ClientInterface) (any, string, error) { + role := roleIDOrDefault(roleID) + if grant { + err := client.GrantOrgMembership(ctx, organization, username, roleID) + return nil, fmt.Sprintf("Granted %s role %s on /programs/%s", username, role, organization), err + } + err := client.RevokeOrgMembership(ctx, organization, username, roleID) + return nil, fmt.Sprintf("Removed %s role %s from /programs/%s", username, role, organization), err + }) + }, + } + cmd.Flags().StringVar(&roleID, "role", arborist.DefaultOrgMemberRole, "Arborist role to grant on the organization projects container") + return cmd +} + +func roleIDOrDefault(roleID string) string { + roleID = strings.TrimSpace(roleID) + if roleID == "" { + return arborist.DefaultOrgMemberRole + } + return roleID +} diff --git a/cmd/arborist_test.go b/cmd/arborist_test.go new file mode 100644 index 0000000..1ee5df5 --- /dev/null +++ b/cmd/arborist_test.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestPermissionsCommandRegistersOwnershipAndAccessSubcommands(t *testing.T) { + arboristCmd := findSubcommand(t, RootCmd, "permissions") + ownershipCmd := findSubcommand(t, arboristCmd, "ownership") + accessCmd := findSubcommand(t, arboristCmd, "access") + findSubcommand(t, arboristCmd, "auth") + findSubcommand(t, arboristCmd, "org-membership") + + getResourceCmd := findSubcommand(t, ownershipCmd, "get-resource") + if getResourceCmd.Flags().Lookup("resource") == nil { + t.Fatal("ownership get-resource missing --resource flag") + } + if getResourceCmd.Flags().Lookup("include-children") == nil { + t.Fatal("ownership get-resource missing --include-children flag") + } + if getResourceCmd.Flags().Lookup("include-admins") == nil { + t.Fatal("ownership get-resource missing --include-admins flag") + } + + for _, sub := range []string{"grant-user", "revoke-user"} { + accessUserCmd := findSubcommand(t, accessCmd, sub) + if accessUserCmd.Flags().Lookup("resource") == nil { + t.Fatalf("%s missing --resource flag", sub) + } + if accessUserCmd.Flags().Lookup("user") == nil { + t.Fatalf("%s missing --user flag", sub) + } + if accessUserCmd.Flags().Lookup("role") == nil { + t.Fatalf("%s missing --role flag", sub) + } + } + + for _, removed := range []string{"policy", "resource", "role", "user"} { + if hasSubcommand(arboristCmd, removed) { + t.Fatalf("unexpected legacy subcommand %q still registered", removed) + } + } +} + +func TestPermissionsRejectsUnknownLegacySubcommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + RootCmd.SetOut(&stdout) + RootCmd.SetErr(&stderr) + RootCmd.SetArgs([]string{"permissions", "policy", "ownership", "--profile", "dev"}) + + _, err := RootCmd.ExecuteC() + if err == nil { + t.Fatal("expected invalid permissions subcommand to fail") + } + if !strings.Contains(err.Error(), "unknown command") && !strings.Contains(err.Error(), "accepts 0 arg(s)") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPermissionsLegacyAliasStillWorks(t *testing.T) { + cmd, _, err := RootCmd.Find([]string{"arborist", "auth", "mapping"}) + if err != nil { + t.Fatalf("legacy alias lookup failed: %v", err) + } + if cmd == nil || cmd.CommandPath() != "calypr-cli permissions auth mapping" { + t.Fatalf("expected arborist alias to resolve to permissions auth mapping, got %v", cmd) + } +} + +func findSubcommand(t *testing.T, parent *cobra.Command, use string) *cobra.Command { + t.Helper() + for _, child := range parent.Commands() { + if child.Use == use { + return child + } + } + t.Fatalf("subcommand %q not found", use) + return nil +} + +func hasSubcommand(parent *cobra.Command, use string) bool { + for _, child := range parent.Commands() { + if child.Use == use { + return true + } + } + return false +} diff --git a/cmd/auth.go b/cmd/auth.go index c9de759..aaa146b 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -8,8 +8,8 @@ import ( "sort" "strings" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" "github.com/spf13/cobra" ) @@ -23,9 +23,9 @@ func init() { Use: "auth", Short: "Return resource access privileges from profile", Long: `Gets resource access privileges for specified profile.`, - Example: `./data-client auth --profile= -./data-client auth --profile= --all -./data-client auth --profile= --json`, + Example: `./calypr-cli auth --profile= +./calypr-cli auth --profile= --all +./calypr-cli auth --profile= --json`, RunE: func(cmd *cobra.Command, args []string) error { // don't initialize transmission logs for non-uploading related commands diff --git a/cmd/calypr-cli/main.go b/cmd/calypr-cli/main.go new file mode 100644 index 0000000..c8d9b14 --- /dev/null +++ b/cmd/calypr-cli/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/calypr/calypr-cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/cmd/collaborator.go b/cmd/collaborator.go index 545affe..75dc479 100644 --- a/cmd/collaborator.go +++ b/cmd/collaborator.go @@ -7,9 +7,9 @@ import ( "regexp" "strings" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" - "github.com/calypr/data-client/requestor" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" + "github.com/calypr/calypr-cli/requestor" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) diff --git a/cmd/configure.go b/cmd/configure.go index 0b2165f..128ad8d 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -4,9 +4,9 @@ import ( "context" "fmt" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" "github.com/spf13/cobra" ) @@ -34,7 +34,7 @@ func init() { Short: "Add or modify a configuration profile to your config file", Long: `Configuration file located at ~/.gen3/gen3_client_config.ini If a field is left empty, the existing value (if it exists) will remain unchanged`, - Example: `./data-client configure --profile= --cred= --apiendpoint=https://data.mycommons.org`, + Example: `./calypr-cli configure --profile= --cred= --apiendpoint=https://data.mycommons.org`, Run: func(cmd *cobra.Command, args []string) { // don't initialize transmission logs for non-uploading related commands cred := &conf.Credential{ diff --git a/cmd/configure_test.go b/cmd/configure_test.go index a8ce1dd..d101609 100644 --- a/cmd/configure_test.go +++ b/cmd/configure_test.go @@ -3,7 +3,7 @@ package cmd import ( "testing" - "github.com/calypr/data-client/conf" + "github.com/calypr/calypr-cli/conf" ) func TestMergeImportedCredentialPreservesExplicitAPIEndpoint(t *testing.T) { diff --git a/cmd/download-multiple.go b/cmd/download-multiple.go index bd17e80..b1ff3ed 100644 --- a/cmd/download-multiple.go +++ b/cmd/download-multiple.go @@ -7,8 +7,8 @@ import ( "log" "os" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" sydownload "github.com/calypr/syfon/client/transfer/download" "github.com/spf13/cobra" ) @@ -20,10 +20,11 @@ func init() { var profile string var downloadMultipleCmd = &cobra.Command{ + Hidden: true, Use: "download-multiple", Short: "Download multiple of files from a specified manifest", Long: `Get presigned URLs for multiple of files specified in a manifest file and then download all of them.`, - Example: `./data-client download-multiple --profile --manifest --download-path `, + Example: `./calypr-cli download-multiple --profile --manifest --download-path `, Run: func(cmd *cobra.Command, args []string) { logger, logCloser := logs.New(profile, logs.WithConsole(), logs.WithFailedLog(), logs.WithScoreboard(), logs.WithSucceededLog()) defer logCloser() diff --git a/cmd/download-single.go b/cmd/download-single.go index 5e1227c..80003f1 100644 --- a/cmd/download-single.go +++ b/cmd/download-single.go @@ -2,48 +2,71 @@ package cmd import ( "context" - "log" + "fmt" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" sydownload "github.com/calypr/syfon/client/transfer/download" "github.com/spf13/cobra" ) func init() { var guid string + var manifestPath string var downloadPath string - var profile string - - var downloadSingleCmd = &cobra.Command{ - Use: "download-single", - Short: "Download a single file from a GUID", - Long: `Gets a presigned URL for a file from a GUID and then downloads the specified file.`, - Example: `./data-client download-single --profile= --guid=206dfaa6-bcf1-4bc9-b2d0-77179f0f48fc`, - Run: func(cmd *cobra.Command, args []string) { + var numParallel int + + var downloadCmd = &cobra.Command{ + Use: "download", + Aliases: []string{"download-single"}, + Short: "Download file(s) from object storage.", + Long: `Downloads files through the Syfon-backed download surface. + +Use --guid for a single file or --manifest for multiple files.`, + Example: "./calypr-cli download --profile= --guid=206dfaa6-bcf1-4bc9-b2d0-77179f0f48fc\n" + + "./calypr-cli download --profile= --manifest= --download-path=", + RunE: func(cmd *cobra.Command, args []string) error { + if guid == "" && manifestPath == "" { + return fmt.Errorf("one of --guid or --manifest is required") + } + if guid != "" && manifestPath != "" { + return fmt.Errorf("--guid and --manifest cannot be used together") + } + logger, logCloser := logs.New(profile, logs.WithConsole(), logs.WithFailedLog(), logs.WithSucceededLog(), logs.WithScoreboard()) defer logCloser() g3I, err := g3client.NewGen3Interface(profile, logger) if err != nil { - log.Fatalf("Failed to parse config on profile %s, %v", profile, err) + return fmt.Errorf("failed to parse config on profile %s: %w", profile, err) } syfon := g3I.SyfonClient() if syfon == nil { - logger.Fatal("failed to initialize syfon client") + return fmt.Errorf("failed to initialize syfon client") } - err = sydownload.DownloadSingleWithProgress(context.Background(), syfon.DRS(), syfon.Data(), guid, downloadPath, "original") - if err != nil { - logger.Println(err.Error()) + + if manifestPath != "" { + guids, err := loadManifestGuids(manifestPath) + if err != nil { + return fmt.Errorf("failed to read manifest %s: %w", manifestPath, err) + } + if err := sydownload.DownloadMultiple(context.Background(), syfon.DRS(), syfon.Data(), guids, downloadPath, numParallel, false); err != nil { + return err + } + return nil + } + + if err := sydownload.DownloadSingleWithProgress(context.Background(), syfon.DRS(), syfon.Data(), guid, downloadPath, "original"); err != nil { + return err } + return nil }, } - downloadSingleCmd.Flags().StringVar(&profile, "profile", "", "Specify profile to use") - downloadSingleCmd.MarkFlagRequired("profile") //nolint:errcheck - downloadSingleCmd.Flags().StringVar(&guid, "guid", "", "Specify the guid for the data you would like to work with") - downloadSingleCmd.MarkFlagRequired("guid") //nolint:errcheck - downloadSingleCmd.Flags().StringVar(&downloadPath, "download-path", ".", "The directory in which to store the downloaded files") - RootCmd.AddCommand(downloadSingleCmd) + downloadCmd.Flags().StringVar(&guid, "guid", "", "Specify the guid for the data you would like to work with") + downloadCmd.Flags().StringVar(&manifestPath, "manifest", "", "Manifest JSON for downloading multiple GUIDs") + downloadCmd.Flags().StringVar(&downloadPath, "download-path", ".", "The directory in which to store the downloaded files") + downloadCmd.Flags().IntVar(&numParallel, "numparallel", 1, "Number of downloads to run in parallel") + RootCmd.AddCommand(downloadCmd) } diff --git a/cmd/gecko.go b/cmd/gecko.go new file mode 100644 index 0000000..900062c --- /dev/null +++ b/cmd/gecko.go @@ -0,0 +1,282 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/gecko" + "github.com/calypr/calypr-cli/logs" + "github.com/spf13/cobra" +) + +type geckoCommandOptions struct { + jsonOutput bool +} + +var geckoOpts geckoCommandOptions + +var geckoCmd = &cobra.Command{ + Use: "portal", + Aliases: []string{"gecko"}, + Short: "Manage portal configuration, project metadata, and app cards", + Long: "Portal configuration tools for health checks, typed config inspection, project metadata, and app cards.", + RunE: groupHelpOrUnknownError, +} + +func init() { + geckoCmd.PersistentFlags().BoolVar(&geckoOpts.jsonOutput, "json", false, "Output raw JSON responses") + RootCmd.AddCommand(geckoCmd) + + registerGeckoHealthCommands() + registerGeckoConfigCommands() + registerGeckoProjectCommands() + registerGeckoAppCardCommands() +} + +func getGeckoClient() (gecko.GeckoInterface, func(), error) { + if strings.TrimSpace(profile) == "" { + return nil, nil, fmt.Errorf("profile is required; use --profile") + } + logger, logCloser := logs.New(profile) + g3i, err := g3client.NewGen3Interface(profile, logger) + if err != nil { + logCloser() + return nil, nil, fmt.Errorf("access Gen3: %w", err) + } + return g3i.GeckoClient(), logCloser, nil +} + +func runGecko(cmd *cobra.Command, action func(context.Context, gecko.GeckoInterface) (any, string, error)) error { + client, closer, err := getGeckoClient() + if err != nil { + return err + } + defer closer() + + result, message, err := action(cmd.Context(), client) + if err != nil { + return err + } + if geckoOpts.jsonOutput { + if result == nil { + result = map[string]string{"message": message} + } + return writeGeckoJSON(cmd, result) + } + if strings.TrimSpace(message) != "" { + fmt.Fprintln(cmd.OutOrStdout(), message) + return nil + } + return writeGeckoJSON(cmd, result) +} + +func writeGeckoJSON(cmd *cobra.Command, value any) error { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} + +func readJSONFile[T any](path string) (*T, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("--file is required") + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var out T + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return &out, nil +} + +func parseConfigType(raw string) (gecko.ConfigType, error) { + configType := gecko.ConfigType(strings.TrimSpace(raw)) + for _, known := range gecko.KnownConfigTypes() { + if configType == known { + return configType, nil + } + } + return "", fmt.Errorf("unknown Gecko config type %q", raw) +} + +func registerGeckoHealthCommands() { + geckoCmd.AddCommand(&cobra.Command{ + Use: "health", + Short: "Check portal health", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + health, err := client.HealthCheck(ctx) + if err != nil { + return nil, "", err + } + return map[string]string{"status": health}, "", nil + }) + }, + }) +} + +func registerGeckoConfigCommands() { + configCmd := &cobra.Command{ + Use: "config", + Short: "Inspect portal config types and config IDs", + RunE: groupHelpOrUnknownError, + } + configCmd.AddCommand(&cobra.Command{ + Use: "types", + Short: "List known Gecko config types", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + types, err := client.ListConfigTypes(ctx) + if err != nil { + return nil, "", err + } + return types, "", nil + }) + }, + }) + configCmd.AddCommand(&cobra.Command{ + Use: "list [type]", + Short: "List config IDs for a portal config type", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + configType, err := parseConfigType(args[0]) + if err != nil { + return err + } + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + configs, err := client.ListConfigs(ctx, configType) + if err != nil { + return nil, "", err + } + return configs, "", nil + }) + }, + }) + configCmd.AddCommand(&cobra.Command{ + Use: "get [type] [id]", + Short: "Get portal config by type and ID", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + configType, err := parseConfigType(args[0]) + if err != nil { + return err + } + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + var out any + err := client.GetConfig(ctx, configType, args[1], &out) + return out, "", err + }) + }, + }) + geckoCmd.AddCommand(configCmd) +} + +func registerGeckoProjectCommands() { + projectCmd := &cobra.Command{ + Use: "project", + Short: "Manage portal project metadata", + RunE: groupHelpOrUnknownError, + } + projectCmd.AddCommand(&cobra.Command{ + Use: "get [id]", + Short: "Get portal project metadata", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + cfg, err := gecko.GetProjectConfig(ctx, client, args[0]) + return cfg, "", err + }) + }, + }) + + var projectFile string + putProjectCmd := &cobra.Command{ + Use: "put [id]", + Short: "Create or replace portal project metadata from JSON", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + cfg, err := readJSONFile[gecko.ProjectConfig](projectFile) + if err != nil { + return nil, "", err + } + status, err := gecko.PutProjectConfig(ctx, client, args[0], *cfg) + return status, fmt.Sprintf("Upserted portal project metadata %s", args[0]), err + }) + }, + } + putProjectCmd.Flags().StringVar(&projectFile, "file", "", "JSON file containing Gecko project config") + projectCmd.AddCommand(putProjectCmd) + + projectCmd.AddCommand(&cobra.Command{ + Use: "delete [id]", + Short: "Delete portal project metadata", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + status, err := gecko.DeleteProjectConfig(ctx, client, args[0]) + return status, fmt.Sprintf("Deleted portal project metadata %s", args[0]), err + }) + }, + }) + geckoCmd.AddCommand(projectCmd) +} + +func registerGeckoAppCardCommands() { + appCardCmd := &cobra.Command{ + Use: "appcard", + Short: "Manage portal app cards", + RunE: groupHelpOrUnknownError, + } + appCardCmd.AddCommand(&cobra.Command{ + Use: "get [project-id]", + Short: "Get a portal app card", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + card, err := gecko.GetAppCard(ctx, client, args[0]) + return card, "", err + }) + }, + }) + + var appCardFile string + putAppCardCmd := &cobra.Command{ + Use: "put [project-id]", + Short: "Create or replace a portal app card from JSON", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + card, err := readJSONFile[gecko.AppCard](appCardFile) + if err != nil { + return nil, "", err + } + status, err := gecko.UpsertAppCard(ctx, client, args[0], *card) + return status, fmt.Sprintf("Upserted portal app card %s", args[0]), err + }) + }, + } + putAppCardCmd.Flags().StringVar(&appCardFile, "file", "", "JSON file containing Gecko app card") + appCardCmd.AddCommand(putAppCardCmd) + + appCardCmd.AddCommand(&cobra.Command{ + Use: "delete [project-id]", + Short: "Delete a portal app card", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGecko(cmd, func(ctx context.Context, client gecko.GeckoInterface) (any, string, error) { + status, err := gecko.DeleteAppCard(ctx, client, args[0]) + return status, fmt.Sprintf("Deleted portal app card %s", args[0]), err + }) + }, + }) + geckoCmd.AddCommand(appCardCmd) +} diff --git a/cmd/gecko_test.go b/cmd/gecko_test.go new file mode 100644 index 0000000..d416498 --- /dev/null +++ b/cmd/gecko_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestPortalCommandRegistersExpectedSubcommands(t *testing.T) { + geckoCmd := findSubcommand(t, RootCmd, "portal") + findSubcommand(t, geckoCmd, "health") + + configCmd := findSubcommand(t, geckoCmd, "config") + findSubcommand(t, configCmd, "types") + findSubcommandPrefix(t, configCmd, "list") + findSubcommandPrefix(t, configCmd, "get") + + projectCmd := findSubcommand(t, geckoCmd, "project") + findSubcommandPrefix(t, projectCmd, "get") + putProjectCmd := findSubcommandPrefix(t, projectCmd, "put") + findSubcommandPrefix(t, projectCmd, "delete") + if putProjectCmd.Flags().Lookup("file") == nil { + t.Fatal("gecko project put missing --file flag") + } + + appCardCmd := findSubcommand(t, geckoCmd, "appcard") + findSubcommandPrefix(t, appCardCmd, "get") + putAppCardCmd := findSubcommandPrefix(t, appCardCmd, "put") + findSubcommandPrefix(t, appCardCmd, "delete") + if putAppCardCmd.Flags().Lookup("file") == nil { + t.Fatal("gecko appcard put missing --file flag") + } +} + +func TestPortalLegacyAliasStillWorks(t *testing.T) { + cmd, _, err := RootCmd.Find([]string{"gecko", "health"}) + if err != nil { + t.Fatalf("legacy alias lookup failed: %v", err) + } + if cmd == nil || cmd.CommandPath() != "calypr-cli portal health" { + t.Fatalf("expected gecko alias to resolve to portal health, got %v", cmd) + } +} + +func findSubcommandPrefix(t *testing.T, parent interface{ Commands() []*cobra.Command }, prefix string) *cobra.Command { + t.Helper() + for _, child := range parent.Commands() { + if strings.HasPrefix(child.Use, prefix+" ") || child.Use == prefix { + return child + } + } + t.Fatalf("subcommand prefix %q not found", prefix) + return nil +} diff --git a/cmd/retry-upload.go b/cmd/retry-upload.go index 01d47d3..1382250 100644 --- a/cmd/retry-upload.go +++ b/cmd/retry-upload.go @@ -3,8 +3,8 @@ package cmd import ( "context" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" syclient "github.com/calypr/syfon/client" sycommon "github.com/calypr/syfon/client/common" @@ -15,10 +15,11 @@ func init() { var failedLogPath, profile string var retryUploadCmd = &cobra.Command{ + Hidden: true, Use: "retry-upload", Short: "Retry failed uploads from a failed_log.json", Long: `Re-uploads files listed in a failed log using exponential backoff and progress bars.`, - Example: `./data-client retry-upload --profile=myprofile --failed-log-path=/path/to/failed_log.json`, + Example: `./calypr-cli retry-upload --profile=myprofile --failed-log-path=/path/to/failed_log.json`, Run: func(cmd *cobra.Command, args []string) { Logger, closer := logs.New(profile, logs.WithConsole(), diff --git a/cmd/root.go b/cmd/root.go index 2a676e4..355d88d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,10 +11,11 @@ var backendType string // RootCmd represents the base command when called without any subcommands var RootCmd = &cobra.Command{ - Use: "data-client", - Short: "Use the data-client to interact with a Gen3 Data Commons", - Long: "Gen3 Client for downloading, uploading and submitting data to data commons.\ndata-client version: " + gitversion + ", commit: " + gitcommit, - Version: gitversion, + Use: "calypr-cli", + Short: "Calypr CLI for data transfer, permissions, collaboration, and portal operations", + Long: "Calypr CLI for data transfer, permissions, collaboration, and portal operations.\ncalypr-cli version: " + gitversion + ", commit: " + gitcommit, + Version: gitversion, + SilenceErrors: true, } // Execute adds all child commands to the root command sets flags appropriately diff --git a/cmd/transfer_commands_test.go b/cmd/transfer_commands_test.go new file mode 100644 index 0000000..4883616 --- /dev/null +++ b/cmd/transfer_commands_test.go @@ -0,0 +1,37 @@ +package cmd + +import "testing" + +func TestTransferCommandsExposeCollapsedTopLevelSurface(t *testing.T) { + uploadCmd := findSubcommand(t, RootCmd, "upload") + if uploadCmd.Flags().Lookup("upload-path") == nil { + t.Fatal("upload missing --upload-path flag") + } + for _, flagName := range []string{"file", "file-path", "guid", "manifest", "failed-log-path", "multipart"} { + if uploadCmd.Flags().Lookup(flagName) == nil { + t.Fatalf("upload missing --%s flag", flagName) + } + } + + downloadCmd := findSubcommand(t, RootCmd, "download") + for _, flagName := range []string{"guid", "manifest", "download-path", "numparallel"} { + if downloadCmd.Flags().Lookup(flagName) == nil { + t.Fatalf("download missing --%s flag", flagName) + } + } +} + +func TestLegacyTransferCommandsAreHidden(t *testing.T) { + for _, use := range []string{ + "upload-single", + "upload-multiple", + "upload-multipart", + "retry-upload", + "download-multiple", + } { + cmd := findSubcommand(t, RootCmd, use) + if !cmd.Hidden { + t.Fatalf("legacy command %q should be hidden", use) + } + } +} diff --git a/cmd/upload-multipart.go b/cmd/upload-multipart.go index 47058fa..b29f055 100644 --- a/cmd/upload-multipart.go +++ b/cmd/upload-multipart.go @@ -3,8 +3,8 @@ package cmd import ( "context" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" syclient "github.com/calypr/syfon/client" "github.com/spf13/cobra" ) @@ -18,12 +18,13 @@ func init() { ) var uploadMultipartCmd = &cobra.Command{ - Use: "upload-multipart", - Short: "Upload a single file using managed multipart upload", + Hidden: true, + Use: "upload-multipart", + Short: "Upload a single file using managed multipart upload", Long: `Uploads a file to object storage using managed multipart upload (init -> presigned part URLs -> complete).`, - Example: `./data-client upload-multipart --profile=myprofile --file-path=./large.bam -./data-client upload-multipart --profile=myprofile --file-path=./data.bam --guid=existing-guid`, + Example: `./calypr-cli upload-multipart --profile=myprofile --file-path=./large.bam +./calypr-cli upload-multipart --profile=myprofile --file-path=./data.bam --guid=existing-guid`, Run: func(cmd *cobra.Command, args []string) { // Initialize logger logger, logCloser := logs.New(profile, logs.WithConsole()) diff --git a/cmd/upload-multiple.go b/cmd/upload-multiple.go index b6a26f0..9d77a49 100644 --- a/cmd/upload-multiple.go +++ b/cmd/upload-multiple.go @@ -5,8 +5,8 @@ import ( "context" "fmt" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" syclient "github.com/calypr/syfon/client" "github.com/spf13/cobra" ) @@ -20,17 +20,18 @@ func init() { var includeSubDirName bool uploadMultipleCmd := &cobra.Command{ - Use: "upload-multiple", - Short: "Upload multiple files from a specified manifest (uses pre-existing GUIDs)", + Hidden: true, + Use: "upload-multiple", + Short: "Upload multiple files from a specified manifest (uses pre-existing GUIDs)", Long: `Get presigned URLs for multiple files specified in a manifest file and then upload all of them. This command is for uploading to existing GUIDs (e.g., from a downloaded manifest). -For new uploads (new GUIDs generated), use "data-client upload" instead. +For new uploads (new GUIDs generated), use "calypr-cli upload" instead. Options to run multipart uploads for large files and parallel batch uploading are available.`, - Example: `./data-client upload-multiple --profile= --manifest= --upload-path= --bucket= --batch`, + Example: `./calypr-cli upload-multiple --profile= --manifest= --upload-path= --bucket= --batch`, Run: func(cmd *cobra.Command, args []string) { // Warning message - fmt.Printf("Notice: this command uploads to pre-existing GUIDs from a manifest.\nIf you want to upload new files (new GUIDs generated automatically), use \"./data-client upload\" instead.\n\n") + fmt.Printf("Notice: this command uploads to pre-existing GUIDs from a manifest.\nIf you want to upload new files (new GUIDs generated automatically), use \"./calypr-cli upload\" instead.\n\n") ctx := context.Background() logger, closer := logs.New(profile, logs.WithSucceededLog(), logs.WithFailedLog(), logs.WithScoreboard()) diff --git a/cmd/upload-single.go b/cmd/upload-single.go index 69aa56a..0c561f2 100644 --- a/cmd/upload-single.go +++ b/cmd/upload-single.go @@ -5,8 +5,8 @@ import ( "context" "log" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" syclient "github.com/calypr/syfon/client" "github.com/spf13/cobra" ) @@ -17,10 +17,11 @@ func init() { var bucketName string var uploadSingleCmd = &cobra.Command{ + Hidden: true, Use: "upload-single", Short: "Upload a single file to a GUID", Long: `Gets a presigned URL for which to upload a file associated with a GUID and then uploads the specified file.`, - Example: `./data-client upload-single --profile= --guid=f6923cf3-xxxx-xxxx-xxxx-14ab3f84f9d6 --file=`, + Example: `./calypr-cli upload-single --profile= --guid=f6923cf3-xxxx-xxxx-xxxx-14ab3f84f9d6 --file=`, Run: func(cmd *cobra.Command, args []string) { logger, closer := logs.New(profile, logs.WithSucceededLog(), logs.WithFailedLog(), logs.WithScoreboard(), logs.WithConsole()) defer closer() diff --git a/cmd/upload.go b/cmd/upload.go index 41549f1..81451fc 100644 --- a/cmd/upload.go +++ b/cmd/upload.go @@ -2,11 +2,13 @@ package cmd import ( "context" + "fmt" "log" - "github.com/calypr/data-client/g3client" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/g3client" + "github.com/calypr/calypr-cli/logs" syclient "github.com/calypr/syfon/client" + sycommon "github.com/calypr/syfon/client/common" "github.com/spf13/cobra" ) @@ -14,21 +16,34 @@ func init() { var bucketName string var includeSubDirName bool var uploadPath string + var filePath string + var multipartFilePath string var batch bool var numParallel int var hasMetadata bool + var guid string + var manifestPath string + var failedLogPath string + var multipart bool var uploadCmd = &cobra.Command{ Use: "upload", Short: "Upload file(s) to object storage.", - Long: `Gets a presigned URL for each file and then uploads the specified file(s).`, - Example: "For uploading a single file:\n./data-client upload --profile= --upload-path=\n" + - "For uploading all files within an folder:\n./data-client upload --profile= --upload-path=\n" + - "Can also support regex such as:\n./data-client upload --profile= --upload-path=\n" + - "Or:\n./data-client upload --profile= --upload-path=\n" + - "This command can also upload file metadata using the --metadata flag. If the --metadata flag is passed, the data-client will look for a file called [filename]_metadata.json in the same folder, which contains the metadata to upload.\n" + - "For example, if uploading the file `folder/my_file.bam`, the data-client will look for a metadata file at `folder/my_file_metadata.json`.\n" + - "For the format of the metadata files, see the README.", + Long: `Uploads files through the Syfon-backed upload surface. + +This command also covers existing-GUID uploads, manifest uploads, forced +multipart uploads, and failed-log retries so that the CLI only needs one +top-level upload entrypoint.`, + Example: "./calypr-cli upload --profile= --upload-path=\n" + + "./calypr-cli upload --profile= --upload-path=\n" + + "./calypr-cli upload --profile= --guid= --file=\n" + + "./calypr-cli upload --profile= --manifest= --upload-path=\n" + + "./calypr-cli upload --profile= --file-path= --multipart\n" + + "./calypr-cli upload --profile= --failed-log-path=/path/to/failed_log.json", Run: func(cmd *cobra.Command, args []string) { + resolvedUploadPath, err := resolveUploadInputPath(uploadPath, filePath, multipartFilePath) + if err != nil { + log.Fatalf("Invalid upload flags: %v", err) + } ctx := context.Background() rootLogger, logCloser := logs.New(profile, logs.WithSucceededLog(), logs.WithScoreboard(), logs.WithFailedLog()) @@ -39,29 +54,51 @@ func init() { log.Fatalf("Failed to parse config on profile %s, %v", profile, err) } logger := g3i.Logger() - if hasMetadata { - hasShepherd, err := g3i.FenceClient().CheckForShepherdAPI(ctx) - if err != nil { - logger.Printf("WARNING: Error when checking for Shepherd API: %v", err) - } else { - if !hasShepherd { - logger.Fatalf("ERROR: Metadata upload (`--metadata`) is not supported in the environment you are uploading to. Double check that you are uploading to the right profile.") - } - } - } - syfon := g3i.SyfonClient() if syfon == nil { logger.Fatalf("failed to initialize syfon client") } - err = syclient.Upload(ctx, syfon.Data(), uploadPath, syclient.UploadOptions{ + uploadOptions := syclient.UploadOptions{ Bucket: bucketName, IncludeSubDirName: includeSubDirName, HasMetadata: hasMetadata, Batch: batch, NumParallel: numParallel, - }) + GUID: guid, + ManifestPath: manifestPath, + ShowProgress: guid != "" || manifestPath != "" || multipart, + ForceMultipart: multipart, + } + + switch { + case failedLogPath != "": + if resolvedUploadPath != "" || manifestPath != "" || guid != "" { + logger.Fatalf("--failed-log-path cannot be combined with file, guid, or manifest upload flags") + } + if _, err := sycommon.LoadFailedLog(failedLogPath); err != nil { + logger.Fatalf("Cannot read failed log: %v", err) + } + err = syclient.Upload(ctx, syfon.Data(), "", syclient.UploadOptions{ + RetryFailedLogPath: failedLogPath, + }) + default: + if resolvedUploadPath == "" { + logger.Fatalf("one of --upload-path, --file, --file-path, or --failed-log-path is required") + } + if manifestPath != "" && guid != "" { + logger.Fatalf("--manifest cannot be combined with --guid") + } + if hasMetadata { + hasShepherd, err := g3i.FenceClient().CheckForShepherdAPI(ctx) + if err != nil { + logger.Printf("WARNING: Error when checking for Shepherd API: %v", err) + } else if !hasShepherd { + logger.Fatalf("ERROR: Metadata upload (`--metadata`) is not supported in the environment you are uploading to. Double check that you are uploading to the right profile.") + } + } + err = syclient.Upload(ctx, syfon.Data(), resolvedUploadPath, uploadOptions) + } if err != nil { logger.Error("Upload failed", "error", err) return @@ -73,7 +110,12 @@ func init() { uploadCmd.Flags().StringVar(&profile, "profile", "", "Specify profile to use") uploadCmd.MarkFlagRequired("profile") //nolint:errcheck uploadCmd.Flags().StringVar(&uploadPath, "upload-path", "", "The directory or file in which contains file(s) to be uploaded") - uploadCmd.MarkFlagRequired("upload-path") //nolint:errcheck + uploadCmd.Flags().StringVar(&filePath, "file", "", "Deprecated alias for --upload-path when uploading a single file") + uploadCmd.Flags().StringVar(&multipartFilePath, "file-path", "", "Deprecated alias for --upload-path, used by legacy multipart uploads") + uploadCmd.Flags().StringVar(&guid, "guid", "", "Upload to an existing GUID instead of creating a new one") + uploadCmd.Flags().StringVar(&manifestPath, "manifest", "", "Manifest JSON for uploads to pre-existing GUIDs") + uploadCmd.Flags().StringVar(&failedLogPath, "failed-log-path", "", "Retry uploads listed in a failed_log.json file") + uploadCmd.Flags().BoolVar(&multipart, "multipart", false, "Force managed multipart upload") uploadCmd.Flags().BoolVar(&batch, "batch", false, "Upload in parallel") uploadCmd.Flags().IntVar(&numParallel, "numparallel", 3, "Number of uploads to run in parallel") uploadCmd.Flags().BoolVar(&includeSubDirName, "include-subdirname", true, "Include subdirectory names in file name") @@ -81,3 +123,20 @@ func init() { uploadCmd.Flags().StringVar(&bucketName, "bucket", "", "The bucket to which files will be uploaded. If not provided, defaults to Gen3's configured DATA_UPLOAD_BUCKET.") RootCmd.AddCommand(uploadCmd) } + +func resolveUploadInputPath(primaryPath, filePath, multipartPath string) (string, error) { + var resolved string + for _, candidate := range []string{primaryPath, filePath, multipartPath} { + if candidate == "" { + continue + } + if resolved == "" { + resolved = candidate + continue + } + if resolved != candidate { + return "", fmt.Errorf("conflicting upload path flags: %q and %q", resolved, candidate) + } + } + return resolved, nil +} diff --git a/conf/config.go b/conf/config.go index f198f0c..d123fe2 100644 --- a/conf/config.go +++ b/conf/config.go @@ -1,6 +1,6 @@ package conf -//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -destination=../mocks/mock_configure.go -package=mocks github.com/calypr/data-client/conf ManagerInterface +//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -destination=../mocks/mock_configure.go -package=mocks github.com/calypr/calypr-cli/conf ManagerInterface import ( "encoding/json" @@ -98,7 +98,7 @@ func (man *Manager) Load(profile string) (*Credential, error) { if _, err := os.Stat(configPath); os.IsNotExist(err) { return nil, fmt.Errorf("%w Run configure command (with a profile if desired) to set up account credentials \n"+ - "Example: ./data-client configure --profile= --cred= --apiendpoint=https://data.mycommons.org", ErrProfileNotFound) + "Example: ./calypr-cli configure --profile= --cred= --apiendpoint=https://data.mycommons.org", ErrProfileNotFound) } // If profile not in config file, prompt user to set up config first @@ -109,7 +109,7 @@ func (man *Manager) Load(profile string) (*Credential, error) { } sec, err := cfg.GetSection(profile) if err != nil { - return nil, fmt.Errorf("%w: Need to run \"data-client configure --profile="+profile+" --cred= --apiendpoint=\" first", ErrProfileNotFound) + return nil, fmt.Errorf("%w: Need to run \"calypr-cli configure --profile="+profile+" --cred= --apiendpoint=\" first", ErrProfileNotFound) } profileConfig := &Credential{ diff --git a/conf/validate.go b/conf/validate.go index 6eb94f8..292493e 100644 --- a/conf/validate.go +++ b/conf/validate.go @@ -9,6 +9,8 @@ import ( "github.com/golang-jwt/jwt/v5" ) +const tokenExpiryWarningThreshold = 7 * 24 * time.Hour + func ValidateUrl(apiEndpoint string) (*url.URL, error) { parsedURL, err := url.Parse(apiEndpoint) if err != nil { @@ -58,10 +60,14 @@ func (man *Manager) IsTokenValid(tokenStr string) (bool, error) { } delta := expTime.Sub(now) - // threshold days set to 10 - if delta > 0 && delta.Hours() < float64(10*24) { - daysUntilExpiration := int(delta.Hours() / 24) - if daysUntilExpiration > 0 && man.Logger != nil { + if delta > 0 && delta <= tokenExpiryWarningThreshold && man.Logger != nil { + if delta < 24*time.Hour { + man.Logger.Warn(fmt.Sprintf("Token will expire in less than 24 hours, on %s", expTime.Format(time.RFC3339))) + } else { + daysUntilExpiration := int(delta.Hours() / 24) + if daysUntilExpiration < 1 { + daysUntilExpiration = 1 + } man.Logger.Warn(fmt.Sprintf("Token will expire in %d days, on %s", daysUntilExpiration, expTime.Format(time.RFC3339))) } } diff --git a/conf/validate_test.go b/conf/validate_test.go index 58d230d..71dd5c2 100644 --- a/conf/validate_test.go +++ b/conf/validate_test.go @@ -1,6 +1,9 @@ package conf import ( + "bytes" + "log/slog" + "strings" "testing" "time" @@ -138,3 +141,41 @@ func TestIsCredentialValid(t *testing.T) { }) } } + +func TestIsTokenValid_WarnsWhenTokenExpiresWithinSevenDays(t *testing.T) { + var out bytes.Buffer + logger := slog.New(slog.NewTextHandler(&out, nil)) + man := &Manager{Logger: logger} + now := time.Now().UTC() + + token := createTestToken(now.Add(6*24*time.Hour+2*time.Hour), now.Add(-time.Hour)) + valid, err := man.IsTokenValid(token) + if err != nil { + t.Fatalf("IsTokenValid returned error: %v", err) + } + if !valid { + t.Fatal("expected token to be valid") + } + if !strings.Contains(out.String(), "Token will expire in") { + t.Fatalf("expected expiration warning, got %q", out.String()) + } +} + +func TestIsTokenValid_DoesNotWarnWhenTokenExpiresAfterSevenDays(t *testing.T) { + var out bytes.Buffer + logger := slog.New(slog.NewTextHandler(&out, nil)) + man := &Manager{Logger: logger} + now := time.Now().UTC() + + token := createTestToken(now.Add(8*24*time.Hour), now.Add(-time.Hour)) + valid, err := man.IsTokenValid(token) + if err != nil { + t.Fatalf("IsTokenValid returned error: %v", err) + } + if !valid { + t.Fatal("expected token to be valid") + } + if strings.Contains(out.String(), "Token will expire in") { + t.Fatalf("did not expect expiration warning, got %q", out.String()) + } +} diff --git a/credentials/refresh.go b/credentials/refresh.go index 115d7d9..95652f0 100644 --- a/credentials/refresh.go +++ b/credentials/refresh.go @@ -6,10 +6,10 @@ import ( "log/slog" "strings" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/fence" - "github.com/calypr/data-client/logs" - "github.com/calypr/data-client/request" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/fence" + "github.com/calypr/calypr-cli/logs" + "github.com/calypr/calypr-cli/request" ) // EnsureValidCredential validates a profile credential and refreshes its diff --git a/docs/DEVELOPER_DOCS.md b/docs/DEVELOPER_DOCS.md index 54478a7..ec4a701 100644 --- a/docs/DEVELOPER_DOCS.md +++ b/docs/DEVELOPER_DOCS.md @@ -1,91 +1,60 @@ -# Dev Docs +# Developer Docs -This repo is a heavily updated / refactored version of https://github.com/uc-cdis/cdis-data-client +`calypr-cli` started from the Gen3 data-client lineage, but this repo now owns +its own CLI surface and operator workflows. Treat it as a command-oriented Go +application with a few backend-specific client packages, not as a thin mirror of +the old fork. -The new architecture splits out many of the mega packages into smaller, more digestable pieces. This whole CLI is essentially a Go client library for Gen3's Fence microservice. +## Entry Points -These new packages are: +- `main.go`: root-compatible binary entrypoint used by `go build .` +- `cmd/calypr-cli/main.go`: compatibility wrapper for subdirectory builds +- `cmd/`: Cobra command definitions and CLI orchestration -├── api -│   ├── gen3.go -│   └── types.go -├── client -│   └── client.go -├── common -│   ├── common.go -│   ├── constants.go -│   ├── isHidden_notwindows.go -│   ├── isHidden_windows.go -│   ├── logHelper.go -│   └── types.go -├── conf -│   ├── config.go -│   └── validate.go -├── download -│   ├── batch.go -│   ├── downloader.go -│   ├── file_info.go -│   ├── types.go -│   ├── url_resolution.go -│   └── utils.go -├── logs -│   ├── factory.go -│   ├── logger.go -│   ├── scoreboard.go -│   └── tee_logger.go -├── mocks -│   ├── mock_configure.go -│   ├── mock_functions.go -│   ├── mock_gen3interface.go -│   └── mock_request.go -├── request -│   ├── auth.go -│   ├── builder.go -│   └── request.go -└── upload - ├── batch.go - ├── multipart.go - ├── request.go - ├── retry.go - ├── singleFile.go - ├── types.go - ├── upload.go - └── utils.go +The root command is `calypr-cli`, and most subcommands require a named +`--profile`. +## Package Map -# api +| Path | Responsibility | +| --- | --- | +| `cmd/` | User-facing commands for configure/auth, transfer flows, collaborators, permissions, and portal management | +| `conf/` | Profile loading, persistence, and config validation | +| `credentials/` | Credential refresh helpers | +| `g3client/` | Shared Gen3 client adapter layer, including Syfon-backed surfaces | +| `fence/` | Fence-specific request and response helpers | +| `gecko/` | Gecko-specific client code for portal operations | +| `arborist/` | Arborist-specific client code for permissions operations | -This is the main Client API for talking to fence. Some of the functions that are currently defined in upload/ and download should probablyl be broken out into this library also. +## High-Level Command Ownership -# client +- Transfer commands live in `cmd/` and rely on shared Gen3/Syfon client code. +- `permissions` is the Arborist-backed operator surface. +- `portal` is the Gecko-backed operator surface. +- `collaborators` handles collaboration-request workflows exposed by the target + commons. -This is a thin wrapper client that wraps the API interface to make the API calls easier to use from other packages. +## Build And Test -# common +Local entrypoints: -This contains common constants / utility functions that are used in the repo +```bash +go build . +go build ./cmd/calypr-cli +go test ./... +``` -# conf +Top-level automation lives under `.github/workflows/`. The intended baseline is: -This is the config package for loading / storing the gen3 credential. Note ~/.gen3/.ini file is where credentials / configurations are stored, -but the raw credential is also stored in ~/.gen3/ under whatever you called it. +- `ci.yaml` for general lint and unit test coverage +- `coverage.yaml` for explicit coverage reporting +- `release.yaml` for tagged releases +- reusable image and integration workflows where the repo still depends on the + shared `uc-cdis/.github` automation -# download +## Notes -This is the business logic for all download and download related operations in the depo - -# logs - -This is where the logger is defined - -# mocks - -This contains mocks for testing the data-client - -# request - -This is the lowest level interface for doing requests. It implements some basic retry, and wraps the http round trip with a token if one is provided - -# upload - -This contains the business logic for all upload and upload related operations. +- The default backend flag is `gen3`, but the CLI also carries backend-specific + behavior behind command flags and adapters. +- This repo still contains some compatibility surfaces from older Gen3 client + behavior; when cleaning up, verify call sites before removing them. diff --git a/docs/permissions-cli.md b/docs/permissions-cli.md new file mode 100644 index 0000000..385db42 --- /dev/null +++ b/docs/permissions-cli.md @@ -0,0 +1,147 @@ +# Permissions CLI + +`calypr-cli permissions` is the operator-facing CLI for the Arborist routes +that are actually exposed through the public Gen3 `/authz` surface. + +This is intentionally not a full Arborist admin client. Raw Arborist catalog +routes like user, role, and resource CRUD are not exposed through revproxy, so +`calypr-cli` does not try to support them. + +Use `calypr-cli collaborators` when a user is asking for access they do not +already have. Use `calypr-cli permissions` when an admin needs to inspect or +change the ownership and direct-access state that Arborist exposes publicly. + +## Basic Shape + +All commands use the normal calypr-cli profile: + +```bash +calypr-cli --profile calypr permissions +``` + +Add `--json` when you need raw output for debugging or scripts: + +```bash +calypr-cli --profile calypr permissions --json auth mapping +``` + +## Current Auth Mapping + +Show the current profile user's Arborist mapping: + +```bash +calypr-cli --profile calypr permissions auth mapping +``` + +This command reads the public `GET /authz/mapping` surface for the current +token. It does not support arbitrary username lookups. + +## Organization Membership + +Organization membership is a convenience wrapper around Arborist direct-access +grants on `/programs//projects`. + +```bash +calypr-cli --profile calypr permissions org-membership add user@ohsu.edu Ellrott_Lab +calypr-cli --profile calypr permissions org-membership rm user@ohsu.edu Ellrott_Lab +``` + +The default role is `org-member`. It carries only +`arborist/create-descendant`, which lets the member create projects under the +existing organization without granting ownership or access on existing projects. + +You can specify another role when needed: + +```bash +calypr-cli --profile calypr permissions org-membership add user@ohsu.edu Ellrott_Lab --role org-member +``` + +Do not pass a resource path to `org-membership`. This is valid: + +```bash +Ellrott_Lab +``` + +This is invalid: + +```bash +/programs/Ellrott_Lab +``` + +## Ownership Power Tools + +Create a new organization resource and make the caller its owner: + +```bash +calypr-cli --profile calypr permissions ownership create-descendant \ + --parent /programs \ + --name Ellrott_Lab \ + --template gen3-program +``` + +Create a new project resource under an organization and make the caller its +owner: + +```bash +calypr-cli --profile calypr permissions ownership create-descendant \ + --parent /programs/Ellrott_Lab/projects \ + --name git_drs_test \ + --template gen3-project +``` + +Add or remove owners: + +```bash +calypr-cli --profile calypr permissions ownership add-owner \ + --resource /programs/Ellrott_Lab \ + --user user@ohsu.edu + +calypr-cli --profile calypr permissions ownership rm-owner \ + --resource /programs/Ellrott_Lab \ + --user user@ohsu.edu +``` + +Read the normalized ownership and direct-access state for a resource: + +```bash +calypr-cli --profile calypr permissions ownership get-resource \ + --resource /programs/Ellrott_Lab/projects/git_drs_test \ + --include-admins \ + --include-children +``` + +## Direct Access Power Tools + +Grant or revoke direct non-owner access on an existing resource: + +```bash +calypr-cli --profile calypr permissions access grant-user \ + --resource /programs/Ellrott_Lab/projects/git_drs_test \ + --user user@ohsu.edu \ + --role writer + +calypr-cli --profile calypr permissions access revoke-user \ + --resource /programs/Ellrott_Lab/projects/git_drs_test \ + --user user@ohsu.edu \ + --role writer +``` + +Use these commands for ordinary direct access. Use ownership add/remove for +owner changes. + +## Deliberate Non-Support + +`calypr-cli permissions` does not support raw Arborist catalog/admin routes such +as: + +- user CRUD +- role CRUD +- resource CRUD +- raw policy mutation +- arbitrary-user auth mapping lookup + +Those routes are not part of the supported public Gen3 revproxy surface, so the +CLI does not expose them. + +The legacy backend-oriented command name `calypr-cli arborist` still works as a +compatibility alias, but `permissions` is the supported user-facing name. diff --git a/docs/portal-cli.md b/docs/portal-cli.md new file mode 100644 index 0000000..07c2041 --- /dev/null +++ b/docs/portal-cli.md @@ -0,0 +1,178 @@ +# Portal CLI + +`calypr-cli portal` is the operator-facing CLI for the portal routes exposed +through the public Gen3 `/gecko` surface. + +This is a configuration and health client. It is not a generic Gecko admin +shell for every internal backend route. + +## Basic Shape + +All commands use a normal calypr-cli profile: + +```bash +calypr-cli --profile calypr portal +``` + +Add `--json` when you want raw output for debugging or scripts: + +```bash +calypr-cli --profile calypr portal --json config types +``` + +## Current Support + +`calypr-cli portal` currently supports: + +- service health checks +- listing Gecko config types +- listing config IDs for a config type +- fetching a config by type and ID +- project config get, put, and delete +- app-card get, put, and delete + +The client currently recognizes these Gecko config-type tokens: + +- `explorer` +- `nav` +- `file_summary` +- `apps_page` +- `project` +- `projects` + +`project` config CRUD should use the dedicated `project` commands below. Those +commands target the public `/gecko/projects/...` route shape. + +## Health Check + +Check whether Gecko is reachable through revproxy: + +```bash +calypr-cli --profile calypr portal health +``` + +## Generic Config Inspection + +List the config types Gecko reports: + +```bash +calypr-cli --profile calypr portal config types +``` + +List config IDs for a type: + +```bash +calypr-cli --profile calypr portal config list explorer +calypr-cli --profile calypr portal config list nav +calypr-cli --profile calypr portal config list apps_page +``` + +Fetch a config by type and ID: + +```bash +calypr-cli --profile calypr portal config get explorer default +calypr-cli --profile calypr portal config get nav default +``` + +Use `config` when you need typed config inspection. Use the dedicated commands +below when you are working with project configs or app cards. + +## Project Configs + +Project config commands use an ID in `ORG/PROJECT` form, not a flat project +name. + +This is valid: + +```bash +HTAN_INT/BForePC +``` + +This is invalid: + +```bash +BForePC +``` + +Get a project config: + +```bash +calypr-cli --profile calypr portal project get HTAN_INT/BForePC +``` + +Create or replace a project config from JSON: + +```bash +calypr-cli --profile calypr portal project put HTAN_INT/BForePC --file project.json +``` + +Delete a project config: + +```bash +calypr-cli --profile calypr portal project delete HTAN_INT/BForePC +``` + +Expected JSON shape: + +```json +{ + "title": "BForePC", + "contact_email": "owner@example.org", + "src_repo": "https://github.com/calypr/gecko", + "org_title": "HTAN INT", + "description": "Example project config", + "project_title": "BForePC", + "icon_name": "folder-git-2" +} +``` + +`src_repo` is validated before the request is sent. The client expects a real +GitHub repository and normalizes the value before writing the config. + +## App Cards + +App-card commands use a project ID, usually in `ORG-PROJECT` form. + +Get an app card: + +```bash +calypr-cli --profile calypr portal appcard get HTAN_INT-BForePC +``` + +Create or replace an app card from JSON: + +```bash +calypr-cli --profile calypr portal appcard put HTAN_INT-BForePC --file appcard.json +``` + +Delete an app card: + +```bash +calypr-cli --profile calypr portal appcard delete HTAN_INT-BForePC +``` + +Expected JSON shape: + +```json +{ + "title": "BForePC", + "description": "Open the BForePC workspace", + "icon": "folder-git-2", + "href": "/workspace/BForePC", + "perms": "read" +} +``` + +## Deliberate Non-Support + +`calypr-cli portal` does not try to expose every internal Gecko backend route. +It is currently scoped to the public revproxy-backed Gecko surface that this +client already knows how to model: + +- health +- config type and config inspection +- project config CRUD +- app-card CRUD + +The legacy backend-oriented command name `calypr-cli gecko` still works as a +compatibility alias, but `portal` is the supported user-facing name. diff --git a/fence/client.go b/fence/client.go index 20ae4bc..81770ba 100644 --- a/fence/client.go +++ b/fence/client.go @@ -14,8 +14,8 @@ import ( "log/slog" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/request" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/request" sycommon "github.com/calypr/syfon/client/common" "github.com/hashicorp/go-version" ) @@ -38,7 +38,7 @@ const ( defaultMinShepherdVersion = "2.0.0" ) -//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -destination=../mocks/mock_fence.go -package=mocks github.com/calypr/data-client/fence FenceInterface +//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -destination=../mocks/mock_fence.go -package=mocks github.com/calypr/calypr-cli/fence FenceInterface // FenceInterface defines the interface for Fence client type FenceInterface interface { diff --git a/fence/client_test.go b/fence/client_test.go index 6d12f30..511590e 100644 --- a/fence/client_test.go +++ b/fence/client_test.go @@ -8,9 +8,9 @@ import ( "strings" "testing" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/logs" - "github.com/calypr/data-client/request" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/logs" + "github.com/calypr/calypr-cli/request" ) type mockFenceServer struct{} diff --git a/g3client/client.go b/g3client/client.go index f5c216f..5a8b25a 100644 --- a/g3client/client.go +++ b/g3client/client.go @@ -6,17 +6,18 @@ import ( "fmt" "strings" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/fence" - "github.com/calypr/data-client/logs" - "github.com/calypr/data-client/request" - "github.com/calypr/data-client/requestor" - "github.com/calypr/data-client/sower" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/fence" + "github.com/calypr/calypr-cli/gecko" + "github.com/calypr/calypr-cli/logs" + "github.com/calypr/calypr-cli/request" + "github.com/calypr/calypr-cli/requestor" + "github.com/calypr/calypr-cli/sower" syconfig "github.com/calypr/syfon/client/config" version "github.com/hashicorp/go-version" ) -//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -destination=../mocks/mock_gen3interface.go -package=mocks github.com/calypr/data-client/g3client Gen3Interface +//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -destination=../mocks/mock_gen3interface.go -package=mocks github.com/calypr/calypr-cli/g3client Gen3Interface type Gen3Interface interface { request.RequestInterface @@ -24,6 +25,7 @@ type Gen3Interface interface { Credentials() syconfig.CredentialManager SyfonClient() SyfonClientInterface FenceClient() fence.FenceInterface + GeckoClient() gecko.GeckoInterface RequestorClient() requestor.RequestorInterface SowerClient() sower.SowerInterface } @@ -70,6 +72,9 @@ func (g *Gen3Client) initializeClients() { if shouldInit(SowerClient) { g.sower = sower.NewSowerClient(g.RequestInterface, g.credential.APIEndpoint) } + if shouldInit(GeckoClient) { + g.gecko = gecko.NewClient(g.RequestInterface, g.credential) + } if shouldInit(RequestorClient) { g.requestor = requestor.NewRequestorClient(g.RequestInterface, g.credential) } @@ -80,6 +85,7 @@ type Gen3Client struct { fence fence.FenceInterface syfon SyfonClientInterface sower sower.SowerInterface + gecko gecko.GeckoInterface requestor requestor.RequestorInterface config conf.ManagerInterface request.RequestInterface @@ -97,6 +103,7 @@ const ( FenceClient ClientType = "fence" SyfonClient ClientType = "syfon" SowerClient ClientType = "sower" + GeckoClient ClientType = "gecko" RequestorClient ClientType = "requestor" ) @@ -123,6 +130,10 @@ func (g *Gen3Client) RequestorClient() requestor.RequestorInterface { return g.requestor } +func (g *Gen3Client) GeckoClient() gecko.GeckoInterface { + return g.gecko +} + func (g *Gen3Client) SowerClient() sower.SowerInterface { return g.sower } diff --git a/g3client/client_test.go b/g3client/client_test.go index 4113a67..f1ce5ca 100644 --- a/g3client/client_test.go +++ b/g3client/client_test.go @@ -4,9 +4,9 @@ import ( "net/http" "testing" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/logs" - "github.com/calypr/data-client/request" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/logs" + "github.com/calypr/calypr-cli/request" "github.com/hashicorp/go-retryablehttp" ) @@ -44,6 +44,9 @@ func TestGen3ClientInitializesSyfonClient(t *testing.T) { if client.SowerClient() != nil { t.Fatal("did not expect sower client to be initialized when it was not requested") } + if client.GeckoClient() != nil { + t.Fatal("did not expect gecko client to be initialized when it was not requested") + } if client.RequestorClient() != nil { t.Fatal("did not expect requestor client to be initialized when it was not requested") } @@ -54,3 +57,34 @@ func TestGen3ClientInitializesSyfonClient(t *testing.T) { t.Fatal("expected credentials manager to return original credential") } } + +func TestGen3ClientInitializesGeckoClient(t *testing.T) { + logger, cleanup := logs.New("g3client-test", logs.WithNoConsole(), logs.WithNoMessageFile()) + t.Cleanup(cleanup) + + req := &request.Request{ + RetryClient: &retryablehttp.Client{HTTPClient: &http.Client{}}, + } + cred := &conf.Credential{ + Profile: "test", + APIEndpoint: "https://example.org", + } + client := &Gen3Client{ + RequestInterface: req, + credential: cred, + logger: logger, + requestedClients: []ClientType{GeckoClient}, + } + + client.initializeClients() + + if client.GeckoClient() == nil { + t.Fatal("expected gecko client to be initialized") + } + if client.syfon != nil { + t.Fatal("did not expect syfon client to be initialized when it was not requested") + } + if client.fence != nil { + t.Fatal("did not expect fence client to be initialized when it was not requested") + } +} diff --git a/g3client/syfon_adapter.go b/g3client/syfon_adapter.go index 697d967..cc5abad 100644 --- a/g3client/syfon_adapter.go +++ b/g3client/syfon_adapter.go @@ -9,9 +9,9 @@ import ( "net/http" "strings" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/logs" - "github.com/calypr/data-client/request" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/logs" + "github.com/calypr/calypr-cli/request" "github.com/calypr/syfon/apigen/client/bucketapi" "github.com/calypr/syfon/apigen/client/drs" "github.com/calypr/syfon/apigen/client/internalapi" diff --git a/gecko/client.go b/gecko/client.go new file mode 100644 index 0000000..b53c209 --- /dev/null +++ b/gecko/client.go @@ -0,0 +1,208 @@ +package gecko + +import ( + "context" + "net/http" + "strings" + + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/githubutil" + "github.com/calypr/calypr-cli/request" +) + +var validateProjectRepoURL = githubutil.ValidateRepositoryURL + +type GeckoInterface interface { + ListConfigTypes(ctx context.Context) ([]ConfigType, error) + ListConfigs(ctx context.Context, configType ConfigType) ([]string, error) + GetConfig(ctx context.Context, configType ConfigType, configID string, out any) error + PutConfig(ctx context.Context, configType ConfigType, configID string, cfg any) (*StatusResponse, error) + DeleteConfig(ctx context.Context, configType ConfigType, configID string) (*StatusResponse, error) + GetAppCard(ctx context.Context, projectID string) (*AppCard, error) + UpsertAppCard(ctx context.Context, projectID string, card AppCard) (*StatusResponse, error) + DeleteAppCard(ctx context.Context, projectID string) (*StatusResponse, error) + HealthCheck(ctx context.Context) (string, error) +} + +type Client struct { + request.RequestInterface + Endpoint string +} + +func NewClient(req request.RequestInterface, creds *conf.Credential) GeckoInterface { + return &Client{ + RequestInterface: req, + Endpoint: creds.APIEndpoint, + } +} + +func (c *Client) ListConfigTypes(ctx context.Context) ([]ConfigType, error) { + var configTypes []ConfigType + if err := request.DoJSON( + ctx, + c.RequestInterface, + c.New(http.MethodGet, request.JoinURL(c.Endpoint, "gecko", "types")), + &configTypes, + request.WithAction("list config types"), + request.WithErrorEnvelope(&ErrorResponse{}), + ); err != nil { + return nil, err + } + return configTypes, nil +} + +func (c *Client) ListConfigs(ctx context.Context, configType ConfigType) ([]string, error) { + var configs []string + if err := request.DoJSON( + ctx, + c.RequestInterface, + c.New(http.MethodGet, c.configListURL(configType)), + &configs, + request.WithAction("list configs"), + request.WithErrorEnvelope(&ErrorResponse{}), + ); err != nil { + return nil, err + } + return configs, nil +} + +func (c *Client) GetConfig(ctx context.Context, configType ConfigType, configID string, out any) error { + return request.DoJSON( + ctx, + c.RequestInterface, + c.New(http.MethodGet, c.configItemURL(configType, configID)), + out, + request.WithAction("get config"), + request.WithErrorEnvelope(&ErrorResponse{}), + ) +} + +func (c *Client) PutConfig(ctx context.Context, configType ConfigType, configID string, cfg any) (*StatusResponse, error) { + rb, err := request.NewJSON(c.RequestInterface, http.MethodPut, c.configItemURL(configType, configID), cfg) + if err != nil { + return nil, err + } + + var status StatusResponse + if err := request.DoJSON( + ctx, + c.RequestInterface, + rb, + &status, + request.WithAction("put config"), + request.WithErrorEnvelope(&ErrorResponse{}), + ); err != nil { + return nil, err + } + return &status, nil +} + +func (c *Client) DeleteConfig(ctx context.Context, configType ConfigType, configID string) (*StatusResponse, error) { + var status StatusResponse + if err := request.DoJSON( + ctx, + c.RequestInterface, + c.New(http.MethodDelete, c.configItemURL(configType, configID)), + &status, + request.WithAction("delete config"), + request.WithErrorEnvelope(&ErrorResponse{}), + ); err != nil { + return nil, err + } + return &status, nil +} + +func (c *Client) GetAppCard(ctx context.Context, projectID string) (*AppCard, error) { + var card AppCard + if err := request.DoJSON( + ctx, + c.RequestInterface, + c.New(http.MethodGet, request.JoinURL(c.Endpoint, "gecko", string(ConfigTypeAppsPage), "appcard", projectID)), + &card, + request.WithAction("get app card"), + request.WithErrorEnvelope(&ErrorResponse{}), + ); err != nil { + return nil, err + } + return &card, nil +} + +func (c *Client) UpsertAppCard(ctx context.Context, projectID string, card AppCard) (*StatusResponse, error) { + rb, err := request.NewJSON( + c.RequestInterface, + http.MethodPost, + request.JoinURL(c.Endpoint, "gecko", string(ConfigTypeAppsPage), "appcard", projectID), + card, + ) + if err != nil { + return nil, err + } + + var status StatusResponse + if err := request.DoJSON( + ctx, + c.RequestInterface, + rb, + &status, + request.WithAction("upsert app card"), + request.WithErrorEnvelope(&ErrorResponse{}), + ); err != nil { + return nil, err + } + return &status, nil +} + +func (c *Client) DeleteAppCard(ctx context.Context, projectID string) (*StatusResponse, error) { + var status StatusResponse + if err := request.DoJSON( + ctx, + c.RequestInterface, + c.New(http.MethodDelete, request.JoinURL(c.Endpoint, "gecko", string(ConfigTypeAppsPage), "appcard", projectID)), + &status, + request.WithAction("delete app card"), + request.WithErrorEnvelope(&ErrorResponse{}), + ); err != nil { + return nil, err + } + return &status, nil +} + +func (c *Client) HealthCheck(ctx context.Context) (string, error) { + var status string + if err := request.DoJSON( + ctx, + c.RequestInterface, + c.New(http.MethodGet, request.JoinURL(c.Endpoint, "gecko", "health")), + &status, + request.WithAction("health check"), + request.WithErrorEnvelope(&ErrorResponse{}), + ); err != nil { + return "", err + } + return status, nil +} + +func (c *Client) configListURL(configType ConfigType) string { + if configType == ConfigTypeProjects { + return request.JoinURL(c.Endpoint, "gecko", "projects", "list") + } + return request.JoinURL(c.Endpoint, "gecko", string(configType), "list") +} + +func (c *Client) configItemURL(configType ConfigType, configID string) string { + if configType == ConfigTypeProjects { + return request.JoinURL(c.Endpoint, append([]string{"gecko", "projects"}, splitConfigID(configID)...)...) + } + return request.JoinURL(c.Endpoint, "gecko", string(configType), configID) +} + +func splitConfigID(configID string) []string { + parts := strings.Split(strings.Trim(configID, "/"), "/") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} diff --git a/gecko/client_test.go b/gecko/client_test.go new file mode 100644 index 0000000..cd80e24 --- /dev/null +++ b/gecko/client_test.go @@ -0,0 +1,340 @@ +package gecko + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "testing" + + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/request" + "github.com/hashicorp/go-retryablehttp" +) + +type fakeRequest struct { + doFn func(rb *request.RequestBuilder) (*http.Response, error) +} + +func (f *fakeRequest) New(method, u string) *request.RequestBuilder { + return &request.RequestBuilder{Method: method, Url: u, Headers: map[string]string{}} +} + +func (f *fakeRequest) Do(ctx context.Context, rb *request.RequestBuilder) (*http.Response, error) { + return f.doFn(rb) +} + +func jsonResp(status int, v any) *http.Response { + var body io.ReadCloser = http.NoBody + if v != nil { + buf, _ := json.Marshal(v) + body = io.NopCloser(bytes.NewReader(buf)) + } + return &http.Response{StatusCode: status, Body: body} +} + +func TestGeckoClientExplorerConfigOperations(t *testing.T) { + client := &Client{ + RequestInterface: &fakeRequest{ + doFn: func(rb *request.RequestBuilder) (*http.Response, error) { + u, err := url.Parse(rb.Url) + if err != nil { + return nil, err + } + + switch { + case rb.Method == http.MethodGet && u.Path == "/gecko/explorer/list": + return jsonResp(http.StatusOK, []string{"default", "study-a"}), nil + case rb.Method == http.MethodGet && u.Path == "/gecko/explorer/default": + return jsonResp(http.StatusOK, Config{ + ExplorerConfig: []ConfigItem{{TabTitle: "Cases"}}, + }), nil + case rb.Method == http.MethodPut && u.Path == "/gecko/explorer/default": + var payload Config + if err := json.NewDecoder(rb.Body).Decode(&payload); err != nil { + t.Fatalf("decode put payload: %v", err) + } + if len(payload.ExplorerConfig) != 1 || payload.ExplorerConfig[0].TabTitle != "Cases" { + t.Fatalf("unexpected put payload: %+v", payload) + } + return jsonResp(http.StatusOK, StatusResponse{Code: 200, Message: "ACCEPTED"}), nil + case rb.Method == http.MethodDelete && u.Path == "/gecko/explorer/default": + return jsonResp(http.StatusOK, StatusResponse{Code: 200, Message: "DELETED"}), nil + default: + return jsonResp(http.StatusNotFound, ErrorResponse{ + Error: HTTPError{Code: 404, Message: "not found"}, + }), nil + } + }, + }, + Endpoint: "https://example.org", + } + + configs, err := ListExplorerConfigs(context.Background(), client) + if err != nil { + t.Fatalf("ListExplorerConfigs failed: %v", err) + } + if len(configs) != 2 || configs[0] != "default" { + t.Fatalf("unexpected config list: %+v", configs) + } + + cfg, err := GetExplorerConfig(context.Background(), client, "default") + if err != nil { + t.Fatalf("GetExplorerConfig failed: %v", err) + } + if len(cfg.ExplorerConfig) != 1 || cfg.ExplorerConfig[0].TabTitle != "Cases" { + t.Fatalf("unexpected config: %+v", cfg) + } + + putResp, err := PutExplorerConfig(context.Background(), client, "default", *cfg) + if err != nil { + t.Fatalf("PutExplorerConfig failed: %v", err) + } + if putResp.Message != "ACCEPTED" { + t.Fatalf("unexpected put response: %+v", putResp) + } + + deleteResp, err := DeleteExplorerConfig(context.Background(), client, "default") + if err != nil { + t.Fatalf("DeleteExplorerConfig failed: %v", err) + } + if deleteResp.Message != "DELETED" { + t.Fatalf("unexpected delete response: %+v", deleteResp) + } +} + +func TestGeckoClientSupportsAllOfficialConfigTypes(t *testing.T) { + client := &Client{ + RequestInterface: &fakeRequest{ + doFn: func(rb *request.RequestBuilder) (*http.Response, error) { + u, err := url.Parse(rb.Url) + if err != nil { + return nil, err + } + switch { + case rb.Method == http.MethodGet && u.Path == "/gecko/types": + return jsonResp(http.StatusOK, []string{"explorer", "nav", "file_summary", "apps_page", "project", "projects"}), nil + case rb.Method == http.MethodGet && u.Path == "/gecko/nav/default": + return jsonResp(http.StatusOK, NavPageLayoutProps{ + HeaderMetadata: HeaderMetadata{Title: "Nav"}, + }), nil + case rb.Method == http.MethodPut && u.Path == "/gecko/apps_page/default": + return jsonResp(http.StatusOK, StatusResponse{Code: 200, Message: "ACCEPTED"}), nil + case rb.Method == http.MethodDelete && u.Path == "/gecko/file_summary/default": + return jsonResp(http.StatusOK, StatusResponse{Code: 200, Message: "DELETED"}), nil + case rb.Method == http.MethodGet && u.Path == "/gecko/projects/HTAN_INT/BForePC": + return jsonResp(http.StatusOK, ProjectConfig{Title: "Project"}), nil + default: + return jsonResp(http.StatusNotFound, ErrorResponse{ + Error: HTTPError{Code: 404, Message: "not found"}, + }), nil + } + }, + }, + Endpoint: "https://example.org", + } + + types, err := client.ListConfigTypes(context.Background()) + if err != nil { + t.Fatalf("ListConfigTypes failed: %v", err) + } + if len(types) != 6 || types[0] != ConfigTypeExplorer || types[3] != ConfigTypeAppsPage || types[4] != ConfigTypeProject || types[5] != ConfigTypeProjects { + t.Fatalf("unexpected config types: %+v", types) + } + + var nav NavPageLayoutProps + if err := client.GetConfig(context.Background(), ConfigTypeNav, "default", &nav); err != nil { + t.Fatalf("GetConfig(nav) failed: %v", err) + } + if nav.HeaderMetadata.Title != "Nav" { + t.Fatalf("unexpected nav config: %+v", nav) + } + + if _, err := client.PutConfig(context.Background(), ConfigTypeAppsPage, "default", AppsConfig{}); err != nil { + t.Fatalf("PutConfig(apps_page) failed: %v", err) + } + + if _, err := client.DeleteConfig(context.Background(), ConfigTypeFileSummary, "default"); err != nil { + t.Fatalf("DeleteConfig(file_summary) failed: %v", err) + } + + project, err := GetTypedConfig[ProjectConfig](context.Background(), client, ConfigTypeProjects, "HTAN_INT/BForePC") + if err != nil { + t.Fatalf("GetTypedConfig(project) failed: %v", err) + } + if project.Title != "Project" { + t.Fatalf("unexpected project config: %+v", project) + } +} + +func TestGeckoClientAppCardAndHealthOperations(t *testing.T) { + client := &Client{ + RequestInterface: &fakeRequest{ + doFn: func(rb *request.RequestBuilder) (*http.Response, error) { + u, err := url.Parse(rb.Url) + if err != nil { + return nil, err + } + + switch { + case rb.Method == http.MethodGet && u.Path == "/gecko/apps_page/appcard/PROG-PROJ": + return jsonResp(http.StatusOK, AppCard{ + Title: "Portal", + Description: "Project portal", + Icon: "beaker", + Href: "/portal", + Perms: "PROG-PROJ", + }), nil + case rb.Method == http.MethodPost && u.Path == "/gecko/apps_page/appcard/PROG-PROJ": + var payload AppCard + if err := json.NewDecoder(rb.Body).Decode(&payload); err != nil { + t.Fatalf("decode app card payload: %v", err) + } + if payload.Perms != "PROG-PROJ" || payload.Title != "Portal" { + t.Fatalf("unexpected app card payload: %+v", payload) + } + return jsonResp(http.StatusOK, StatusResponse{Code: 200, Message: "ACCEPTED"}), nil + case rb.Method == http.MethodDelete && u.Path == "/gecko/apps_page/appcard/PROG-PROJ": + return jsonResp(http.StatusOK, StatusResponse{Code: 200, Message: "DELETED"}), nil + case rb.Method == http.MethodGet && u.Path == "/gecko/health": + return jsonResp(http.StatusOK, "Healthy"), nil + default: + return jsonResp(http.StatusNotFound, ErrorResponse{ + Error: HTTPError{Type: ErrorTypeNotFound, Code: 404, Message: "not found"}, + }), nil + } + }, + }, + Endpoint: "https://example.org", + } + + card, err := GetAppCard(context.Background(), client, "PROG-PROJ") + if err != nil { + t.Fatalf("GetAppCard failed: %v", err) + } + if card.Perms != "PROG-PROJ" || card.Title != "Portal" { + t.Fatalf("unexpected app card: %+v", card) + } + + upsertResp, err := UpsertAppCard(context.Background(), client, "PROG-PROJ", *card) + if err != nil { + t.Fatalf("UpsertAppCard failed: %v", err) + } + if upsertResp.Message != "ACCEPTED" { + t.Fatalf("unexpected upsert response: %+v", upsertResp) + } + + deleteResp, err := DeleteAppCard(context.Background(), client, "PROG-PROJ") + if err != nil { + t.Fatalf("DeleteAppCard failed: %v", err) + } + if deleteResp.Message != "DELETED" { + t.Fatalf("unexpected delete response: %+v", deleteResp) + } + + health, err := client.HealthCheck(context.Background()) + if err != nil { + t.Fatalf("HealthCheck failed: %v", err) + } + if health != "Healthy" { + t.Fatalf("unexpected health response: %q", health) + } +} + +func TestGeckoClientPutProjectConfigValidatesRepository(t *testing.T) { + oldValidator := validateProjectRepoURL + validateProjectRepoURL = func(ctx context.Context, raw string, token string) (string, error) { + if raw != "https://github.com/calypr/gecko" { + t.Fatalf("unexpected repo URL passed to validator: %q", raw) + } + return "github.com/calypr/gecko", nil + } + t.Cleanup(func() { + validateProjectRepoURL = oldValidator + }) + + client := &Client{ + RequestInterface: &fakeRequest{ + doFn: func(rb *request.RequestBuilder) (*http.Response, error) { + u, err := url.Parse(rb.Url) + if err != nil { + return nil, err + } + if rb.Method != http.MethodPut || u.Path != "/gecko/projects/Org/Project" { + return jsonResp(http.StatusNotFound, nil), nil + } + var payload ProjectConfig + if err := json.NewDecoder(rb.Body).Decode(&payload); err != nil { + t.Fatalf("decode project payload: %v", err) + } + if payload.SrcRepo != "github.com/calypr/gecko" { + t.Fatalf("expected normalized repo, got %q", payload.SrcRepo) + } + return jsonResp(http.StatusOK, StatusResponse{Code: 200, Message: "ACCEPTED"}), nil + }, + }, + Endpoint: "https://example.org", + } + + if _, err := PutProjectConfig(context.Background(), client, "Org/Project", ProjectConfig{ + Title: "Project", + ContactEmail: "user@example.org", + SrcRepo: "https://github.com/calypr/gecko", + OrgTitle: "Org", + Description: "Desc", + ProjectTitle: "Project", + IconName: "beaker", + }); err != nil { + t.Fatalf("PutProjectConfig failed: %v", err) + } +} + +func TestGeckoClientReturnsStructuredErrors(t *testing.T) { + client := &Client{ + RequestInterface: &fakeRequest{ + doFn: func(rb *request.RequestBuilder) (*http.Response, error) { + return jsonResp(http.StatusNotFound, ErrorResponse{ + Error: HTTPError{ + Type: ErrorTypeConfigNotFound, + Code: 404, + Message: "no config found with configId: missing of type: explorer", + Details: map[string]any{"config_type": "explorer", "config_id": "missing"}, + }, + }), nil + }, + }, + Endpoint: "https://example.org", + } + + _, err := GetExplorerConfig(context.Background(), client, "missing") + if err == nil || err.Error() != "get config: status 404: no config found with configId: missing of type: explorer" { + t.Fatalf("unexpected error: %v", err) + } + + var statusErr *request.HTTPStatusError + if !errors.As(err, &statusErr) { + t.Fatalf("expected request.HTTPStatusError, got %T", err) + } + if statusErr.Type != string(ErrorTypeConfigNotFound) { + t.Fatalf("unexpected error type: %q", statusErr.Type) + } + if statusErr.Details["config_id"] != "missing" { + t.Fatalf("unexpected error details: %+v", statusErr.Details) + } +} + +func TestNewClientBuildsEndpoint(t *testing.T) { + req := &request.Request{ + RetryClient: &retryablehttp.Client{HTTPClient: &http.Client{}}, + } + client := NewClient(req, &conf.Credential{APIEndpoint: "https://example.org/base"}).(*Client) + if got := client.configListURL(ConfigTypeExplorer); got != "https://example.org/base/gecko/explorer/list" { + t.Fatalf("unexpected full URL: %s", got) + } + if got := client.configItemURL(ConfigTypeProjects, "Org/Project"); got != "https://example.org/base/gecko/projects/Org/Project" { + t.Fatalf("unexpected project URL: %s", got) + } +} diff --git a/gecko/helpers.go b/gecko/helpers.go new file mode 100644 index 0000000..b8fb498 --- /dev/null +++ b/gecko/helpers.go @@ -0,0 +1,68 @@ +package gecko + +import "context" + +func GetTypedConfig[T any](ctx context.Context, c GeckoInterface, configType ConfigType, configID string) (*T, error) { + var cfg T + if err := c.GetConfig(ctx, configType, configID, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func PutTypedConfig[T any](ctx context.Context, c GeckoInterface, configType ConfigType, configID string, cfg T) (*StatusResponse, error) { + return c.PutConfig(ctx, configType, configID, cfg) +} + +func DeleteTypedConfig(ctx context.Context, c GeckoInterface, configType ConfigType, configID string) (*StatusResponse, error) { + return c.DeleteConfig(ctx, configType, configID) +} + +func ListExplorerConfigs(ctx context.Context, c GeckoInterface) ([]string, error) { + return c.ListConfigs(ctx, ConfigTypeExplorer) +} + +func GetExplorerConfig(ctx context.Context, c GeckoInterface, configID string) (*Config, error) { + return GetTypedConfig[Config](ctx, c, ConfigTypeExplorer, configID) +} + +func PutExplorerConfig(ctx context.Context, c GeckoInterface, configID string, cfg Config) (*StatusResponse, error) { + return PutTypedConfig(ctx, c, ConfigTypeExplorer, configID, cfg) +} + +func DeleteExplorerConfig(ctx context.Context, c GeckoInterface, configID string) (*StatusResponse, error) { + return DeleteTypedConfig(ctx, c, ConfigTypeExplorer, configID) +} + +func GetProjectConfig(ctx context.Context, c GeckoInterface, configID string) (*ProjectConfig, error) { + return GetTypedConfig[ProjectConfig](ctx, c, ConfigTypeProjects, configID) +} + +func PutProjectConfig(ctx context.Context, c GeckoInterface, configID string, cfg ProjectConfig) (*StatusResponse, error) { + normalized, err := validateProjectRepoURL(ctx, cfg.SrcRepo, "") + if err != nil { + return nil, err + } + cfg.SrcRepo = normalized + return PutTypedConfig(ctx, c, ConfigTypeProjects, configID, cfg) +} + +func DeleteProjectConfig(ctx context.Context, c GeckoInterface, configID string) (*StatusResponse, error) { + return DeleteTypedConfig(ctx, c, ConfigTypeProjects, configID) +} + +func GetAppCard(ctx context.Context, c GeckoInterface, projectID string) (*AppCard, error) { + return c.GetAppCard(ctx, projectID) +} + +func UpsertAppCard(ctx context.Context, c GeckoInterface, projectID string, card AppCard) (*StatusResponse, error) { + return c.UpsertAppCard(ctx, projectID, card) +} + +func DeleteAppCard(ctx context.Context, c GeckoInterface, projectID string) (*StatusResponse, error) { + return c.DeleteAppCard(ctx, projectID) +} + +func HealthCheck(ctx context.Context, c GeckoInterface) (string, error) { + return c.HealthCheck(ctx) +} diff --git a/gecko/types.go b/gecko/types.go new file mode 100644 index 0000000..b66bf41 --- /dev/null +++ b/gecko/types.go @@ -0,0 +1,370 @@ +package gecko + +import "encoding/json" + +type FieldConfig struct { + Field string `json:"field,omitempty"` + DataField string `json:"dataField,omitempty"` + Index string `json:"index,omitempty"` + Label string `json:"label"` + Type string `json:"type,omitempty"` +} + +type FilterTab struct { + Title string `json:"title,omitempty"` + Fields []string `json:"fields"` + FieldsConfig map[string]FieldConfig `json:"fieldsConfig,omitempty"` +} + +type FiltersConfig struct { + Tabs []FilterTab `json:"tabs"` +} + +type TableConfig struct { + Enabled bool `json:"enabled"` + Fields []string `json:"fields"` + Columns map[string]TableColumnsConfig `json:"columns,omitempty"` + DetailsConfig TableDetailsConfig `json:"detailsConfig"` +} + +type SummaryTableColumnType string + +const ( + SummaryTableColumnTypeString SummaryTableColumnType = "string" + SummaryTableColumnTypeNumber SummaryTableColumnType = "number" + SummaryTableColumnTypeDate SummaryTableColumnType = "date" + SummaryTableColumnTypeArray SummaryTableColumnType = "array" + SummaryTableColumnTypeLink SummaryTableColumnType = "link" + SummaryTableColumnTypeBoolean SummaryTableColumnType = "boolean" + SummaryTableColumnTypeParagraphs SummaryTableColumnType = "paragraphs" +) + +type TableColumnsConfig struct { + Field string `json:"field"` + Title string `json:"title"` + AccessorPath string `json:"accessorPath,omitempty"` + Type SummaryTableColumnType `json:"type,omitempty"` + CellRenderFunction string `json:"cellRenderFunction,omitempty"` + Params map[string]any `json:"params,omitempty"` + Width string `json:"width,omitempty"` + Sortable bool `json:"sortable,omitempty"` + Visable bool `json:"visable,omitempty"` +} + +func (t TableColumnsConfig) MarshalJSON() ([]byte, error) { + type alias TableColumnsConfig + aux := struct { + *alias + Type *SummaryTableColumnType `json:"type,omitempty"` + }{ + alias: (*alias)(&t), + } + if t.Type != "" { + aux.Type = &t.Type + } + return json.Marshal(aux) +} + +type TableDetailsConfig struct { + Panel string `json:"panel,omitempty"` + Mode string `json:"mode,omitempty"` + IDField string `json:"idField,omitempty"` + FilterField string `json:"filterField,omitempty"` + Title string `json:"title,omitempty"` + NodeType string `json:"nodeType,omitempty"` + NodeFields map[string]string `json:"nodeFields,omitempty"` +} + +type GuppyConfig struct { + DataType string `json:"dataType"` + NodeCountTitle string `json:"nodeCountTitle"` + FieldMapping []GuppyFieldMapping `json:"fieldMapping,omitempty"` + AccessibleFieldCheckList []string `json:"accessibleFieldCheckList,omitempty"` + AccessibleValidationField string `json:"accessibleValidationField,omitempty"` + ManifestMapping ManifestMapping `json:"manifestMapping"` +} + +type GuppyFieldMapping struct { + Field string `json:"field,omitempty"` + Name string `json:"name,omitempty"` +} + +type ManifestMapping struct { + ResourceIndexType string `json:"resourceIndexType,omitempty"` + ResourceIdField string `json:"resourceIdField,omitempty"` + ReferenceIdFieldInResourceIndex string `json:"referenceIdFieldInResourceIndex,omitempty"` + ReferenceIdFieldInDataIndex string `json:"referenceIdFieldInDataIndex,omitempty"` +} + +type Chart struct { + ChartType string `json:"chartType"` + Title string `json:"title"` +} + +type ButtonConfig struct { + Enabled bool `json:"enabled,omitempty"` + Type string `json:"type,omitempty"` + Action string `json:"action,omitempty"` + Title string `json:"title,omitempty"` + LeftIcon string `json:"leftIcon,omitempty"` + RightIcon string `json:"rightIcon,omitempty"` + FileName string `json:"fileName,omitempty"` + ActionArgs ButtonActionArgs `json:"actionArgs"` +} + +type ButtonActionArgs struct { + ResourceIndexType string `json:"resourceIndexType,omitempty"` + ResourceIdField string `json:"resourceIdField,omitempty"` + ReferenceIdFieldInDataIndex string `json:"referenceIdFieldInDataIndex,omitempty"` + ReferenceIdFieldInResourceIndex string `json:"referenceIdFieldInResourceIndex,omitempty"` + FileFields []string `json:"fileFields,omitempty"` +} + +type ConfigItem struct { + TabTitle string `json:"tabTitle"` + GuppyConfig GuppyConfig `json:"guppyConfig"` + Charts map[string]Chart `json:"charts,omitempty"` + Filters FiltersConfig `json:"filters"` + Table TableConfig `json:"table"` + Dropdowns map[string]any `json:"dropdowns,omitempty"` + Buttons []ButtonConfig `json:"buttons,omitempty"` + LoginForDownload bool `json:"loginForDownload,omitempty"` + PreFilters map[string]any `json:"preFilters,omitempty"` +} + +type Config struct { + SharedFilters SharedFiltersConfig `json:"sharedFilters"` + ExplorerConfig []ConfigItem `json:"explorerConfig"` + FileActions FileActionsConfig `json:"fileActions,omitempty"` +} + +type FileActionsConfig map[string][]string + +type SharedFiltersConfig struct { + SharedFilter map[string][]FilterPair `json:"defined"` +} + +type FilterPair struct { + Index string `json:"index"` + Field string `json:"field"` +} + +type ErrorResponse struct { + Error HTTPError `json:"error"` +} + +func (e ErrorResponse) ErrorMessage() string { + return e.Error.Message +} + +func (e ErrorResponse) ErrorType() string { + return string(e.Error.Type) +} + +func (e ErrorResponse) ErrorDetails() map[string]any { + return e.Error.Details +} + +type HTTPError struct { + Type ErrorType `json:"type,omitempty"` + Message string `json:"message"` + Code int `json:"code"` + Details map[string]any `json:"details,omitempty"` +} + +type StatusResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type ConfigType string + +const ( + ConfigTypeExplorer ConfigType = "explorer" + ConfigTypeNav ConfigType = "nav" + ConfigTypeFileSummary ConfigType = "file_summary" + ConfigTypeAppsPage ConfigType = "apps_page" + ConfigTypeProject ConfigType = "project" + ConfigTypeProjects ConfigType = "projects" +) + +type ErrorType string + +const ( + ErrorTypeNotFound ErrorType = "not_found" + ErrorTypeUnauthorized ErrorType = "unauthorized" + ErrorTypeMethodNotAllowed ErrorType = "method_not_allowed" + ErrorTypeMissingAuthorization ErrorType = "missing_authorization" + ErrorTypeForbidden ErrorType = "forbidden" + ErrorTypeInvalidConfigType ErrorType = "invalid_config_type" + ErrorTypeConfigNotFound ErrorType = "config_not_found" + ErrorTypeInvalidJSON ErrorType = "invalid_json" + ErrorTypeEmptyRequestBody ErrorType = "empty_request_body" + ErrorTypeInvalidRequestBody ErrorType = "invalid_request_body" + ErrorTypeValidationFailed ErrorType = "validation_failed" + ErrorTypeMissingProjectID ErrorType = "missing_project_id" + ErrorTypeInvalidProjectID ErrorType = "invalid_project_id" + ErrorTypeProjectIDMismatch ErrorType = "project_id_mismatch" + ErrorTypeInvalidDirectory ErrorType = "invalid_directory" + ErrorTypeInvalidUUID ErrorType = "invalid_uuid" + ErrorTypeMissingIdentifier ErrorType = "missing_identifier" + ErrorTypeInvalidQueryParameter ErrorType = "invalid_query_parameter" + ErrorTypeInvalidDistance ErrorType = "invalid_distance" + ErrorTypeInvalidVectorRequest ErrorType = "invalid_vector_request" + ErrorTypeInvalidPointData ErrorType = "invalid_point_data" + ErrorTypePointNotFound ErrorType = "point_not_found" + ErrorTypeVectorCollectionNotFound ErrorType = "vector_collection_not_found" + ErrorTypeVectorCollectionAlreadyExists ErrorType = "vector_collection_already_exists" + ErrorTypeVectorStoreUnavailable ErrorType = "vector_store_unavailable" + ErrorTypeVectorOperationFailed ErrorType = "vector_operation_failed" + ErrorTypeDatabaseError ErrorType = "database_error" + ErrorTypeDatabaseUnavailable ErrorType = "database_unavailable" + ErrorTypeGraphQueryFailed ErrorType = "graph_query_failed" + ErrorTypeInvalidJWTHandler ErrorType = "invalid_jwt_handler" + ErrorTypeInvalidAuthorizationResponse ErrorType = "invalid_authorization_response" + ErrorTypeAuthorizationServiceError ErrorType = "authorization_service_error" + ErrorTypeAppCardNotFound ErrorType = "app_card_not_found" +) + +func KnownConfigTypes() []ConfigType { + return []ConfigType{ + ConfigTypeExplorer, + ConfigTypeNav, + ConfigTypeFileSummary, + ConfigTypeAppsPage, + ConfigTypeProject, + ConfigTypeProjects, + } +} + +type StylingOverrideWithMergeControl struct { + MergeStrategy string `json:"mergeStrategy,omitempty"` + Root string `json:"root,omitempty"` +} + +type NavigationButtonProps struct { + Icon string `json:"icon"` + Tooltip string `json:"tooltip"` + Href string `json:"href"` + NoBasePath *bool `json:"noBasePath,omitempty"` + Name string `json:"name"` + IconHeight string `json:"iconHeight,omitempty"` + Title string `json:"title,omitempty"` + ClassNames *StylingOverrideWithMergeControl `json:"classNames,omitempty"` +} + +type NavigationBarLogo struct { + Src string `json:"src"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Width float64 `json:"width,omitempty"` + Height float64 `json:"height,omitempty"` + NoBasePath *bool `json:"noBasePath,omitempty"` + Divider *bool `json:"divider,omitempty"` + BasePath string `json:"basePath,omitempty"` + Href string `json:"href"` + OnToggle json.RawMessage `json:"onToggle,omitempty"` + Basepage *bool `json:"basepage,omitempty"` + ClassNames *StylingOverrideWithMergeControl `json:"classNames,omitempty"` +} + +type NavigationProps struct { + Logo *NavigationBarLogo `json:"logo,omitempty"` + Items []NavigationButtonProps `json:"items"` + Title string `json:"title,omitempty"` + LoginIcon json.RawMessage `json:"loginIcon,omitempty"` + ClassNames *StylingOverrideWithMergeControl `json:"classNames"` +} + +type LeftNavBarProps struct { + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Href string `json:"href"` + Perms any `json:"perms"` +} + +type TopBarItem struct { + ClassNames struct { + Button string `json:"button"` + Label string `json:"label"` + Root string `json:"root"` + } `json:"classNames,omitempty"` + Href string `json:"href,omitempty"` + Name string `json:"name,omitempty"` +} + +type TopBarProps struct { + Items []TopBarItem `json:"items,omitempty"` + LoginButtonVisibility string `json:"loginButtonVisibility,omitempty"` +} + +type HeaderProps struct { + Top TopBarProps `json:"topBar"` + Navigation NavigationProps `json:"navigation"` + LeftNav []LeftNavBarProps `json:"leftnav"` + BasePage bool `json:"basePage,omitempty"` +} + +type FooterColumnLink struct { + Label string `json:"label,omitempty"` + Href string `json:"href,omitempty"` +} + +type FooterColumn struct { + Title string `json:"title,omitempty"` + Links []FooterColumnLink `json:"links,omitempty"` +} + +type FooterRightSection struct { + Columns []FooterColumn `json:"columns,omitempty"` +} + +type FooterProps struct { + RightSection *FooterRightSection `json:"rightSection,omitempty"` + BottomLinks []FooterColumnLink `json:"bottomLinks,omitempty"` + ColumnLinks []FooterColumnLink `json:"columnLinks,omitempty"` +} + +type HeaderMetadata struct { + Title string `json:"title"` + Content string `json:"content"` + Key string `json:"key"` +} + +type NavPageLayoutProps struct { + HeaderProps HeaderProps `json:"headerProps"` + FooterProps FooterProps `json:"footerProps"` + HeaderMetadata HeaderMetadata `json:"headerMetadata"` +} + +type FilesummaryConfig struct { + Config map[string]TableColumnsConfig `json:"config"` + BarChartColor string `json:"barChartColor"` + DefaultProject string `json:"defaultProject"` + BinslicePoints []int `json:"binslicePoints"` + IdField string `json:"idField"` + Index string `json:"index"` +} + +type AppCard struct { + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Href string `json:"href"` + Perms string `json:"perms"` +} + +type AppsConfig struct { + AppCards []AppCard `json:"appCards"` +} + +type ProjectConfig struct { + Title string `json:"title"` + ContactEmail string `json:"contact_email"` + SrcRepo string `json:"src_repo"` + OrgTitle string `json:"org_title"` + Description string `json:"description"` + ProjectTitle string `json:"project_title"` + IconName string `json:"icon_name"` +} diff --git a/githubutil/repository.go b/githubutil/repository.go new file mode 100644 index 0000000..254570a --- /dev/null +++ b/githubutil/repository.go @@ -0,0 +1,130 @@ +package githubutil + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +var githubSSHHosts = map[string]struct{}{ + "ssh.github.com": {}, + "altssh.github.com": {}, +} + +func NormalizeRepositoryURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("repository URL is required") + } + + host, path, err := splitRepositoryURL(raw) + if err != nil { + return "", err + } + + host = strings.ToLower(strings.TrimSpace(host)) + path = strings.Trim(path, "/") + path = strings.TrimSuffix(path, ".git") + + parts := strings.Split(path, "/") + if _, ok := githubSSHHosts[host]; ok { + host = "github.com" + if len(parts) == 3 && parts[0] == "443" { + parts = parts[1:] + } + } + if len(parts) != 2 { + return "", fmt.Errorf("repository URL must point to a GitHub-style owner/repo path") + } + if parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("repository URL must include both owner and repo") + } + + return host + "/" + parts[0] + "/" + parts[1], nil +} + +func ValidateRepositoryURL(ctx context.Context, raw string, token string) (string, error) { + normalized, err := NormalizeRepositoryURL(raw) + if err != nil { + return "", err + } + if err := ValidateNormalizedRepositoryURL(ctx, normalized, token); err != nil { + return "", err + } + return normalized, nil +} + +func ValidateNormalizedRepositoryURL(ctx context.Context, normalized string, token string) error { + validationURL := "https://" + normalized + ".git/info/refs?service=git-upload-pack" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, validationURL, nil) + if err != nil { + return fmt.Errorf("failed to create validation request: %w", err) + } + req.Header.Set("User-Agent", "git/2.43.0") + if token != "" { + req.SetBasicAuth("x-access-token", token) + } + + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + }, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to reach repository at %s: %w", validationURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return nil + } + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("repository exists but is not accessible with the provided credentials") + } + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("repository does not exist: %s", normalized) + } + return fmt.Errorf("repository validation failed for %s: status %s", normalized, resp.Status) +} + +func splitRepositoryURL(raw string) (host string, path string, err error) { + if strings.Contains(raw, "://") { + parsed, parseErr := url.Parse(raw) + if parseErr != nil { + return "", "", fmt.Errorf("invalid repository URL: %w", parseErr) + } + if parsed.Host == "" { + return "", "", fmt.Errorf("repository URL host is required") + } + return parsed.Hostname(), parsed.EscapedPath(), nil + } + + if strings.Contains(raw, "@") && strings.Contains(raw, ":") { + atIdx := strings.LastIndex(raw, "@") + colonIdx := strings.Index(raw[atIdx+1:], ":") + if colonIdx >= 0 { + host := raw[atIdx+1 : atIdx+1+colonIdx] + path := raw[atIdx+1+colonIdx+1:] + return host, path, nil + } + } + + parts := strings.Split(strings.Trim(raw, "/"), "/") + if len(parts) >= 3 { + return parts[0], strings.Join(parts[1:], "/"), nil + } + + return "", "", fmt.Errorf("invalid repository URL: %s", raw) +} diff --git a/githubutil/repository_test.go b/githubutil/repository_test.go new file mode 100644 index 0000000..61f5c63 --- /dev/null +++ b/githubutil/repository_test.go @@ -0,0 +1,42 @@ +package githubutil + +import "testing" + +func TestNormalizeRepositoryURL(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"https://github.com/calypr/gecko", "github.com/calypr/gecko"}, + {"https://github.com/calypr/gecko.git", "github.com/calypr/gecko"}, + {"git@github.com:calypr/gecko.git", "github.com/calypr/gecko"}, + {"github.com/calypr/gecko", "github.com/calypr/gecko"}, + {"ssh://git@ssh.github.com:443/calypr/gecko.git", "github.com/calypr/gecko"}, + {"source.ohsu.edu/calypr/gecko", "source.ohsu.edu/calypr/gecko"}, + } + + for _, tt := range tests { + got, err := NormalizeRepositoryURL(tt.in) + if err != nil { + t.Fatalf("NormalizeRepositoryURL(%q) returned error: %v", tt.in, err) + } + if got != tt.want { + t.Fatalf("NormalizeRepositoryURL(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestNormalizeRepositoryURLRejectsInvalidPaths(t *testing.T) { + bad := []string{ + "", + "https://github.com/calypr", + "github.com/calypr", + "https://github.com/calypr/gecko/extra", + } + + for _, input := range bad { + if _, err := NormalizeRepositoryURL(input); err == nil { + t.Fatalf("expected NormalizeRepositoryURL(%q) to fail", input) + } + } +} diff --git a/go.mod b/go.mod index 05d0de8..17d53d7 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/calypr/data-client +module github.com/calypr/calypr-cli go 1.26.2 diff --git a/main.go b/main.go index dd6e829..5c039bf 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main import ( - "github.com/calypr/data-client/cmd" + "github.com/calypr/calypr-cli/cmd" ) func main() { diff --git a/mocks/mock_configure.go b/mocks/mock_configure.go index dac723e..0677003 100644 --- a/mocks/mock_configure.go +++ b/mocks/mock_configure.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/calypr/data-client/conf (interfaces: ManagerInterface) +// Source: github.com/calypr/calypr-cli/conf (interfaces: ManagerInterface) // // Generated by this command: // -// mockgen -destination=../mocks/mock_configure.go -package=mocks github.com/calypr/data-client/conf ManagerInterface +// mockgen -destination=../mocks/mock_configure.go -package=mocks github.com/calypr/calypr-cli/conf ManagerInterface // // Package mocks is a generated GoMock package. @@ -12,7 +12,7 @@ package mocks import ( reflect "reflect" - conf "github.com/calypr/data-client/conf" + conf "github.com/calypr/calypr-cli/conf" gomock "go.uber.org/mock/gomock" ) diff --git a/mocks/mock_fence.go b/mocks/mock_fence.go index 004b633..646da5f 100644 --- a/mocks/mock_fence.go +++ b/mocks/mock_fence.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/calypr/data-client/fence (interfaces: FenceInterface) +// Source: github.com/calypr/calypr-cli/fence (interfaces: FenceInterface) // // Generated by this command: // -// mockgen -destination=../mocks/mock_fence.go -package=mocks github.com/calypr/data-client/fence FenceInterface +// mockgen -destination=../mocks/mock_fence.go -package=mocks github.com/calypr/calypr-cli/fence FenceInterface // // Package mocks is a generated GoMock package. @@ -14,8 +14,8 @@ import ( http "net/http" reflect "reflect" - fence "github.com/calypr/data-client/fence" - request "github.com/calypr/data-client/request" + fence "github.com/calypr/calypr-cli/fence" + request "github.com/calypr/calypr-cli/request" gomock "go.uber.org/mock/gomock" ) diff --git a/mocks/mock_gen3interface.go b/mocks/mock_gen3interface.go index adb4446..f03f195 100644 --- a/mocks/mock_gen3interface.go +++ b/mocks/mock_gen3interface.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/calypr/data-client/g3client (interfaces: Gen3Interface) +// Source: github.com/calypr/calypr-cli/g3client (interfaces: Gen3Interface) // // Generated by this command: // -// mockgen -destination=../mocks/mock_gen3interface.go -package=mocks github.com/calypr/data-client/g3client Gen3Interface +// mockgen -destination=../mocks/mock_gen3interface.go -package=mocks github.com/calypr/calypr-cli/g3client Gen3Interface // // Package mocks is a generated GoMock package. @@ -14,12 +14,13 @@ import ( http "net/http" reflect "reflect" - fence "github.com/calypr/data-client/fence" - g3client "github.com/calypr/data-client/g3client" - logs "github.com/calypr/data-client/logs" - request "github.com/calypr/data-client/request" - requestor "github.com/calypr/data-client/requestor" - sower "github.com/calypr/data-client/sower" + fence "github.com/calypr/calypr-cli/fence" + g3client "github.com/calypr/calypr-cli/g3client" + gecko "github.com/calypr/calypr-cli/gecko" + logs "github.com/calypr/calypr-cli/logs" + request "github.com/calypr/calypr-cli/request" + requestor "github.com/calypr/calypr-cli/requestor" + sower "github.com/calypr/calypr-cli/sower" syconfig "github.com/calypr/syfon/client/config" gomock "go.uber.org/mock/gomock" ) @@ -105,6 +106,20 @@ func (mr *MockGen3InterfaceMockRecorder) FenceClient() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FenceClient", reflect.TypeOf((*MockGen3Interface)(nil).FenceClient)) } +// GeckoClient mocks base method. +func (m *MockGen3Interface) GeckoClient() gecko.GeckoInterface { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GeckoClient") + ret0, _ := ret[0].(gecko.GeckoInterface) + return ret0 +} + +// GeckoClient indicates an expected call of GeckoClient. +func (mr *MockGen3InterfaceMockRecorder) GeckoClient() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GeckoClient", reflect.TypeOf((*MockGen3Interface)(nil).GeckoClient)) +} + // Logger mocks base method. func (m *MockGen3Interface) Logger() *logs.Gen3Logger { m.ctrl.T.Helper() diff --git a/mocks/mock_request.go b/mocks/mock_request.go index d0d0fff..eb58e59 100644 --- a/mocks/mock_request.go +++ b/mocks/mock_request.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/calypr/data-client/request (interfaces: RequestInterface) +// Source: github.com/calypr/calypr-cli/request (interfaces: RequestInterface) // // Generated by this command: // -// mockgen -destination=./mocks/mock_request.go -package=mocks github.com/calypr/data-client/request RequestInterface +// mockgen -destination=./mocks/mock_request.go -package=mocks github.com/calypr/calypr-cli/request RequestInterface // // Package mocks is a generated GoMock package. @@ -14,7 +14,7 @@ import ( http "net/http" reflect "reflect" - request "github.com/calypr/syfon/client/request" + request "github.com/calypr/calypr-cli/request" gomock "go.uber.org/mock/gomock" ) diff --git a/request/auth.go b/request/auth.go index 0a1bcec..4b5c828 100644 --- a/request/auth.go +++ b/request/auth.go @@ -10,7 +10,7 @@ import ( "strings" "sync" - "github.com/calypr/data-client/conf" + "github.com/calypr/calypr-cli/conf" sycommon "github.com/calypr/syfon/client/common" ) diff --git a/request/auth_test.go b/request/auth_test.go index 08a5a74..50744cc 100644 --- a/request/auth_test.go +++ b/request/auth_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/logs" ) type trackingConfigManager struct { diff --git a/request/helpers.go b/request/helpers.go new file mode 100644 index 0000000..a08d518 --- /dev/null +++ b/request/helpers.go @@ -0,0 +1,176 @@ +package request + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type ErrorMessageProvider interface { + ErrorMessage() string +} + +type ErrorTypeProvider interface { + ErrorType() string +} + +type ErrorDetailsProvider interface { + ErrorDetails() map[string]any +} + +type HTTPStatusError struct { + Action string + StatusCode int + Message string + Type string + Details map[string]any + RawBody string +} + +func (e *HTTPStatusError) Error() string { + if e.Message != "" { + return fmt.Sprintf("%s: status %d: %s", e.Action, e.StatusCode, e.Message) + } + if e.RawBody == "" { + return fmt.Sprintf("%s: status %d", e.Action, e.StatusCode) + } + return fmt.Sprintf("%s: status %d, body: %s", e.Action, e.StatusCode, e.RawBody) +} + +type JSONOption func(*jsonOptions) + +type jsonOptions struct { + action string + errorEnvelope ErrorMessageProvider + expectedCodes map[int]struct{} +} + +func WithAction(action string) JSONOption { + return func(opts *jsonOptions) { + opts.action = action + } +} + +func WithErrorEnvelope(envelope ErrorMessageProvider) JSONOption { + return func(opts *jsonOptions) { + opts.errorEnvelope = envelope + } +} + +func WithExpectedStatus(codes ...int) JSONOption { + return func(opts *jsonOptions) { + opts.expectedCodes = make(map[int]struct{}, len(codes)) + for _, code := range codes { + opts.expectedCodes[code] = struct{}{} + } + } +} + +func JoinURL(base string, parts ...string) string { + u, _ := url.Parse(strings.TrimSpace(base)) + + joined := make([]string, 0, len(parts)+1) + if basePath := strings.Trim(u.Path, "/"); basePath != "" { + joined = append(joined, basePath) + } + for _, part := range parts { + if trimmed := strings.Trim(part, "/"); trimmed != "" { + joined = append(joined, trimmed) + } + } + + if len(joined) == 0 { + u.Path = "" + return u.String() + } + + u.Path = "/" + strings.Join(joined, "/") + return u.String() +} + +func NewJSON(req RequestInterface, method, url string, body any) (*RequestBuilder, error) { + rb := req.New(method, url) + if body == nil { + return rb, nil + } + return rb.WithJSONBody(body) +} + +func DoJSON(ctx context.Context, req RequestInterface, rb *RequestBuilder, out any, opts ...JSONOption) error { + options := jsonOptions{ + action: "request failed", + } + for _, opt := range opts { + opt(&options) + } + + resp, err := req.Do(ctx, rb) + if err != nil { + return err + } + defer resp.Body.Close() + + if !statusAccepted(resp.StatusCode, options.expectedCodes) { + if options.errorEnvelope != nil { + return StatusErrorJSON(resp, options.action, options.errorEnvelope) + } + return StatusError(resp, options.action) + } + + if out == nil { + return nil + } + return DecodeJSON(resp, out) +} + +func DecodeJSON(resp *http.Response, out any) error { + return json.NewDecoder(resp.Body).Decode(out) +} + +func StatusError(resp *http.Response, action string) error { + bodyBytes, _ := io.ReadAll(resp.Body) + return statusErrorFromBody(resp.StatusCode, action, bodyBytes) +} + +func StatusErrorJSON(resp *http.Response, action string, out ErrorMessageProvider) error { + bodyBytes, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(bodyBytes, out); err == nil { + if message := strings.TrimSpace(out.ErrorMessage()); message != "" { + statusErr := &HTTPStatusError{ + Action: action, + StatusCode: resp.StatusCode, + Message: message, + RawBody: strings.TrimSpace(string(bodyBytes)), + } + if typed, ok := out.(ErrorTypeProvider); ok { + statusErr.Type = strings.TrimSpace(typed.ErrorType()) + } + if detailed, ok := out.(ErrorDetailsProvider); ok { + statusErr.Details = detailed.ErrorDetails() + } + return statusErr + } + } + return statusErrorFromBody(resp.StatusCode, action, bodyBytes) +} + +func statusAccepted(statusCode int, expectedCodes map[int]struct{}) bool { + if len(expectedCodes) == 0 { + return statusCode < http.StatusBadRequest + } + _, ok := expectedCodes[statusCode] + return ok +} + +func statusErrorFromBody(statusCode int, action string, bodyBytes []byte) error { + body := strings.TrimSpace(string(bodyBytes)) + return &HTTPStatusError{ + Action: action, + StatusCode: statusCode, + RawBody: body, + } +} diff --git a/request/request.go b/request/request.go index d496d0f..9857a2d 100644 --- a/request/request.go +++ b/request/request.go @@ -1,6 +1,6 @@ package request -//go:generate mockgen -destination=../mocks/mock_request.go -package=mocks github.com/calypr/syfon/client/request RequestInterface +//go:generate mockgen -destination=../mocks/mock_request.go -package=mocks github.com/calypr/calypr-cli/request RequestInterface import ( "context" @@ -10,8 +10,8 @@ import ( "strings" "time" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/logs" "github.com/hashicorp/go-retryablehttp" ) diff --git a/request/request_test.go b/request/request_test.go index 019b097..1e49af9 100644 --- a/request/request_test.go +++ b/request/request_test.go @@ -2,6 +2,7 @@ package request import ( "context" + "errors" "io" "log/slog" "net/http" @@ -11,8 +12,8 @@ import ( "testing" "time" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/logs" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/logs" ) func TestNewRequestInterface(t *testing.T) { @@ -235,6 +236,121 @@ func TestRequest_Do_WithCustomHeaders(t *testing.T) { resp.Body.Close() } +func TestRequest_JoinURL(t *testing.T) { + got := JoinURL("https://example.org/base", "config", "explorer", "default") + if got != "https://example.org/base/config/explorer/default" { + t.Fatalf("unexpected joined URL: %s", got) + } +} + +func TestRequest_DecodeJSON(t *testing.T) { + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(`{"status":"ok"}`)), + } + + var decoded struct { + Status string `json:"status"` + } + if err := DecodeJSON(resp, &decoded); err != nil { + t.Fatalf("DecodeJSON failed: %v", err) + } + if decoded.Status != "ok" { + t.Fatalf("unexpected decoded payload: %+v", decoded) + } +} + +func TestRequest_StatusError(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error":"bad request"}`)), + } + + err := StatusError(resp, "request failed") + if err == nil { + t.Fatal("expected error") + } + if got := err.Error(); got != `request failed: status 400, body: {"error":"bad request"}` { + t.Fatalf("unexpected error: %s", got) + } +} + +type testErrorEnvelope struct { + Type string `json:"type,omitempty"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` +} + +func (t testErrorEnvelope) ErrorMessage() string { + return t.Message +} + +func (t testErrorEnvelope) ErrorType() string { + return t.Type +} + +func (t testErrorEnvelope) ErrorDetails() map[string]any { + return t.Details +} + +func TestRequest_StatusErrorJSON(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"type":"config_not_found","message":"missing","details":{"config_id":"default"}}`)), + } + + err := StatusErrorJSON(resp, "request failed", &testErrorEnvelope{}) + if err == nil { + t.Fatal("expected error") + } + if got := err.Error(); got != `request failed: status 404: missing` { + t.Fatalf("unexpected error: %s", got) + } + + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) { + t.Fatalf("expected HTTPStatusError, got %T", err) + } + if statusErr.Type != "config_not_found" { + t.Fatalf("unexpected error type: %q", statusErr.Type) + } + if statusErr.Details["config_id"] != "default" { + t.Fatalf("unexpected error details: %+v", statusErr.Details) + } +} + +func TestRequest_NewJSONAndDoJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("expected application/json content type, got %q", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + cred := &conf.Credential{KeyID: "test-key", APIKey: "test-secret", APIEndpoint: server.URL} + req := NewRequestInterface(logs.NewGen3Logger(logger, "", ""), cred, &mockConfigManager{}) + + rb, err := NewJSON(req, http.MethodPost, server.URL+"/api/test", map[string]string{"hello": "world"}) + if err != nil { + t.Fatalf("NewJSON failed: %v", err) + } + + var decoded struct { + Status string `json:"status"` + } + if err := DoJSON(context.Background(), req, rb, &decoded, WithExpectedStatus(http.StatusOK), WithAction("post test")); err != nil { + t.Fatalf("DoJSON failed: %v", err) + } + if decoded.Status != "ok" { + t.Fatalf("unexpected decoded payload: %+v", decoded) + } +} + // Mock config manager for testing type mockConfigManager struct{} diff --git a/requestor/client.go b/requestor/client.go index 223861d..47e4383 100644 --- a/requestor/client.go +++ b/requestor/client.go @@ -3,16 +3,14 @@ package requestor import ( "context" "embed" - "encoding/json" "fmt" - "io" "net/http" "net/url" "sort" "strings" - "github.com/calypr/data-client/conf" - "github.com/calypr/data-client/request" + "github.com/calypr/calypr-cli/conf" + "github.com/calypr/calypr-cli/request" "gopkg.in/yaml.v3" ) @@ -44,9 +42,9 @@ type RequestorInterface interface { } func (c *RequestorClient) ListRequests(ctx context.Context, mine bool, active bool, username string) ([]Request, error) { - url := c.Endpoint + "/requestor/request" + url := request.JoinURL(c.Endpoint, "requestor", "request") if mine { - url += "/user" + url = request.JoinURL(c.Endpoint, "requestor", "request", "user") } params := []string{} @@ -61,77 +59,61 @@ func (c *RequestorClient) ListRequests(ctx context.Context, mine bool, active bo url += "?" + strings.Join(params, "&") } - rb := c.New(http.MethodGet, url) - resp, err := c.Do(ctx, rb) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to list requests: status %d", resp.StatusCode) - } - var requests []Request - if err := json.NewDecoder(resp.Body).Decode(&requests); err != nil { + if err := request.DoJSON( + ctx, + c.RequestInterface, + c.New(http.MethodGet, url), + &requests, + request.WithAction("failed to list requests"), + request.WithExpectedStatus(http.StatusOK), + ); err != nil { return nil, err } return requests, nil } func (c *RequestorClient) CreateRequest(ctx context.Context, reqPayload CreateRequestRequest, revoke bool) (*Request, error) { - url := c.Endpoint + "/requestor/request" + url := request.JoinURL(c.Endpoint, "requestor", "request") if revoke { url += "?revoke" } - rb := c.New(http.MethodPost, url) - rb, err := rb.WithJSONBody(reqPayload) - if err != nil { - return nil, err - } - - resp, err := c.Do(ctx, rb) + rb, err := request.NewJSON(c.RequestInterface, http.MethodPost, url, reqPayload) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - bodyBytes, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("failed to create request: status %d, body: %s", resp.StatusCode, string(bodyBytes)) - } var createdRequest Request - if err := json.NewDecoder(resp.Body).Decode(&createdRequest); err != nil { + if err := request.DoJSON( + ctx, + c.RequestInterface, + rb, + &createdRequest, + request.WithAction("failed to create request"), + ); err != nil { return nil, err } return &createdRequest, nil } func (c *RequestorClient) UpdateRequest(ctx context.Context, requestID string, status string) (*Request, error) { - url := fmt.Sprintf("%s/requestor/request/%s", c.Endpoint, requestID) + url := request.JoinURL(c.Endpoint, "requestor", "request", requestID) payload := UpdateRequestRequest{Status: status} - rb := c.New(http.MethodPut, url) - rb, err := rb.WithJSONBody(payload) + rb, err := request.NewJSON(c.RequestInterface, http.MethodPut, url, payload) if err != nil { return nil, err } - resp, err := c.Do(ctx, rb) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - bodyBytes, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("failed to update request: status %d, body: %s", resp.StatusCode, string(bodyBytes)) - } - var updatedRequest Request - if err := json.NewDecoder(resp.Body).Decode(&updatedRequest); err != nil { + if err := request.DoJSON( + ctx, + c.RequestInterface, + rb, + &updatedRequest, + request.WithAction("failed to update request"), + ); err != nil { return nil, err } return &updatedRequest, nil @@ -157,8 +139,8 @@ func formatPolicy(policy CreateRequestRequest, projectID string, username string } if projectID != "" { - parts := strings.Split(projectID, "-") - if len(parts) >= 2 { + parts := strings.SplitN(projectID, "-", 2) + if len(parts) == 2 { program := parts[0] project := parts[1] diff --git a/requestor/client_test.go b/requestor/client_test.go index 3d04e64..5d85c5f 100644 --- a/requestor/client_test.go +++ b/requestor/client_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/calypr/data-client/request" + "github.com/calypr/calypr-cli/request" ) type fakeRequest struct { @@ -34,6 +34,13 @@ func jsonResponse(status int, v any) *http.Response { return &http.Response{StatusCode: status, Body: body} } +func TestRequestorClientStatusError(t *testing.T) { + err := request.StatusError(jsonResponse(http.StatusBadRequest, map[string]string{"error": "bad request"}), "failed") + if err == nil || !strings.Contains(err.Error(), "failed: status 400") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestGetPolicyKey(t *testing.T) { c := &RequestorClient{} @@ -113,7 +120,7 @@ func TestLoadPoliciesAndFormatPolicy(t *testing.T) { if formatted.ResourceDisplayName != "demo-program-demo-project" { t.Fatalf("expected display name to be replaced, got %q", formatted.ResourceDisplayName) } - if got := formatted.ResourcePaths[0]; got != "/demo/projects/program/data" { + if got := formatted.ResourcePaths[0]; got != "/demo/projects/program-demo-project/data" { t.Fatalf("unexpected formatted path: %q", got) } } @@ -296,6 +303,72 @@ func TestRequestorClientAddAndRemoveUser(t *testing.T) { } } +func TestRequestorClientAddUserPreservesEmbeddedDashInProject(t *testing.T) { + var payloads []CreateRequestRequest + + client := &RequestorClient{ + RequestInterface: &fakeRequest{ + doFn: func(rb *request.RequestBuilder) (*http.Response, error) { + u, err := url.Parse(rb.Url) + if err != nil { + return nil, err + } + if rb.Method != http.MethodPost || u.Path != "/requestor/request" { + return jsonResponse(http.StatusNotFound, nil), nil + } + + var payload CreateRequestRequest + if err := json.NewDecoder(rb.Body).Decode(&payload); err != nil { + return nil, err + } + payloads = append(payloads, payload) + return jsonResponse(http.StatusOK, Request{RequestID: "req-ok", Status: "open"}), nil + }, + }, + Endpoint: "https://example.org", + } + + created, err := client.AddUser(context.Background(), "cbds-aaa-bbb", "bob@example.org", true, true) + if err != nil { + t.Fatalf("AddUser failed: %v", err) + } + if len(created) == 0 { + t.Fatal("expected AddUser to create requests") + } + if len(payloads) == 0 { + t.Fatal("expected request payloads to be captured") + } + + wantPath := "/programs/cbds/projects/aaa-bbb" + foundProjectScopedPayload := false + for _, payload := range payloads { + if payload.ResourceDisplayName != "cbds-aaa-bbb" { + t.Fatalf("unexpected display name: %+v", payload) + } + + hasProjectPlaceholderPath := false + found := false + for _, path := range payload.ResourcePaths { + if strings.Contains(path, "/programs/") { + hasProjectPlaceholderPath = true + } + if path == wantPath || strings.Contains(path, wantPath+"/") { + found = true + break + } + } + if hasProjectPlaceholderPath && !found { + t.Fatalf("expected payload to target %q, got %+v", wantPath, payload.ResourcePaths) + } + if found { + foundProjectScopedPayload = true + } + } + if !foundProjectScopedPayload { + t.Fatalf("expected at least one project-scoped payload to target %q", wantPath) + } +} + func TestAddUserToResourcesCreatesRequestsPerResource(t *testing.T) { var payloads []CreateRequestRequest client := &RequestorClient{ diff --git a/sower/client.go b/sower/client.go index 770e891..99f0901 100644 --- a/sower/client.go +++ b/sower/client.go @@ -2,13 +2,10 @@ package sower import ( "context" - "encoding/json" - "fmt" - "io" "net/http" "net/url" - "github.com/calypr/data-client/request" + "github.com/calypr/calypr-cli/request" ) const ( @@ -38,9 +35,7 @@ func NewSowerClient(req request.RequestInterface, endpoint string) *SowerClient } func (sc *SowerClient) fullURL(path string) string { - u, _ := url.Parse(sc.Endpoint) - u.Path = path - return u.String() + return request.JoinURL(sc.Endpoint, path) } func (sc *SowerClient) DispatchJob(ctx context.Context, name string, args *DispatchArgs) (*StatusResp, error) { @@ -49,26 +44,20 @@ func (sc *SowerClient) DispatchJob(ctx context.Context, name string, args *Dispa Input: *args, } - rb := sc.New(http.MethodPost, sc.fullURL(sowerDispatch)) - rb, err := rb.WithJSONBody(body) + rb, err := request.NewJSON(sc.RequestInterface, http.MethodPost, sc.fullURL(sowerDispatch), body) if err != nil { return nil, err } - resp, err := sc.Do(ctx, rb) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("sower dispatch failed: %d %s", resp.StatusCode, string(b)) - } - statusResp := &StatusResp{} - err = json.NewDecoder(resp.Body).Decode(statusResp) - if err != nil { + if err := request.DoJSON( + ctx, + sc.RequestInterface, + rb, + statusResp, + request.WithAction("sower dispatch failed"), + request.WithExpectedStatus(http.StatusOK), + ); err != nil { return nil, err } return statusResp, nil @@ -80,21 +69,15 @@ func (sc *SowerClient) Status(ctx context.Context, uid string) (*StatusResp, err q.Add("UID", uid) u.RawQuery = q.Encode() - rb := sc.New(http.MethodGet, u.String()) - resp, err := sc.Do(ctx, rb) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("sower status failed: %d %s", resp.StatusCode, string(b)) - } - statusResp := &StatusResp{} - err = json.NewDecoder(resp.Body).Decode(statusResp) - if err != nil { + if err := request.DoJSON( + ctx, + sc.RequestInterface, + sc.New(http.MethodGet, u.String()), + statusResp, + request.WithAction("sower status failed"), + request.WithExpectedStatus(http.StatusOK), + ); err != nil { return nil, err } return statusResp, nil @@ -106,42 +89,30 @@ func (sc *SowerClient) Output(ctx context.Context, uid string) (*OutputResp, err q.Add("UID", uid) u.RawQuery = q.Encode() - rb := sc.New(http.MethodGet, u.String()) - resp, err := sc.Do(ctx, rb) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("sower output failed: %d %s", resp.StatusCode, string(b)) - } - var outputResp OutputResp - err = json.NewDecoder(resp.Body).Decode(&outputResp) - if err != nil { + if err := request.DoJSON( + ctx, + sc.RequestInterface, + sc.New(http.MethodGet, u.String()), + &outputResp, + request.WithAction("sower output failed"), + request.WithExpectedStatus(http.StatusOK), + ); err != nil { return nil, err } return &outputResp, nil } func (sc *SowerClient) List(ctx context.Context) ([]StatusResp, error) { - rb := sc.New(http.MethodGet, sc.fullURL(sowerList)) - resp, err := sc.Do(ctx, rb) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("sower list failed: %d %s", resp.StatusCode, string(b)) - } - var listResp []StatusResp - err = json.NewDecoder(resp.Body).Decode(&listResp) - if err != nil { + if err := request.DoJSON( + ctx, + sc.RequestInterface, + sc.New(http.MethodGet, sc.fullURL(sowerList)), + &listResp, + request.WithAction("sower list failed"), + request.WithExpectedStatus(http.StatusOK), + ); err != nil { return nil, err } return listResp, nil diff --git a/sower/client_test.go b/sower/client_test.go index 77788c7..fa5085b 100644 --- a/sower/client_test.go +++ b/sower/client_test.go @@ -9,7 +9,7 @@ import ( "net/url" "testing" - "github.com/calypr/data-client/request" + "github.com/calypr/calypr-cli/request" "github.com/hashicorp/go-retryablehttp" )