Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions cmd/kvlist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

renderapi "github.com/render-oss/cli/internal/fakes/renderapi"
"github.com/render-oss/cli/internal/testids"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -185,3 +186,34 @@ func TestKVList_UnknownProject_Errors(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "does-not-exist")
}

func TestKVList_FilterByIDShapedProjectAndEnvironmentNames(t *testing.T) {
server := renderapi.NewServer(t)

// Accepted names that match the prj-/evm- ID shape must still resolve by
// name when no object has that ID.
projectA := server.CreateProject(
renderapi.ProjectAttrs{Id: testids.ProjectID("id shaped a"), Name: "prj-00000000000000093206", OwnerId: kvTestWorkspaceID},
renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped a"), Name: "evm-00000000000000093206"},
renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped staging"), Name: "staging"},
)
projectB := server.CreateProject(
renderapi.ProjectAttrs{Id: testids.ProjectID("id shaped b"), Name: "Project B", OwnerId: kvTestWorkspaceID},
renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped b"), Name: "evm-00000000000000093206"},
)

aKV := seedKVInEnv(server, "a-cache", projectA.Env("evm-00000000000000093206").Id)
aStagingKV := seedKVInEnv(server, "a-staging-cache", projectA.Env("staging").Id)
bKV := seedKVInEnv(server, "b-cache", projectB.Env("evm-00000000000000093206").Id)

result, err := executeKVList(t, server,
"--project", "prj-00000000000000093206",
"--environment", "evm-00000000000000093206",
"--output", "text",
)
require.NoError(t, err)

assert.Contains(t, result.Stdout, aKV.Id)
assert.NotContains(t, result.Stdout, aStagingKV.Id)
assert.NotContains(t, result.Stdout, bKV.Id)
}
32 changes: 32 additions & 0 deletions cmd/pglist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/require"

renderapi "github.com/render-oss/cli/internal/fakes/renderapi"
"github.com/render-oss/cli/internal/testids"
"github.com/render-oss/cli/internal/testrequire"
"github.com/render-oss/cli/pkg/client"
)
Expand Down Expand Up @@ -178,3 +179,34 @@ func TestPGList_UnknownProject_Errors(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "does-not-exist")
}

func TestPGList_FilterByIDShapedProjectAndEnvironmentNames(t *testing.T) {
harness := newPGListHarness(t)

// Accepted names that match the prj-/evm- ID shape must still resolve by
// name when no object has that ID.
projectA := harness.server.CreateProject(
renderapi.ProjectAttrs{Id: testids.ProjectID("id shaped a"), Name: "prj-00000000000000093206", OwnerId: pgActiveWorkspaceID},
renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped a"), Name: "evm-00000000000000093206"},
renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped staging"), Name: "staging"},
)
projectB := harness.server.CreateProject(
renderapi.ProjectAttrs{Id: testids.ProjectID("id shaped b"), Name: "Project B", OwnerId: pgActiveWorkspaceID},
renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped b"), Name: "evm-00000000000000093206"},
)

aPG := seedPGInEnv(harness.server, "a-db", projectA.Env("evm-00000000000000093206").Id)
aStagingPG := seedPGInEnv(harness.server, "a-staging-db", projectA.Env("staging").Id)
bPG := seedPGInEnv(harness.server, "b-db", projectB.Env("evm-00000000000000093206").Id)

result, err := harness.execute(
"--project", "prj-00000000000000093206",
"--environment", "evm-00000000000000093206",
"--output", "text",
)
require.NoError(t, err)

assert.Contains(t, result.Stdout, aPG.Id)
assert.NotContains(t, result.Stdout, aStagingPG.Id)
assert.NotContains(t, result.Stdout, bPG.Id)
}
24 changes: 24 additions & 0 deletions pkg/environment/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,31 @@ package environment

import (
"context"
"errors"
"fmt"
"net/http"

"github.com/render-oss/cli/pkg/client"
)

// ErrEnvironmentNotFound is matched (via errors.Is) by the error Repo.GetEnvironment returns
// when the API responds 404 Not Found for the requested ID.
var ErrEnvironmentNotFound = errors.New("environment not found")

// environmentNotFoundError keeps the existing "unexpected response: <status>" message
// while letting callers distinguish a 404 from other failures.
type environmentNotFoundError struct {
status string
}

func (e *environmentNotFoundError) Error() string {
return fmt.Sprintf("unexpected response: %v", e.status)
}

func (e *environmentNotFoundError) Unwrap() error {
return ErrEnvironmentNotFound
}

type Repo struct {
client *client.ClientWithResponses
}
Expand All @@ -25,6 +45,10 @@ func (e *Repo) GetEnvironment(ctx context.Context, id string) (*client.Environme
return nil, err
}

if resp.StatusCode() == http.StatusNotFound {
return nil, &environmentNotFoundError{status: resp.Status()}
}

if resp.JSON200 == nil {
return nil, fmt.Errorf("unexpected response: %v", resp.Status())
}
Expand Down
24 changes: 24 additions & 0 deletions pkg/project/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,33 @@ package project

import (
"context"
"errors"
"fmt"
"net/http"

"github.com/render-oss/cli/pkg/client"
"github.com/render-oss/cli/pkg/config"
"github.com/render-oss/cli/pkg/pointers"
)

// ErrProjectNotFound is matched (via errors.Is) by the error Repo.GetProject returns
// when the API responds 404 Not Found for the requested ID.
var ErrProjectNotFound = errors.New("project not found")

// projectNotFoundError keeps the existing "unexpected response: <status>" message
// while letting callers distinguish a 404 from other failures.
type projectNotFoundError struct {
status string
}

func (e *projectNotFoundError) Error() string {
return fmt.Sprintf("unexpected response: %v", e.status)
}

func (e *projectNotFoundError) Unwrap() error {
return ErrProjectNotFound
}

func NewRepo(client *client.ClientWithResponses) *Repo {
return &Repo{client: client}
}
Expand Down Expand Up @@ -87,6 +107,10 @@ func (p *Repo) GetProject(ctx context.Context, id string) (*client.Project, erro
return nil, err
}

if resp.StatusCode() == http.StatusNotFound {
return nil, &projectNotFoundError{status: resp.Status()}
}

if resp.JSON200 == nil {
return nil, fmt.Errorf("unexpected response: %v", resp.Status())
}
Expand Down
61 changes: 44 additions & 17 deletions pkg/resolve/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package resolve

import (
"context"
"errors"
"fmt"
"strings"

Expand Down Expand Up @@ -223,22 +224,29 @@ func validateEnvironmentBelongsToWorkspace(env *client.Environment, project *cli
// them; when absent, this helper resolves the missing ancestors from the
// environment itself.
func (r *Resolver) resolveEnvironmentAndProject(ctx context.Context, envIDOrName string, workspaceID string, resolvedProject *client.Project) (*client.Environment, *client.Project, error) {
// An ID-shaped selector that 404s by ID may still be an environment name,
// so fall through to the scoped name lookup. idErr is reported if no
// environment has that name either. Other errors are returned as-is.
var idErr error
if validate.IsEnvironmentID(envIDOrName) {
env, err := r.environmentRepo.GetEnvironment(ctx, envIDOrName)
if err != nil {
return nil, nil, fmt.Errorf("environment %q not found: %w", envIDOrName, err)
}
project, err := r.projectRepo.GetProject(ctx, env.ProjectId)
if err != nil {
return nil, nil, err
if err == nil {
project, err := r.projectRepo.GetProject(ctx, env.ProjectId)
if err != nil {
return nil, nil, err
}
if err := validateEnvironmentBelongsToProject(env, resolvedProject); err != nil {
return nil, nil, err
}
if err := validateEnvironmentBelongsToWorkspace(env, project, workspaceID); err != nil {
return nil, nil, err
}
return env, project, nil
}
if err := validateEnvironmentBelongsToProject(env, resolvedProject); err != nil {
return nil, nil, err
}
if err := validateEnvironmentBelongsToWorkspace(env, project, workspaceID); err != nil {
return nil, nil, err
idErr = fmt.Errorf("environment %q not found: %w", envIDOrName, err)
if !errors.Is(err, environment.ErrEnvironmentNotFound) {
return nil, nil, idErr
}
return env, project, nil
}

projects, err := r.candidateProjectsForEnvironmentLookup(ctx, resolvedProject, workspaceID)
Expand All @@ -252,7 +260,7 @@ func (r *Resolver) resolveEnvironmentAndProject(ctx context.Context, envIDOrName
projectByID[p.Id] = p
}
if len(projectIDs) == 0 {
return nil, nil, fmt.Errorf("no environment found with name %q", envIDOrName)
return nil, nil, noEnvironmentMatch(envIDOrName, idErr)
}

nameFilter := client.NameParam{envIDOrName}
Expand All @@ -265,7 +273,7 @@ func (r *Resolver) resolveEnvironmentAndProject(ctx context.Context, envIDOrName
}

if len(matches) == 0 {
return nil, nil, fmt.Errorf("no environment found with name %q", envIDOrName)
return nil, nil, noEnvironmentMatch(envIDOrName, idErr)
}
if len(matches) > 1 {
return nil, nil, fmt.Errorf("multiple environments found with name %q across projects — please use the environment ID instead", envIDOrName)
Expand All @@ -288,6 +296,15 @@ func (r *Resolver) resolveEnvironmentAndProject(ctx context.Context, envIDOrName
return env, project, nil
}

// noEnvironmentMatch reports a selector that matched no environment name,
// preferring the by-ID 404 error when the selector was ID-shaped.
func noEnvironmentMatch(envIDOrName string, idErr error) error {
if idErr != nil {
return idErr
}
return fmt.Errorf("no environment found with name %q", envIDOrName)
}

// candidateProjectsForEnvironmentLookup returns the projects whose environments
// may match an environment name selector under the current scope constraints.
func (r *Resolver) candidateProjectsForEnvironmentLookup(ctx context.Context, resolvedProject *client.Project, workspaceID string) ([]*client.Project, error) {
Expand All @@ -313,19 +330,29 @@ func (r *Resolver) candidateProjectsForEnvironmentLookup(ctx context.Context, re
// search spans every workspace the caller can access. Returns the full
// *client.Project so callers can read Owner.Id without re-fetching.
func (r *Resolver) ResolveProject(ctx context.Context, idOrName string, workspaceID string) (*client.Project, error) {
// An ID-shaped selector that 404s by ID may still be a project name, so
// fall through to the scoped name lookup. idErr is reported if no project
// has that name either. Other errors are returned as-is.
var idErr error
if validate.IsProjectID(idOrName) {
proj, err := r.projectRepo.GetProject(ctx, idOrName)
if err != nil {
return nil, fmt.Errorf("project %q not found: %w", idOrName, err)
if err == nil {
return proj, nil
}
idErr = fmt.Errorf("project %q not found: %w", idOrName, err)
if !errors.Is(err, project.ErrProjectNotFound) {
return nil, idErr
}
return proj, nil
}

matches, err := r.projectRepo.ListProjectsByName(ctx, idOrName, workspaceID)
if err != nil {
return nil, err
}
if len(matches) == 0 {
if idErr != nil {
return nil, idErr
}
return nil, fmt.Errorf("no project found with name %q", idOrName)
}
if len(matches) > 1 {
Expand Down
Loading