diff --git a/cmd/cloudx/client/sdks.go b/cmd/cloudx/client/sdks.go index 52cf03ed..cd2d3198 100644 --- a/cmd/cloudx/client/sdks.go +++ b/cmd/cloudx/client/sdks.go @@ -112,6 +112,24 @@ func (h *CommandHelper) ProjectAuthToken(ctx context.Context) (oauth2.TokenSourc return config.TokenSource(ctx), CloudAPIsURL, nil } +// NewProjectAPIClient returns an SDK client for the selected project's own API +// (https://.projects.oryapis.com), as opposed to the Ory Console API. +func (h *CommandHelper) NewProjectAPIClient(ctx context.Context) (*cloud.APIClient, error) { + c, baseURL, err := h.newProjectHTTPClient(ctx) + if err != nil { + return nil, err + } + + p, err := h.GetSelectedProject(ctx) + if err != nil { + return nil, err + } + + conf := newSDKConfiguration(baseURL(p.Slug + ".projects").String()) + conf.HTTPClient = c + return cloud.NewAPIClient(conf), nil +} + func (h *CommandHelper) newProjectHTTPClient(ctx context.Context) (*http.Client, func(string) *url.URL, error) { tokenSource, baseURL, err := h.ProjectAuthToken(ctx) if err != nil { diff --git a/cmd/cloudx/project/validate_namespace_config.go b/cmd/cloudx/project/validate_namespace_config.go new file mode 100644 index 00000000..937ad461 --- /dev/null +++ b/cmd/cloudx/project/validate_namespace_config.go @@ -0,0 +1,190 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "fmt" + "io" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ory/cli/cmd/cloudx/client" + cloud "github.com/ory/client-go" + "github.com/ory/x/cmdx" + "github.com/ory/x/osx" +) + +// stdinSource is the value of --file that makes the command read from stdin. +const stdinSource = "-" + +func NewValidateNamespaceConfigCmd() *cobra.Command { + var file string + + cmd := &cobra.Command{ + Use: "opl", + Aliases: []string{ + "namespaces-config", + }, + Args: cobra.NoArgs, + Short: "Validate the syntax of an Ory Permission Language file", + Long: `Validate the syntax of an Ory Permission Language file without applying it. + +The file is checked by the Ory Network project's syntax check endpoint; nothing +is written to the project. The command exits with a non-zero status when the +file has syntax errors, which makes it usable as a CI/CD check before running +` + "`ory update opl`" + `.`, + Example: `$ {{ .CommandPath }} --file /path/to/namespace_config.ts + +The Ory Permission Language file is valid. + +$ cat namespace_config.ts | {{ .CommandPath }} --file - --format json + +{"errors":[{"end":{"Line":1,"column":36},"message":"expected 'permits' or 'related', got \"\"","start":{"Line":1,"column":36}}]}`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + h, err := client.NewCobraCommandHelper(cmd) + if err != nil { + return err + } + + data, err := readOPLSource(cmd, file) + if err != nil { + return err + } + + c, err := h.NewProjectAPIClient(ctx) + if err != nil { + return err + } + + var errs oplSyntaxErrors + // An empty file has no syntax errors, and the SDK refuses to send a + // zero-length body ("invalid body type text/plain"). + if len(data) > 0 { + result, _, err := c.RelationshipAPI.CheckOplSyntax(ctx).Body(string(data)).Execute() + if err != nil { + return cmdx.PrintOpenAPIError(cmd, err) + } + // GetErrors is nil-safe: result is nil if the API replies with an empty body. + errs = oplSyntaxErrors(result.GetErrors()) + } + + switch outputFormat(cmd) { + case cmdx.FormatJSON, cmdx.FormatJSONPretty, cmdx.FormatJSONPath, cmdx.FormatJSONPointer, cmdx.FormatYAML: + cmdx.PrintTable(cmd, errs) + default: + // Everything else (including an unknown value, which cmdx itself + // treats as the default format) is rendered for humans. + source := sourceName(file) + for _, parseErr := range errs { + // Syntax errors are what the command is for, so they are also + // printed with --quiet; only the success message is noise. + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), formatParseError(source, parseErr)) + } + if len(errs) == 0 { + _, _ = cmdx.NewLoudOutPrinter(cmd).Println("The Ory Permission Language file is valid.") + } + } + + if len(errs) > 0 { + return cmdx.FailSilently(cmd) + } + return nil + }, + } + + cmd.Flags().StringVarP(&file, "file", "f", "", + `Ory Permission Language file (namespace_config.ts, https://example.org/namespace_config.ts, "-" for stdin, ...) to validate`) + _ = cmd.MarkFlagRequired("file") + client.RegisterProjectFlag(cmd.Flags()) + client.RegisterWorkspaceFlag(cmd.Flags()) + + return cmd +} + +// readOPLSource reads the OPL file from stdin when source is "-", and from any +// source supported by osx otherwise. +func readOPLSource(cmd *cobra.Command, source string) ([]byte, error) { + if source == stdinSource { + return io.ReadAll(cmd.InOrStdin()) + } + return osx.ReadFileFromAllSources(source) +} + +func sourceName(source string) string { + if source == stdinSource { + return "stdin" + } + return source +} + +// outputFormat mirrors how cmdx picks the output format, so that the +// human-readable rendering below is only used for the human-readable formats. +func outputFormat(cmd *cobra.Command) cmdx.Format { + if quiet, err := cmd.Flags().GetBool(cmdx.FlagQuiet); err == nil && quiet { + return cmdx.FormatQuiet + } + format, err := cmd.Flags().GetString(cmdx.FlagFormat) + if err != nil { + return cmdx.FormatDefault + } + // jsonpath and jsonpointer carry their argument in the flag value. + name, _, _ := strings.Cut(format, "=") + return cmdx.Format(name) +} + +// formatParseError renders a parse error the way compilers do, so that editors +// and CI log parsers can pick up the location. +func formatParseError(source string, err cloud.ParseError) string { + location := source + // The column is only appended together with the line, otherwise it would be + // rendered in the position where readers expect the line number. + if start := err.Start; start != nil && start.Line != nil { + location += ":" + strconv.FormatInt(*start.Line, 10) + if start.Column != nil { + location += ":" + strconv.FormatInt(*start.Column, 10) + } + } + return fmt.Sprintf("%s: %s", location, err.GetMessage()) +} + +type oplSyntaxErrors []cloud.ParseError + +var _ cmdx.Table = (oplSyntaxErrors)(nil) + +func (oplSyntaxErrors) Header() []string { + return []string{"LINE", "COLUMN", "MESSAGE"} +} + +func (e oplSyntaxErrors) Table() [][]string { + rows := make([][]string, len(e)) + for i, parseErr := range e { + line, column := cmdx.None, cmdx.None + if start := parseErr.Start; start != nil { + if start.Line != nil { + line = strconv.FormatInt(*start.Line, 10) + } + if start.Column != nil { + column = strconv.FormatInt(*start.Column, 10) + } + } + rows[i] = []string{line, column, parseErr.GetMessage()} + } + return rows +} + +func (e oplSyntaxErrors) Interface() interface{} { + if e == nil { + // The field is omitted when nil, but CI/CD consumers should be able to + // rely on "errors" always being present, e.g. `jq '.errors | length'`. + e = oplSyntaxErrors{} + } + return cloud.CheckOplSyntaxResult{Errors: e} +} + +func (e oplSyntaxErrors) Len() int { + return len(e) +} diff --git a/cmd/cloudx/project/validate_namespace_config_test.go b/cmd/cloudx/project/validate_namespace_config_test.go new file mode 100644 index 00000000..32b73555 --- /dev/null +++ b/cmd/cloudx/project/validate_namespace_config_test.go @@ -0,0 +1,148 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package project_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/ory/x/cmdx" +) + +const ( + validOPL = `class Default implements Namespace {}` + invalidOPL = `class Default implements Namespace {` +) + +func TestValidateNamespaceConfig(t *testing.T) { + if testing.Short() { + // this test needs internet, typically not available when you're on a (german) train + return + } + + t.Parallel() + + t.Run("accepts a valid file", func(t *testing.T) { + t.Parallel() + + config := writeFile(t, validOPL) + validate := func(t *testing.T, exec execFunc) { + stdout, stderr, err := exec(nil, "validate", "opl", "--file", config) + require.NoError(t, err, stderr) + // not just "valid", which is also a substring of "invalid" + assert.Contains(t, stdout, "file is valid") + assert.Empty(t, stderr) + } + + runWithProjectAsDefault(ctx, t, defaultProject.Id, validate) + runWithProjectAsFlag(ctx, t, extraProject.Id, validate) + }) + + t.Run("reports syntax errors and exits non-zero", func(t *testing.T) { + t.Parallel() + + config := writeFile(t, invalidOPL) + validate := func(t *testing.T, exec execFunc) { + stdout, stderr, err := exec(nil, "validate", "opl", "--file", config) + assert.ErrorIs(t, err, cmdx.ErrNoPrintButFail, stderr) + assert.Empty(t, stdout) + // the location is rendered as "::: " + assert.Contains(t, stderr, config+":", stderr) + } + + runWithProjectAsDefault(ctx, t, defaultProject.Id, validate) + runWithProjectAsFlag(ctx, t, extraProject.Id, validate) + }) + + t.Run("reports syntax errors as JSON", func(t *testing.T) { + t.Parallel() + + config := writeFile(t, invalidOPL) + validate := func(t *testing.T, exec execFunc) { + stdout, stderr, err := exec(nil, "validate", "opl", "--file", config, "--format", "json") + assert.ErrorIs(t, err, cmdx.ErrNoPrintButFail, stderr) + + errs := gjson.Get(stdout, "errors") + require.True(t, errs.IsArray(), stdout) + require.NotEmpty(t, errs.Array(), stdout) + assert.NotEmpty(t, errs.Array()[0].Get("message").String(), stdout) + } + + runWithProjectAsDefault(ctx, t, defaultProject.Id, validate) + runWithProjectAsFlag(ctx, t, extraProject.Id, validate) + }) + + t.Run("reports an empty error list as JSON", func(t *testing.T) { + t.Parallel() + + config := writeFile(t, validOPL) + validate := func(t *testing.T, exec execFunc) { + stdout, stderr, err := exec(nil, "validate", "opl", "--file", config, "--format", "json") + require.NoError(t, err, stderr) + + // the key is always present, so that `jq '.errors | length'` works + errs := gjson.Get(stdout, "errors") + require.True(t, errs.IsArray(), stdout) + assert.Empty(t, errs.Array(), stdout) + } + + runWithProjectAsDefault(ctx, t, defaultProject.Id, validate) + }) + + t.Run("accepts an empty file", func(t *testing.T) { + t.Parallel() + + config := writeFile(t, "") + validate := func(t *testing.T, exec execFunc) { + stdout, stderr, err := exec(nil, "validate", "opl", "--file", config) + require.NoError(t, err, stderr) + assert.Contains(t, stdout, "file is valid") + } + + runWithProjectAsDefault(ctx, t, defaultProject.Id, validate) + }) + + t.Run("reads the file from stdin", func(t *testing.T) { + t.Parallel() + + validate := func(t *testing.T, exec execFunc) { + stdout, stderr, err := exec(strings.NewReader(validOPL), "validate", "opl", "--file", "-") + require.NoError(t, err, stderr) + assert.Contains(t, stdout, "file is valid") + + // errors are reported against "stdin", not against the literal "-" + _, stderr, err = exec(strings.NewReader(invalidOPL), "validate", "opl", "--file", "-") + assert.ErrorIs(t, err, cmdx.ErrNoPrintButFail, stderr) + assert.Contains(t, stderr, "stdin:", stderr) + } + + runWithProjectAsDefault(ctx, t, defaultProject.Id, validate) + runWithProjectAsFlag(ctx, t, extraProject.Id, validate) + }) + + t.Run("stays quiet on success but still reports syntax errors", func(t *testing.T) { + t.Parallel() + + valid, invalid := writeFile(t, validOPL), writeFile(t, invalidOPL) + validate := func(t *testing.T, exec execFunc) { + stdout, stderr, err := exec(nil, "validate", "opl", "--file", valid, "--quiet") + require.NoError(t, err, stderr) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + + // --quiet silences the success message, not the syntax errors + stdout, stderr, err = exec(nil, "validate", "opl", "--file", invalid, "--quiet") + assert.ErrorIs(t, err, cmdx.ErrNoPrintButFail, stderr) + assert.Empty(t, stdout) + assert.Contains(t, stderr, invalid+":", stderr) + } + + runWithProjectAsDefault(ctx, t, defaultProject.Id, validate) + runWithProjectAsFlag(ctx, t, extraProject.Id, validate) + }) +} diff --git a/cmd/cloudx/validate.go b/cmd/cloudx/validate.go index 0f7422fd..226e8275 100644 --- a/cmd/cloudx/validate.go +++ b/cmd/cloudx/validate.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/ory/cli/cmd/cloudx/client" + "github.com/ory/cli/cmd/cloudx/project" "github.com/ory/kratos/cmd/identities" "github.com/ory/x/cmdx" ) @@ -17,7 +18,10 @@ func NewValidateCmd() *cobra.Command { Short: "Validate resources", } - cmd.AddCommand(identities.NewValidateIdentityCmd()) + cmd.AddCommand( + identities.NewValidateIdentityCmd(), + project.NewValidateNamespaceConfigCmd(), + ) client.RegisterConfigFlag(cmd.PersistentFlags()) client.RegisterYesFlag(cmd.PersistentFlags())