Skip to content
Merged
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
8 changes: 4 additions & 4 deletions cmd/entire/cli/cell_target.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ type cellCoreClient interface {
}

type nativeRepoCellCoreClient interface {
nativeRepoResolverClient
repoRefClient
ListClusters(ctx context.Context) (*coreapi.ListClustersOutputBody, error)
ListRepos(ctx context.Context, params coreapi.ListReposParams) (*coreapi.ListReposOutputBody, error)
}
Expand Down Expand Up @@ -314,9 +314,9 @@ func resolveForgeRepoCellPlacement(ctx context.Context, forge, owner, repo strin
}

// resolveNativeRepoCellPlacement resolves /et/<project>/<repo> through the
// native project-scoped repo lookup, then maps the repo's home cluster to its
// entire-api cell. It deliberately never consults the forge-blind repos index:
// that index can select a same-named /gh/ mirror instead.
// native path lookup, then maps the repo's home cluster to its entire-api
// cell. It deliberately never consults the forge-blind repos index: that index
// can select a same-named /gh/ mirror instead.
func resolveNativeRepoCellPlacement(ctx context.Context, project, repoName string) (repoCellPlacement, error) {
ctx, cancel := context.WithTimeout(ctx, requiredCellResolveTimeout)
defer cancel()
Expand Down
88 changes: 42 additions & 46 deletions cmd/entire/cli/cell_target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,15 @@ func TestMatchClusterBySlug(t *testing.T) {
// fakeCellCore is a stub control plane for resolveRepoCellTarget /
// resolveRepoCellPlacement tests.
type fakeCellCore struct {
repo *coreapi.Repo
repoErr error
projects *coreapi.ListProjectsOutputBody
projectsErr error
projectRepos *coreapi.ListProjectReposOutputBody
projectErr error
clusters []coreapi.Cluster
clustersErr error
repos *coreapi.ListReposOutputBody
reposErr error
repo *coreapi.Repo
repoErr error
// resolution answers POST /repos/resolve; nil resolves nothing.
resolution *coreapi.ResolveReposResponse
resolveErr error
clusters []coreapi.Cluster
clustersErr error
repos *coreapi.ListReposOutputBody
reposErr error
// blockUntilCtxDone makes ListRepos and GetRepo hang until the caller's
// deadline fires, standing in for a reachable-but-slow control plane —
// both, so the owner/repo and ULID paths can each be tested. Off by
Expand Down Expand Up @@ -118,30 +117,41 @@ func (f *fakeCellCore) GetRepo(ctx context.Context, _ coreapi.GetRepoParams) (*c
return f.repo, f.repoErr
}

func (f *fakeCellCore) ListProjects(ctx context.Context, _ coreapi.ListProjectsParams) (*coreapi.ListProjectsOutputBody, error) {
func (f *fakeCellCore) ResolveRepos(ctx context.Context, _ *coreapi.ResolveReposInputBody) (*coreapi.ResolveReposResponse, error) {
if err := f.waitIfBlocking(ctx); err != nil {
return nil, err
}
if f.projectsErr != nil {
return nil, f.projectsErr
if f.resolveErr != nil {
return nil, f.resolveErr
}
if f.projects != nil {
return f.projects, nil
if f.resolution != nil {
return f.resolution, nil
}
return &coreapi.ListProjectsOutputBody{}, nil
return &coreapi.ResolveReposResponse{}, nil
}

func (f *fakeCellCore) ListProjectRepos(ctx context.Context, _ coreapi.ListProjectReposParams) (*coreapi.ListProjectReposOutputBody, error) {
if err := f.waitIfBlocking(ctx); err != nil {
return nil, err
}
if f.projectErr != nil {
return nil, f.projectErr
}
if f.projectRepos != nil {
return f.projectRepos, nil
}
return &coreapi.ListProjectReposOutputBody{}, nil
// errProjectLookupRan fails a native-ref resolution that reaches a
// project-scoped lookup. Those need project#inspect, which a repo-only grant
// lacks.
var errProjectLookupRan = errors.New("project lookup must not run for a native ref")

func (f *fakeCellCore) ListProjects(context.Context, coreapi.ListProjectsParams) (*coreapi.ListProjectsOutputBody, error) {
return nil, errProjectLookupRan
}

func (f *fakeCellCore) ListProjectRepos(context.Context, coreapi.ListProjectReposParams) (*coreapi.ListProjectReposOutputBody, error) {
return nil, errProjectLookupRan
}

// nativeResolution is the POST /repos/resolve answer for one ready native repo.
func nativeResolution(fullName, repoID string) *coreapi.ResolveReposResponse {
return &coreapi.ResolveReposResponse{Resolutions: []coreapi.RepoResolution{{
Provider: repoProviderEntire,
RequestedFullName: fullName,
FullName: coreapi.NewOptString(fullName),
Status: coreapi.RepoResolutionStatusReady,
RepoId: coreapi.NewOptString(repoID),
}}}
}

func (f *fakeCellCore) ListClusters(context.Context) (*coreapi.ListClustersOutputBody, error) {
Expand Down Expand Up @@ -186,12 +196,7 @@ func TestResolveForgeRepoCellPlacement_NativeDoesNotSelectSameNamedGitHubMirror(
legacyGHID = "01LEGACYGHMIRROR000000000"
)
withFakeCellCore(t, &fakeCellCore{
projects: &coreapi.ListProjectsOutputBody{Project: coreapi.NewOptProject(coreapi.Project{
ID: projectID, Name: "entirehq",
})},
projectRepos: &coreapi.ListProjectReposOutputBody{Repo: coreapi.NewOptRepo(coreapi.Repo{
ID: nativeID, Name: "marvin", OwningProjectId: projectID,
})},
resolution: nativeResolution("entirehq/marvin", nativeID),
repo: &coreapi.Repo{
ID: nativeID, Name: "marvin", OwningProjectId: projectID,
ClusterHost: coreapi.NewOptString("eu.entire.io"),
Expand Down Expand Up @@ -235,36 +240,27 @@ func TestResolveNativeRepoCellPlacement_ClassifiesDefinitiveMisses(t *testing.T)
projectID = "01NATIVEPROJECT00000000000"
repoID = "01NATIVEREPOSITORY00000000"
)
project := &coreapi.ListProjectsOutputBody{Project: coreapi.NewOptProject(coreapi.Project{ID: projectID, Name: "entirehq"})}
projectRepo := &coreapi.ListProjectReposOutputBody{Repo: coreapi.NewOptRepo(coreapi.Repo{ID: repoID, Name: "marvin", OwningProjectId: projectID})}

tests := []struct {
name string
core *fakeCellCore
wantNotOnboarded bool
wantMessageSnippet string
}{
{
name: "project does not exist",
name: "repo does not resolve",
core: &fakeCellCore{},
wantNotOnboarded: true,
wantMessageSnippet: "no project named",
},
{
name: "repo does not exist in project",
core: &fakeCellCore{projects: project},
wantNotOnboarded: true,
wantMessageSnippet: "no repo named",
wantMessageSnippet: "not found or not shared",
},
{
name: "repo has no cluster host",
core: &fakeCellCore{projects: project, projectRepos: projectRepo, repo: &coreapi.Repo{ID: repoID, Name: "marvin", OwningProjectId: projectID}},
core: &fakeCellCore{resolution: nativeResolution("entirehq/marvin", repoID), repo: &coreapi.Repo{ID: repoID, Name: "marvin", OwningProjectId: projectID}},
wantNotOnboarded: true,
wantMessageSnippet: "repo has no cluster host",
},
{
name: "control plane failure remains retryable",
core: &fakeCellCore{projectsErr: errors.New("core unavailable")},
core: &fakeCellCore{resolveErr: errors.New("core unavailable")},
wantNotOnboarded: false,
wantMessageSnippet: "core unavailable",
},
Expand Down
18 changes: 8 additions & 10 deletions cmd/entire/cli/grant_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ const (
)

// grantWiringHandler serves the lookups a grant command makes before its
// DELETE — handle resolution for a provider:handle grantee, and the project and
// repo by-name lookups behind a /et/<project>/<repo> ref — and records the
// DELETE. record is called with the DELETE's method and path; deleteFn writes
// the DELETE response (e.g. 204 or a 404 problem).
// DELETE — handle resolution for a provider:handle grantee, and the path
// lookup behind a /et/<project>/<repo> ref — and records the DELETE. record is
// called with the DELETE's method and path; deleteFn writes the DELETE
// response (e.g. 204 or a 404 problem).
func grantWiringHandler(t *testing.T, record func(method, path string), deleteFn func(w http.ResponseWriter)) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
if r.Method != http.MethodGet && !strings.HasSuffix(r.URL.Path, "/repos/resolve") {
record(r.Method, r.URL.Path)
deleteFn(w)
return
Expand All @@ -47,12 +47,10 @@ func grantWiringHandler(t *testing.T, record func(method, path string), deleteFn
Handle: "alice",
ProviderUserId: "12345",
}
case strings.HasSuffix(r.URL.Path, "/repos"):
payload = &coreapi.ListProjectReposOutputBody{Repo: coreapi.NewOptRepo(coreapi.Repo{ID: wiringRepoULID, Name: "web"})}
case strings.HasSuffix(r.URL.Path, "/projects"):
payload = &coreapi.ListProjectsOutputBody{Project: coreapi.NewOptProject(coreapi.Project{ID: wiringProjULID, Name: "acme", OwnerId: wiringOrgULID, OwnerType: coreapi.ProjectOwnerTypeOrg})}
case strings.HasSuffix(r.URL.Path, "/repos/resolve"):
payload = nativeResolution("acme/web", wiringRepoULID)
default:
t.Errorf("unexpected GET %s", r.URL.Path)
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
return
}
if err := printJSON(w, payload); err != nil {
Expand Down
15 changes: 6 additions & 9 deletions cmd/entire/cli/repo_clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,12 @@ func parseNativeCloneRef(ref string) (project, repo string, err error) {
return project, repo, nil
}

type nativeRepoResolverClient interface {
repoRefClient
GetRepo(ctx context.Context, params coreapi.GetRepoParams) (*coreapi.Repo, error)
}

// resolveNativeRepo performs the canonical /et/<project>/<repo> identity
// lookup shared by clone and repo-scoped data commands.
func resolveNativeRepo(ctx context.Context, c nativeRepoResolverClient, project, repoName string) (*coreapi.Repo, error) {
repoID, err := resolveRepoRef(ctx, c, repoName, project)
// lookup shared by clone and repo-scoped data commands. It bypasses
// resolveRepoRef: both segments are names, and a ULID-shaped project name
// must not be read as an id.
func resolveNativeRepo(ctx context.Context, c repoRefClient, project, repoName string) (*coreapi.Repo, error) {
repoID, err := resolveNativeRepoByPath(ctx, c, project, repoName)
if err != nil {
return nil, err
}
Expand All @@ -207,7 +204,7 @@ func resolveNativeRepo(ctx context.Context, c nativeRepoResolverClient, project,
}

// resolveNativeCloneURL resolves an Entire-native repo (by project and repo
// name) to its entire:// clone URL: name → ULID via the project-scoped lookup,
// name) to its entire:// clone URL: name → ULID via the pull-gated path lookup,
// then GetRepo — the one call that returns both clusterHost and path. The URL
// is the server's own coordinates, never synthesized from the user's ref: the
// path is the repo's, and the host is one of its readable placements — the
Expand Down
19 changes: 7 additions & 12 deletions cmd/entire/cli/repo_clone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,11 @@ type nativeRepoFixture struct {
clustersStatus int
}

// serveNativeRepo fakes the three-call native resolution chain: project by
// name, repo by name within the project, then the single-repo GET (the one
// response that carries clusterHost + path). The mirror listing answers empty,
// so resolution sees exactly one placement: the home cluster.
// serveNativeRepo fakes the two-call native resolution chain: POST
// /repos/resolve, then the single-repo GET (the one response that carries
// clusterHost + path). No /projects route is served: a native ref must resolve
// with repo#pull alone. The mirror listing answers empty, so resolution sees
// exactly one placement: the home cluster.
func serveNativeRepo(t *testing.T, repo coreapi.Repo) *coreapi.Client {
t.Helper()
return serveNativeRepoFixture(t, nativeRepoFixture{repo: repo})
Expand All @@ -364,14 +365,8 @@ func serveNativeRepoFixture(t *testing.T, fx nativeRepoFixture) *coreapi.Client
w.Header().Set("Content-Type", "application/json")
var body any
switch r.URL.Path {
case "/api/v1/projects":
body = &coreapi.ListProjectsOutputBody{Project: coreapi.NewOptProject(coreapi.Project{
ID: testProjectULID, Name: "paul", OwnerId: testProjectULID, OwnerType: coreapi.ProjectOwnerTypeOrg,
})}
case "/api/v1/projects/" + testProjectULID + "/repos":
body = &coreapi.ListProjectReposOutputBody{Repo: coreapi.NewOptRepo(coreapi.Repo{
ID: testNativeRepoULID, Name: fx.repo.Name, OwningProjectId: testProjectULID,
})}
case "/api/v1/repos/resolve":
body = nativeResolution("paul/"+fx.repo.Name, testNativeRepoULID)
case "/api/v1/repos/" + testNativeRepoULID:
body = &fx.repo
case "/api/v1/repos/" + testNativeRepoULID + "/native-mirrors":
Expand Down
67 changes: 52 additions & 15 deletions cmd/entire/cli/resolveref.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ type projectRefClient interface {
type repoRefClient interface {
projectRefClient
ListProjectRepos(ctx context.Context, params coreapi.ListProjectReposParams) (*coreapi.ListProjectReposOutputBody, error)
ResolveRepos(ctx context.Context, request *coreapi.ResolveReposInputBody) (*coreapi.ResolveReposResponse, error)
GetRepo(ctx context.Context, params coreapi.GetRepoParams) (*coreapi.Repo, error)
}

// looksLikeULID reports whether s has the shape of a ULID: 26 characters drawn
Expand Down Expand Up @@ -242,20 +244,49 @@ func resolveRepoRef(ctx context.Context, c repoRefClient, ref, projectRef string
if projectRef == "" {
return "", fmt.Errorf("repo %q is a name; pass --project <name|ULID> to resolve it, use its /%s/<project>/<repo> path, or a repo ULID", ref, nativeCloneForge)
}
projID, err := resolveProjectRef(ctx, c, projectRef)
// The path lookup takes names only. A project ULID has no name route, so
// it stays on the project-scoped listing.
if !looksLikeULID(projectRef) {
return resolveNativeRepoByPath(ctx, c, projectRef, ref)
}
return resolveRepoInProject(ctx, c, ref, projectRef)
}

// resolveNativeRepoByPath resolves <project>/<repo> through POST /repos/resolve,
// which needs repo#pull only. The project-scoped lookups need project#inspect,
// which a direct repo grant does not confer.
//
// The server answers unknown and unauthorized alike, so the miss is one error.
// It wraps errNamedRefNotFound so routing callers classify it as definitive.
func resolveNativeRepoByPath(ctx context.Context, c repoRefClient, project, repoName string) (string, error) {
fullName := project + "/" + repoName
out, err := c.ResolveRepos(ctx, &coreapi.ResolveReposInputBody{
Repositories: []coreapi.RepoReference{{Provider: repoProviderEntire, FullName: fullName}},
})
if err != nil {
return "", err
return "", fmt.Errorf("resolve repo: %w", err)
}
for _, r := range out.Resolutions {
if r.Provider != repoProviderEntire || !strings.EqualFold(r.RequestedFullName, fullName) {
continue
}
if r.Status == coreapi.RepoResolutionStatusUnavailable {
break
}
if id := strings.TrimSpace(r.RepoId.Or("")); id != "" {
return id, nil
}
}
return resolveRepoInProject(ctx, c, ref, projID)
return "", noRepoAtPathErr(project, repoName)
}

// resolveRepoPathRef resolves a slash-bearing repo ref: the native
// `/et/<project>/<repo>` path (leading slash optional, `.git` suffix dropped —
// parseNativeCloneRef owns that grammar). The ref names its own project, so a
// --project given alongside it is checked for agreement rather than trusted or
// ignored: a name compares case-insensitively (the server matches lower(name)
// and project names are globally unique), a ULID against the resolved project
// id — neither costs an extra round trip.
// and project names are globally unique) before any request; a ULID compares
// against the resolved repo's owning project, which costs one GetRepo.
func resolveRepoPathRef(ctx context.Context, c repoRefClient, ref, projectRef string) (string, error) {
// The refusal is about the ref SHAPE, not about the command. Several
// commands sharing this resolver DO address mirror repos by ULID — `repo
Expand Down Expand Up @@ -299,22 +330,28 @@ func resolveRepoPathRef(ctx context.Context, c repoRefClient, ref, projectRef st
if projectRef != "" && !looksLikeULID(projectRef) && !strings.EqualFold(projectRef, project) {
return "", projectMismatchErr(projectRef, project, ref)
}
projID, err := resolveProjectRef(ctx, c, project)
repoID, err := resolveNativeRepoByPath(ctx, c, project, repoName)
if err != nil {
return "", err
}
if projectRef != "" && looksLikeULID(projectRef) && !strings.EqualFold(projectRef, projID) {
return "", projectMismatchErr(projectRef, project, ref)
if projectRef != "" && looksLikeULID(projectRef) {
repo, err := c.GetRepo(ctx, coreapi.GetRepoParams{RepoId: repoID})
if err != nil {
return "", fmt.Errorf("get repo: %w", err)
}
if !strings.EqualFold(projectRef, repo.OwningProjectId) {
return "", projectMismatchErr(projectRef, project, ref)
}
}
return resolveRepoInProject(ctx, c, repoName, projID)
return repoID, nil
}

// resolveRepoPath resolves the one repo spelling `repo grant` accepts, the
// native /et/<project>/<repo> path. A ULID or a bare name is refused: the path
// names the repo the way the API and `repo clone` do, and access management
// should not need a lookup to know which project it is touching. The grammar
// is parseNativeCloneRef, shared with clone. Both parsed segments are names by
// construction, so they take the by-name lookups directly rather than
// construction, so they take the path lookup directly rather than
// resolveRepoRef, whose ULID passthrough would read a ULID-shaped NAME as an id.
//
// A ref that never named the et/ token is answered with the accepted shape and
Expand All @@ -331,11 +368,7 @@ func resolveRepoPath(ctx context.Context, c repoRefClient, ref string) (string,
if err != nil {
return "", fmt.Errorf("invalid repo ref %q: %w", ref, err)
}
projID, err := resolveProjectByName(ctx, c, project)
if err != nil {
return "", err
}
return resolveRepoInProject(ctx, c, repoName, projID)
return resolveNativeRepoByPath(ctx, c, project, repoName)
}

func projectMismatchErr(projectRef, project, ref string) error {
Expand Down Expand Up @@ -382,6 +415,10 @@ func noRepoNamedErr(name string) error {
return &namedRefNotFoundError{message: fmt.Sprintf("no repo named %q in that project (run `entire repo list --project <project>` to see names, or pass a ULID)", name)}
}

func noRepoAtPathErr(project, repoName string) error {
return &namedRefNotFoundError{message: fmt.Sprintf("repo /%s/%s/%s not found or not shared with you", nativeCloneForge, project, repoName)}
}

// resolvedRefLabel formats a reference for a success message so it always
// names the resolved ULID. When the user passed a ULID (ref == id) it returns
// the id alone; when they passed a name it returns "name (id)" so the message
Expand Down
Loading
Loading