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
16 changes: 13 additions & 3 deletions docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,19 @@ flags (`--loadbalancer`, `--listener`, `--pool`, `--member`, `--healthmonitor`,
clear-everything behaviour moved to the new `koc loadbalancer quota reset`. This
is a breaking change for anyone who relied on the old flagless `unset`.

One flag deviates rather than a command: `koc dns service list --service-name`,
where upstream spells the same filter `--service_name` — the only underscored flag
in designate's CLI. Both work; the underscored form is registered hidden.
One flag deviates in **spelling** rather than a command: `koc dns service list
--service-name`, where upstream spells the same filter `--service_name` — the only
underscored flag in designate's CLI. Both work; the underscored form is
registered hidden.

One deviates in **semantics**: on `koc user create` and `koc user set`,
`--project-domain` falls back to `--domain` — the user's own domain — when it is
absent, where upstream resolves the default project unscoped across all domains
(`identity/v3/user.py`, `CreateUser`/`SetUser.take_action`). The two differ only
when `--domain` is given and the default project lives in another domain, which
`--project-domain` then states explicitly; scoping to the user's domain is the
safer reading of a project name that is ambiguous cloud-wide, and it keeps the
two user write verbs consistent with each other.

One flag is **koc-native**: `koc image list --name-contains`, a case-insensitive
substring filter applied client-side. Glance's query builder accepts only `in:`
Expand Down
9 changes: 6 additions & 3 deletions internal/cli/identity/flagnames.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ const (
// Flag help strings reused across commands, so the wording stays identical.
const (
helpDomainProject = "domain owning the project (name or ID)"
helpDomainUser = "domain owning the user (name or ID)"
helpDomainRole = "domain the role belongs to (name or ID)"
helpOwningUser = "owning user (name or ID; defaults to the current user)"
// helpDomainDefaultProject qualifies --project on the user write verbs,
// where the flag names the user's default project rather than a scope.
helpDomainDefaultProject = "domain owning --project (name or ID; defaults to --domain)"
helpDomainUser = "domain owning the user (name or ID)"
helpDomainRole = "domain the role belongs to (name or ID)"
helpOwningUser = "owning user (name or ID; defaults to the current user)"
)
46 changes: 32 additions & 14 deletions internal/cli/identity/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,26 @@ type userWriteFlags struct {
disableSet bool
}

// resolveDefaultProjectID turns --project into the ID that goes on the user's
// `default_project_id`. The default project may live in a different domain than
// the user, so it is resolved with its own qualifier; absent --project-domain
// the lookup falls back to the user's domain, which is where upstream leaves it
// unscoped (see docs/coverage.md, "Naming deviations"). An empty --project
// short-circuits inside resolveProjectID, so callers may invoke this
// unconditionally: the resulting empty ID is omitempty on both opts structs and
// leaves the field untouched.
func resolveDefaultProjectID(ctx context.Context, client *gophercloud.ServiceClient, f *userWriteFlags, userDomainID string) (string, error) {
projectDomainID := userDomainID
if f.projectDomain != "" {
var err error
projectDomainID, err = resolveDomainID(ctx, client, f.projectDomain)
if err != nil {
return "", err
}
}
return resolveProjectID(ctx, client, f.project, projectDomainID)
}

func newUserCreateCommand(a *auth.Options, o *output.Options) *cobra.Command {
f := &userWriteFlags{}
cmd := &cobra.Command{
Expand Down Expand Up @@ -153,7 +173,7 @@ func newUserCreateCommand(a *auth.Options, o *output.Options) *cobra.Command {
fl.StringVar(&f.domain, "domain", "", "domain to create the user in (name or ID)")
fl.StringVar(&f.password, "password", "", "user password")
fl.StringVar(&f.project, "project", "", "default project (name or ID)")
fl.StringVar(&f.projectDomain, "project-domain", "", "domain owning --project (name or ID; defaults to --domain)")
fl.StringVar(&f.projectDomain, "project-domain", "", helpDomainDefaultProject)
fl.StringVar(&f.description, "description", "", "user description")
fl.BoolVar(&f.enable, "enable", true, "enable the user (default)")
fl.BoolVar(new(bool), "disable", false, "disable the user")
Expand All @@ -165,16 +185,7 @@ func runUserCreate(ctx context.Context, client *gophercloud.ServiceClient, o *ou
if err != nil {
return err
}
// The default project may live in a different domain than the user; resolve
// it with its own qualifier, falling back to the user's domain.
projectDomainID := domainID
if f.projectDomain != "" {
projectDomainID, err = resolveDomainID(ctx, client, f.projectDomain)
if err != nil {
return err
}
}
projectID, err := resolveProjectID(ctx, client, f.project, projectDomainID)
projectID, err := resolveDefaultProjectID(ctx, client, f, domainID)
if err != nil {
return err
}
Expand Down Expand Up @@ -259,6 +270,8 @@ func newUserSetCommand(a *auth.Options, o *output.Options) *cobra.Command {
fl.StringVar(&f.domain, "domain", "", helpDomainUser)
fl.StringVar(&f.name, "name", "", "new user name")
fl.StringVar(&f.password, "password", "", "new user password")
fl.StringVar(&f.project, "project", "", "new default project (name or ID)")
fl.StringVar(&f.projectDomain, "project-domain", "", helpDomainDefaultProject)
fl.StringVar(&f.description, "description", "", "new user description")
fl.BoolVar(&f.enable, "enable", false, "enable the user")
fl.BoolVar(new(bool), "disable", false, "disable the user")
Expand All @@ -274,10 +287,15 @@ func runUserSet(ctx context.Context, client *gophercloud.ServiceClient, nameOrID
if err != nil {
return err
}
projectID, err := resolveDefaultProjectID(ctx, client, f, domainID)
if err != nil {
return err
}
opts := users.UpdateOpts{
Name: f.name,
Password: f.password,
Enabled: enabledFromFlags(f.enableSet, f.disableSet, f.enable),
Name: f.name,
Password: f.password,
DefaultProjectID: projectID,
Enabled: enabledFromFlags(f.enableSet, f.disableSet, f.enable),
}
if descSet {
opts.Description = &f.description
Expand Down
96 changes: 96 additions & 0 deletions internal/cli/identity/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"net/http"
"net/url"
"strings"
"testing"

Expand Down Expand Up @@ -178,3 +179,98 @@ func TestRunUserSet_ResolvesNameAndPatches(t *testing.T) {
t.Errorf("method = %q, want PATCH", patchMethod)
}
}

// `openstack user set --project <project> <user>` is upstream's way of moving a
// user's default project; koc carried the flag on `create` only until this
// test's subject was wired up.
func TestRunUserSet_ProjectSetsDefaultProjectID(t *testing.T) {
fakeServer := th.SetupHTTP()
defer fakeServer.Teardown()

var projQuery url.Values
fakeServer.Mux.HandleFunc("/users", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"users":[{"id":"u1","name":"admin"}]}`))
})
fakeServer.Mux.HandleFunc("/projects", func(w http.ResponseWriter, r *http.Request) {
projQuery = r.URL.Query()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"admin"}]}`))
})
fakeServer.Mux.HandleFunc("/users/u1", func(w http.ResponseWriter, r *http.Request) {
th.TestJSONRequest(t, r, `{"user":{"default_project_id":"p1"}}`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"user":{"id":"u1","name":"admin","default_project_id":"p1"}}`))
})

client := identityClient(fakeServer)
f := &userWriteFlags{project: "admin"}
if err := runUserSet(context.Background(), client, "admin", f, false); err != nil {
t.Fatalf("runUserSet error: %v", err)
}
if got := projQuery.Get("name"); got != "admin" {
t.Errorf("project resolve name = %q, want admin", got)
}
if got := projQuery.Get("domain_id"); got != "" {
t.Errorf("project resolve domain_id = %q, want empty", got)
}
}

// The default project may live outside the user's domain, so --project-domain
// qualifies the lookup; without it koc falls back to the user's own domain
// (where upstream leaves the lookup unscoped) — see docs/coverage.md.
func TestRunUserSet_ProjectDomainQualifiesLookup(t *testing.T) {
for _, tc := range []struct {
name string
projectDomain string
wantDomainID string
}{
{name: "falls back to the user domain", wantDomainID: "d-user"},
{name: "--project-domain wins", projectDomain: "other", wantDomainID: "d-other"},
} {
t.Run(tc.name, func(t *testing.T) {
fakeServer := th.SetupHTTP()
defer fakeServer.Teardown()

var projDomainID string
fakeServer.Mux.HandleFunc("/domains", func(w http.ResponseWriter, r *http.Request) {
id := "d-user"
if r.URL.Query().Get("name") == "other" {
id = "d-other"
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"domains":[{"id":"` + id + `","name":"x"}]}`))
})
fakeServer.Mux.HandleFunc("/users", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"users":[{"id":"u1","name":"admin"}]}`))
})
fakeServer.Mux.HandleFunc("/projects", func(w http.ResponseWriter, r *http.Request) {
projDomainID = r.URL.Query().Get("domain_id")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"admin"}]}`))
})
fakeServer.Mux.HandleFunc("/users/u1", func(w http.ResponseWriter, r *http.Request) {
th.TestJSONRequest(t, r, `{"user":{"default_project_id":"p1"}}`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"user":{"id":"u1","name":"admin","default_project_id":"p1"}}`))
})

client := identityClient(fakeServer)
f := &userWriteFlags{domain: "default", project: "admin", projectDomain: tc.projectDomain}
if err := runUserSet(context.Background(), client, "admin", f, false); err != nil {
t.Fatalf("runUserSet error: %v", err)
}
if projDomainID != tc.wantDomainID {
t.Errorf("project resolve domain_id = %q, want %q", projDomainID, tc.wantDomainID)
}
})
}
}