diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 000000000..7aeda5c04 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +--- +- id: config-validate + name: Validate CircleCI config + entry: circleci config validate --skip-update-check + language: golang + files: .circleci/config.yml diff --git a/Dockerfile b/Dockerfile index b5194eb1f..7cdf842f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM cimg/go:1.20 +LABEL maintainer="Developer Experience Team " + ENV CIRCLECI_CLI_SKIP_UPDATE_CHECK true COPY ./dist/circleci-cli_linux_amd64/circleci /usr/local/bin diff --git a/README.md b/README.md index e95688599..4980af719 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,9 @@ CircleCI host has been set. Setup complete. Your configuration has been saved. ``` +If you are using this tool on `circleci.com`, accept the provided default `CircleCI Host`. -If you are using this tool on `circleci.com`. accept the provided default `CircleCI Host`. - -Server users will have to change the default value to your custom address (i.e. `circleci.my-org.com`). +Server users will have to change the default value to your custom address (e.g., `circleci.my-org.com`). **Note**: Server does not yet support config processing and orbs, you will only be able to use `circleci local execute` (previously `circleci build`) for now. @@ -153,7 +152,7 @@ The following commands are affected: ## Platforms, Deployment and Package Managers -The tool is deployed through a number of channels. The primary release channel is through [GitHub Releases](https://github.com/CircleCI-Public/circleci-cli/releases). Green builds on the `main` branch will publish a new GitHub release. These releases contain binaries for macOS, Linux and Windows. These releases are published from (CircleCI)[https://app.circleci.com/pipelines/github/CircleCI-Public/circleci-cli] using (GoReleaser)[https://goreleaser.com/]. +The tool is deployed through a number of channels. The primary release channel is through [GitHub Releases](https://github.com/CircleCI-Public/circleci-cli/releases). Green builds on the `main` branch will publish a new GitHub release. These releases contain binaries for macOS, Linux and Windows. These releases are published from (CircleCI)[https://app.circleci.com/pipelines/github/CircleCI-Public/circleci-cli] using [GoReleaser](https://goreleaser.com/). ### Homebrew diff --git a/api/api.go b/api/api.go index ac29d7c1f..f94c64401 100644 --- a/api/api.go +++ b/api/api.go @@ -513,7 +513,7 @@ func WhoamiQuery(cl *graphql.Client) (*WhoamiResponse, error) { } // OrbQuery validated and processes an orb. -func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) { +func OrbQuery(cl *graphql.Client, configPath string, ownerId string) (*ConfigResponse, error) { var response OrbConfigResponse config, err := loadYaml(configPath) @@ -522,8 +522,8 @@ func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) { } query := ` - query ValidateOrb ($config: String!) { - orbConfig(orbYaml: $config) { + query ValidateOrb ($config: String!, $owner: UUID) { + orbConfig(orbYaml: $config, ownerId: $owner) { valid, errors { message }, sourceYaml, @@ -533,6 +533,11 @@ func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) { request := graphql.NewRequest(query) request.Var("config", config) + + if ownerId != "" { + request.Var("owner", ownerId) + } + request.SetToken(cl.Token) err = cl.Run(request, &response) diff --git a/api/collaborators/collaborators.go b/api/collaborators/collaborators.go new file mode 100644 index 000000000..6fecf61f9 --- /dev/null +++ b/api/collaborators/collaborators.go @@ -0,0 +1,14 @@ +package collaborators + +type CollaborationResult struct { + VcsType string `json:"vcs_type"` + OrgSlug string `json:"slug"` + OrgName string `json:"name"` + OrgId string `json:"id"` + AvatarUrl string `json:"avatar_url"` +} + +type CollaboratorsClient interface { + GetCollaborationBySlug(slug string) (*CollaborationResult, error) + GetOrgCollaborations() ([]CollaborationResult, error) +} diff --git a/api/collaborators/collaborators_rest.go b/api/collaborators/collaborators_rest.go new file mode 100644 index 000000000..e15f7774e --- /dev/null +++ b/api/collaborators/collaborators_rest.go @@ -0,0 +1,64 @@ +package collaborators + +import ( + "net/url" + "strings" + + "github.com/CircleCI-Public/circleci-cli/api/rest" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +var ( + CollaborationsPath = "me/collaborations" +) + +type collaboratorsRestClient struct { + client *rest.Client +} + +// NewCollaboratorsRestClient returns a new collaboratorsRestClient satisfying the api.CollaboratorsClient +// interface via the REST API. +func NewCollaboratorsRestClient(config settings.Config) (*collaboratorsRestClient, error) { + client := &collaboratorsRestClient{ + client: rest.NewFromConfig(config.Host, &config), + } + return client, nil +} + +func (c *collaboratorsRestClient) GetOrgCollaborations() ([]CollaborationResult, error) { + req, err := c.client.NewRequest("GET", &url.URL{Path: CollaborationsPath}, nil) + if err != nil { + return nil, err + } + + var resp []CollaborationResult + _, err = c.client.DoRequest(req, &resp) + return resp, err +} + +func (c *collaboratorsRestClient) GetCollaborationBySlug(slug string) (*CollaborationResult, error) { + // Support for / as well as / for the slug + // requires splitting + collaborations, err := c.GetOrgCollaborations() + + if err != nil { + return nil, err + } + + slugParts := strings.Split(slug, "/") + + for _, v := range collaborations { + // The rest-api allways returns / as a slug + if v.OrgSlug == slug { + return &v, nil + } + + // Compare first part of argument slug with the VCSType + splitted := strings.Split(v.OrgSlug, "/") + if len(slugParts) >= 2 && len(splitted) >= 2 && slugParts[0] == v.VcsType && slugParts[1] == splitted[1] { + return &v, nil + } + } + + return nil, nil +} diff --git a/api/collaborators/collaborators_rest_test.go b/api/collaborators/collaborators_rest_test.go new file mode 100644 index 000000000..333539c68 --- /dev/null +++ b/api/collaborators/collaborators_rest_test.go @@ -0,0 +1,380 @@ +package collaborators_test + +import ( + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/CircleCI-Public/circleci-cli/api/collaborators" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/CircleCI-Public/circleci-cli/version" + "gotest.tools/v3/assert" +) + +func getCollaboratorsRestClient(server *httptest.Server) (collaborators.CollaboratorsClient, error) { + client := &http.Client{} + + return collaborators.NewCollaboratorsRestClient(settings.Config{ + RestEndpoint: "api/v2", + Host: server.URL, + HTTPClient: client, + Token: "token", + }) +} + +func Test_collaboratorsRestClient_GetOrgCollaborations(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + want []collaborators.CollaborationResult + wantErr bool + }{ + { + name: "Should handle a successful request", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + want: []collaborators.CollaborationResult{ + { + VcsType: "github", + OrgSlug: "gh/example", + OrgName: "Example Org", + OrgId: "some-uuid-123", + AvatarUrl: "http://placekitten.com/200/300", + }, + { + VcsType: "bitbucket", + OrgSlug: "bb/other", + OrgName: "Other Org", + OrgId: "some-uuid-789", + AvatarUrl: "http://placekitten.com/200/300", + }, + }, + wantErr: false, + }, + { + name: "Should handle an error request", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"message": "error"}`)) + assert.NilError(t, err) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + c, err := getCollaboratorsRestClient(server) + assert.NilError(t, err) + + got, err := c.GetOrgCollaborations() + if (err != nil) != tt.wantErr { + t.Errorf("collaboratorsRestClient.GetOrgCollaborations() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("collaboratorsRestClient.GetOrgCollaborations() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_collaboratorsRestClient_GetCollaborationBySlug(t *testing.T) { + type args struct { + slug string + } + tests := []struct { + name string + handler http.HandlerFunc + args args + want *collaborators.CollaborationResult + wantErr bool + }{ + { + name: "Should work with short-vcs notation (github)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "gh/example", + }, + want: &collaborators.CollaborationResult{ + VcsType: "github", + OrgSlug: "gh/example", + OrgName: "Example Org", + OrgId: "some-uuid-123", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should work with vcs-name notation (github)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "github/example", + }, + want: &collaborators.CollaborationResult{ + VcsType: "github", + OrgSlug: "gh/example", + OrgName: "Example Org", + OrgId: "some-uuid-123", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should work with vcs-short notation (bitbucket)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "bb/other", + }, + want: &collaborators.CollaborationResult{ + VcsType: "bitbucket", + OrgSlug: "bb/other", + OrgName: "Other Org", + OrgId: "some-uuid-789", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should work with vcs-name notation (bitbucket)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "bitbucket/other", + }, + want: &collaborators.CollaborationResult{ + VcsType: "bitbucket", + OrgSlug: "bb/other", + OrgName: "Other Org", + OrgId: "some-uuid-789", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should return nil if not found", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "bad-slug", + }, + want: nil, + wantErr: false, + }, + { + name: "Should error if request errors", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"message": "error"}`)) + assert.NilError(t, err) + }, + args: args{ + slug: "bad-slug", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + c, err := getCollaboratorsRestClient(server) + assert.NilError(t, err) + + got, err := c.GetCollaborationBySlug(tt.args.slug) + if (err != nil) != tt.wantErr { + t.Errorf("collaboratorsRestClient.GetCollaborationBySlug() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("collaboratorsRestClient.GetCollaborationBySlug() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/orb.go b/cmd/orb.go index 33a06c855..1f8ef230c 100644 --- a/cmd/orb.go +++ b/cmd/orb.go @@ -18,6 +18,7 @@ import ( "time" "github.com/CircleCI-Public/circleci-cli/api" + "github.com/CircleCI-Public/circleci-cli/api/collaborators" "github.com/CircleCI-Public/circleci-cli/api/graphql" "github.com/CircleCI-Public/circleci-cli/filetree" "github.com/CircleCI-Public/circleci-cli/process" @@ -43,9 +44,10 @@ import ( ) type orbOptions struct { - cfg *settings.Config - cl *graphql.Client - args []string + cfg *settings.Config + cl *graphql.Client + args []string + collaborators collaborators.CollaboratorsClient color string @@ -62,6 +64,11 @@ type orbOptions struct { integrationTesting bool } +type orbOrgOptions struct { + OrgID string + OrgSlug string +} + var orbAnnotations = map[string]string{ "": "The path to your orb (use \"-\" for STDIN)", "": "The namespace used for the orb (i.e. circleci)", @@ -93,9 +100,16 @@ func (ui createOrbTestUI) askUserToConfirm(message string) bool { } func newOrbCommand(config *settings.Config) *cobra.Command { + collaborators, err := collaborators.NewCollaboratorsRestClient(*config) + + if err != nil { + panic(err) + } + opts := orbOptions{ - cfg: config, - tty: createOrbInteractiveUI{}, + cfg: config, + tty: createOrbInteractiveUI{}, + collaborators: collaborators, } listCommand := &cobra.Command{ @@ -121,13 +135,23 @@ func newOrbCommand(config *settings.Config) *cobra.Command { validateCommand := &cobra.Command{ Use: "validate ", Short: "Validate an orb.yml", - RunE: func(_ *cobra.Command, _ []string) error { - return validateOrb(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + orgID, _ := cmd.Flags().GetString("org-id") + orgSlug, _ := cmd.Flags().GetString("org-slug") + + org := orbOrgOptions{ + OrgID: orgID, + OrgSlug: orgSlug, + } + + return validateOrb(opts, org) }, Args: cobra.ExactArgs(1), Annotations: make(map[string]string), } validateCommand.Annotations[""] = orbAnnotations[""] + validateCommand.Flags().String("org-slug", "", "organization slug (for example: github/example-org), used when an orb depends on private orbs belonging to that org") + validateCommand.Flags().String("org-id", "", "organization id used when an orb depends on private orbs belonging to that org") processCommand := &cobra.Command{ Use: "process ", @@ -141,14 +165,24 @@ func newOrbCommand(config *settings.Config) *cobra.Command { opts.args = args opts.cl = graphql.NewClient(config.HTTPClient, config.Host, config.Endpoint, config.Token, config.Debug) }, - RunE: func(_ *cobra.Command, _ []string) error { - return processOrb(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + orgID, _ := cmd.Flags().GetString("org-id") + orgSlug, _ := cmd.Flags().GetString("org-slug") + + org := orbOrgOptions{ + OrgID: orgID, + OrgSlug: orgSlug, + } + + return processOrb(opts, org) }, Args: cobra.ExactArgs(1), Annotations: make(map[string]string), } processCommand.Example = ` circleci orb process src/my-orb/@orb.yml` processCommand.Annotations[""] = orbAnnotations[""] + processCommand.Flags().String("org-slug", "", "organization slug (for example: github/example-org), used when an orb depends on private orbs belonging to that org") + processCommand.Flags().String("org-id", "", "organization id used when an orb depends on private orbs belonging to that org") publishCommand := &cobra.Command{ Use: "publish ", @@ -691,8 +725,14 @@ func listNamespaceOrbs(opts orbOptions) error { return logOrbs(*orbs, opts) } -func validateOrb(opts orbOptions) error { - _, err := api.OrbQuery(opts.cl, opts.args[0]) +func validateOrb(opts orbOptions, org orbOrgOptions) error { + orgId, err := (&opts).getOrgId(org) + + if err != nil { + return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error()) + } + + _, err = api.OrbQuery(opts.cl, opts.args[0], orgId) if err != nil { return err @@ -707,8 +747,14 @@ func validateOrb(opts orbOptions) error { return nil } -func processOrb(opts orbOptions) error { - response, err := api.OrbQuery(opts.cl, opts.args[0]) +func processOrb(opts orbOptions, org orbOrgOptions) error { + orgId, err := (&opts).getOrgId(org) + + if err != nil { + return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error()) + } + + response, err := api.OrbQuery(opts.cl, opts.args[0], orgId) if err != nil { return err @@ -719,6 +765,7 @@ func processOrb(opts orbOptions) error { } func publishOrb(opts orbOptions) error { + orbInformThatOrbCannotBeDeletedMessage() path := opts.args[0] ref := opts.args[1] namespace, orb, version, err := references.SplitIntoOrbNamespaceAndVersion(ref) @@ -794,6 +841,8 @@ func validateSegmentArg(label string) error { } func incrementOrb(opts orbOptions) error { + orbInformThatOrbCannotBeDeletedMessage() + ref := opts.args[1] segment := opts.args[2] @@ -822,6 +871,8 @@ func incrementOrb(opts orbOptions) error { } func promoteOrb(opts orbOptions) error { + orbInformThatOrbCannotBeDeletedMessage() + ref := opts.args[0] segment := opts.args[1] @@ -1008,6 +1059,7 @@ type OrbSchema struct { type ExampleUsageSchema struct { Version string `yaml:"version,omitempty"` + Setup bool `yaml:"setup,omitempty"` Orbs interface{} `yaml:"orbs,omitempty"` Jobs interface{} `yaml:"jobs,omitempty"` Workflows interface{} `yaml:"workflows"` @@ -1104,6 +1156,8 @@ func initOrb(opts orbOptions) error { var err error fmt.Println("Note: This command is in preview. Please report any bugs! https://github.com/CircleCI-Public/circleci-cli/issues/new/choose") + orbInformThatOrbCannotBeDeletedMessage() + fullyAutomated := 0 prompt := &survey.Select{ Message: "Would you like to perform an automated setup of this orb?", @@ -1734,3 +1788,34 @@ func stringifyDiff(diff gotextdiff.Unified, colorOpt string) string { color.NoColor = oldNoColor return strings.Join(lines, "\n") } + +func orbInformThatOrbCannotBeDeletedMessage() { + fmt.Println("Once an orb is created it cannot be deleted. Orbs are semver compliant, and each published version is immutable. Publicly released orbs are potential dependencies for other projects.") + fmt.Println("Therefore, allowing orb deletion would make users susceptible to unexpected loss of functionality.") +} + +func (o *orbOptions) getOrgId(orgInfo orbOrgOptions) (string, error) { + if strings.TrimSpace(orgInfo.OrgID) != "" { + return orgInfo.OrgID, nil + } + + if strings.TrimSpace(orgInfo.OrgSlug) == "" { + return "", nil + } + + coll, err := o.collaborators.GetCollaborationBySlug(orgInfo.OrgSlug) + + if err != nil { + return "", err + } + + if coll == nil { + fmt.Println("Could not fetch a valid org-id from collaborators endpoint.") + fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations") + fmt.Println("Continuing on - private orb resolution will not work as intended") + + return "", nil + } + + return coll.OrgId, nil +} diff --git a/cmd/orb_test.go b/cmd/orb_test.go index e5a5f42b3..2ab23c889 100644 --- a/cmd/orb_test.go +++ b/cmd/orb_test.go @@ -124,8 +124,8 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs } `json:"variables"` }{ Query: ` - query ValidateOrb ($config: String!) { - orbConfig(orbYaml: $config) { + query ValidateOrb ($config: String!, $owner: UUID) { + orbConfig(orbYaml: $config, ownerId: $owner) { valid, errors { message }, sourceYaml, @@ -184,7 +184,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "{}" } @@ -231,7 +231,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -266,7 +266,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -309,7 +309,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -344,7 +344,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -3311,6 +3311,20 @@ Windows Server 2010 - run: name: Say hello command: <> + +examples: + example: + description: | + An example of how to use the orb. + usage: + version: 2.1 + orbs: + orb-name: company/orb-name@1.2.3 + setup: true + workflows: + create-pipeline: + jobs: + orb-name: create-pipeline-x `)) script = clitest.OpenTmpFile(tempSettings.Home, filepath.Join("scripts", "script.sh")) script.Write([]byte(`echo Hello, world!`)) @@ -3330,10 +3344,7 @@ Windows Server 2010 It("Includes a script in the packed Orb file", func() { session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) Expect(err).ShouldNot(HaveOccurred()) - - Eventually(session.Out).Should(gbytes.Say(`commands: - orb: - steps: + Eventually(session.Out).Should(gbytes.Say(`steps: - run: command: echo Hello, world! name: Say hello @@ -3341,6 +3352,18 @@ Windows Server 2010 Eventually(session).Should(gexec.Exit(0)) }) + It("Includes the setup key when an orb example uses a dynamic pipeline", func() { + session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) + Expect(err).ShouldNot(HaveOccurred()) + Eventually(session.Out).Should(gbytes.Say(`orbs: + orb-name: company/orb-name@1.2.3 + setup: true + version: 2.1 + workflows: +`)) + Eventually(session).Should(gexec.Exit(0)) + }) + }) Describe("Orb diff", func() { diff --git a/config/collaborators.go b/config/collaborators.go deleted file mode 100644 index 30da51d17..000000000 --- a/config/collaborators.go +++ /dev/null @@ -1,39 +0,0 @@ -package config - -import ( - "net/url" -) - -var ( - CollaborationsPath = "me/collaborations" -) - -type CollaborationResult struct { - VcsTye string `json:"vcs_type"` - OrgSlug string `json:"slug"` - OrgName string `json:"name"` - OrgId string `json:"id"` - AvatarUrl string `json:"avatar_url"` -} - -// GetOrgCollaborations - fetches all the collaborations for a given user. -func (c *ConfigCompiler) GetOrgCollaborations() ([]CollaborationResult, error) { - req, err := c.collaboratorRestClient.NewRequest("GET", &url.URL{Path: CollaborationsPath}, nil) - if err != nil { - return nil, err - } - - var resp []CollaborationResult - _, err = c.collaboratorRestClient.DoRequest(req, &resp) - return resp, err -} - -// GetOrgIdFromSlug - converts a slug into an orgID. -func GetOrgIdFromSlug(slug string, collaborations []CollaborationResult) string { - for _, v := range collaborations { - if v.OrgSlug == slug { - return v.OrgId - } - } - return "" -} diff --git a/config/collaborators_test.go b/config/collaborators_test.go deleted file mode 100644 index 115c4f8c9..000000000 --- a/config/collaborators_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package config - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/CircleCI-Public/circleci-cli/settings" - "github.com/stretchr/testify/assert" -) - -func TestGetOrgCollaborations(t *testing.T) { - svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`) - })) - defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) - - t.Run("assert compiler has correct host", func(t *testing.T) { - assert.Equal(t, "http://"+compiler.collaboratorRestClient.BaseURL.Host, svr.URL) - }) - - t.Run("getOrgCollaborations can parse response correctly", func(t *testing.T) { - collabs, err := compiler.GetOrgCollaborations() - assert.NoError(t, err) - assert.Equal(t, 1, len(collabs)) - assert.Equal(t, "circleci", collabs[0].VcsTye) - }) - - t.Run("can fetch orgID from a slug", func(t *testing.T) { - expected := "1234" - actual := GetOrgIdFromSlug("gh/test", []CollaborationResult{{OrgSlug: "gh/test", OrgId: "1234"}}) - assert.Equal(t, expected, actual) - }) - - t.Run("returns empty if no slug match", func(t *testing.T) { - expected := "" - actual := GetOrgIdFromSlug("gh/doesntexist", []CollaborationResult{{OrgSlug: "gh/test", OrgId: "1234"}}) - assert.Equal(t, expected, actual) - }) -} diff --git a/config/commands.go b/config/commands.go index 64504939f..ef3682145 100644 --- a/config/commands.go +++ b/config/commands.go @@ -39,27 +39,29 @@ func (c *ConfigCompiler) getOrgID( optsOrgID string, optsOrgSlug string, ) (string, error) { - if optsOrgID == "" && optsOrgSlug == "" { + if strings.TrimSpace(optsOrgID) != "" { + return optsOrgID, nil + } + + if strings.TrimSpace(optsOrgSlug) == "" { return "", nil } - var orgID string - if strings.TrimSpace(optsOrgID) != "" { - orgID = optsOrgID - } else { - orgs, err := c.GetOrgCollaborations() - if err != nil { - return "", err - } - orgID = GetOrgIdFromSlug(optsOrgSlug, orgs) - if orgID == "" { - fmt.Println("Could not fetch a valid org-id from collaborators endpoint.") - fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations") - fmt.Println("Continuing on - private orb resolution will not work as intended") - } + coll, err := c.collaborators.GetCollaborationBySlug(optsOrgSlug) + + if err != nil { + return "", err + } + + if coll == nil { + fmt.Println("Could not fetch a valid org-id from collaborators endpoint.") + fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations") + fmt.Println("Continuing on - private orb resolution will not work as intended") + + return "", nil } - return orgID, nil + return coll.OrgId, nil } func (c *ConfigCompiler) ProcessConfig(opts ProcessConfigOpts) error { diff --git a/config/config.go b/config/config.go index b8cb5921b..1affc5672 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ import ( "net/url" "os" + "github.com/CircleCI-Public/circleci-cli/api/collaborators" "github.com/CircleCI-Public/circleci-cli/api/graphql" "github.com/CircleCI-Public/circleci-cli/api/rest" "github.com/CircleCI-Public/circleci-cli/settings" @@ -21,9 +22,9 @@ var ( ) type ConfigCompiler struct { - host string - compileRestClient *rest.Client - collaboratorRestClient *rest.Client + host string + compileRestClient *rest.Client + collaborators collaborators.CollaboratorsClient cfg *settings.Config legacyGraphQLClient *graphql.Client @@ -31,11 +32,17 @@ type ConfigCompiler struct { func New(cfg *settings.Config) *ConfigCompiler { hostValue := getCompileHost(cfg.Host) + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + + if err != nil { + panic(err) + } + configCompiler := &ConfigCompiler{ - host: hostValue, - compileRestClient: rest.NewFromConfig(hostValue, cfg), - collaboratorRestClient: rest.NewFromConfig(cfg.Host, cfg), - cfg: cfg, + host: hostValue, + compileRestClient: rest.NewFromConfig(hostValue, cfg), + collaborators: collaboratorsClient, + cfg: cfg, } configCompiler.legacyGraphQLClient = graphql.NewClient(cfg.HTTPClient, cfg.Host, cfg.Endpoint, cfg.Token, cfg.Debug) @@ -119,7 +126,7 @@ func (c *ConfigCompiler) ConfigQuery( return legacyResponse, nil } if originalErr != nil { - return nil, fmt.Errorf("config compilation request returned an error: %w", err) + return nil, fmt.Errorf("config compilation request returned an error: %w", originalErr) } if statusCode != 200 { diff --git a/local/local.go b/local/local.go index 320789c66..32e55320c 100644 --- a/local/local.go +++ b/local/local.go @@ -20,6 +20,7 @@ import ( var picardRepo = "circleci/picard" const DefaultConfigPath = ".circleci/config.yml" +const DefaultDockerSocketPath = "/var/run/docker.sock" func Execute(flags *pflag.FlagSet, cfg *settings.Config, args []string) error { var err error @@ -80,7 +81,8 @@ func Execute(flags *pflag.FlagSet, cfg *settings.Config, args []string) error { } job := args[0] - arguments := generateDockerCommand(processedConfigPath, image, pwd, job, processedArgs...) + dockerSocketPath, _ := flags.GetString("docker-socket-path") + arguments := generateDockerCommand(processedConfigPath, image, pwd, job, dockerSocketPath, processedArgs...) if cfg.Debug { _, err = fmt.Fprintf(os.Stderr, "Starting docker with args: %s", arguments) @@ -114,6 +116,7 @@ func AddFlagsForDocumentation(flags *pflag.FlagSet) { flags.String("branch", "", "Git branch") flags.String("repo-url", "", "Git Url") flags.StringArrayP("env", "e", nil, "Set environment variables, e.g. `-e VAR=VAL`") + flags.String("docker-socket-path", DefaultDockerSocketPath, "Path to the host's docker socket") } // Given the full set of flags that were passed to this command, return the path @@ -131,7 +134,7 @@ func buildAgentArguments(flags *pflag.FlagSet) ([]string, string) { // build a list of all supplied flags, that we will pass on to build-agent flags.Visit(func(flag *pflag.Flag) { - if flag.Name != "build-agent-version" && flag.Name != "org-slug" && flag.Name != "config" && flag.Name != "debug" && flag.Name != "org-id" { + if flag.Name != "build-agent-version" && flag.Name != "org-slug" && flag.Name != "config" && flag.Name != "debug" && flag.Name != "org-id" && flag.Name != "docker-socket-path" { result = append(result, unparseFlag(flags, flag)...) } }) @@ -272,10 +275,10 @@ func writeStringToTempFile(data string) (string, error) { return f.Name(), nil } -func generateDockerCommand(configPath, image, pwd string, job string, arguments ...string) []string { +func generateDockerCommand(configPath, image, pwd string, job string, dockerSocketPath string, arguments ...string) []string { const configPathInsideContainer = "/tmp/local_build_config.yml" core := []string{"docker", "run", "--interactive", "--tty", "--rm", - "--volume", "/var/run/docker.sock:/var/run/docker.sock", + "--volume", fmt.Sprintf("%s:/var/run/docker.sock", dockerSocketPath), "--volume", fmt.Sprintf("%s:%s", configPath, configPathInsideContainer), "--volume", fmt.Sprintf("%s:%s", pwd, pwd), "--volume", fmt.Sprintf("%s:/root/.circleci", settings.SettingsPath()), diff --git a/local/local_test.go b/local/local_test.go index 598806ca9..5a290f722 100644 --- a/local/local_test.go +++ b/local/local_test.go @@ -17,7 +17,7 @@ var _ = Describe("build", func() { It("can generate a command line", func() { home, err := os.UserHomeDir() Expect(err).NotTo(HaveOccurred()) - Expect(generateDockerCommand("/config/path", "docker-image-name", "/current/directory", "build", "extra-1", "extra-2")).To(ConsistOf( + Expect(generateDockerCommand("/config/path", "docker-image-name", "/current/directory", "build", "/var/run/docker.sock", "extra-1", "extra-2")).To(ConsistOf( "docker", "run", "--interactive",