diff --git a/cmd/agola/cmd/agola.go b/cmd/agola/cmd/agola.go index 6d1b01d..25e4526 100644 --- a/cmd/agola/cmd/agola.go +++ b/cmd/agola/cmd/agola.go @@ -30,6 +30,8 @@ var level = zap.NewAtomicLevelAt(zapcore.InfoLevel) var logger = slog.New(level) var log = logger.Sugar() +var token string + var cmdAgola = &cobra.Command{ Use: "agola", Short: "agola", @@ -68,6 +70,7 @@ func init() { flags := cmdAgola.PersistentFlags() flags.StringVarP(&agolaOpts.gatewayURL, "gateway-url", "u", gatewayURL, "agola gateway exposed url") + flags.StringVar(&token, "token", token, "api token") flags.BoolVarP(&agolaOpts.debug, "debug", "d", false, "debug") } diff --git a/cmd/agola/cmd/project.go b/cmd/agola/cmd/project.go new file mode 100644 index 0000000..abe1d87 --- /dev/null +++ b/cmd/agola/cmd/project.go @@ -0,0 +1,28 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var cmdProject = &cobra.Command{ + Use: "project", + Short: "project", +} + +func init() { + cmdAgola.AddCommand(cmdProject) +} diff --git a/cmd/agola/cmd/projectcreate.go b/cmd/agola/cmd/projectcreate.go new file mode 100644 index 0000000..bd9c74e --- /dev/null +++ b/cmd/agola/cmd/projectcreate.go @@ -0,0 +1,78 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + + "github.com/pkg/errors" + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdProjectCreate = &cobra.Command{ + Use: "create", + Short: "create a project", + Run: func(cmd *cobra.Command, args []string) { + if err := projectCreate(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, +} + +type projectCreateOptions struct { + name string + repoURL string + remoteSourceName string + skipSSHHostKeyCheck bool +} + +var projectCreateOpts projectCreateOptions + +func init() { + flags := cmdProjectCreate.Flags() + + flags.StringVarP(&projectCreateOpts.name, "name", "n", "", "project name") + flags.StringVar(&projectCreateOpts.repoURL, "repo-url", "", "repository url") + flags.StringVar(&projectCreateOpts.remoteSourceName, "remote-source", "", "remote source name") + flags.BoolVarP(&projectCreateOpts.skipSSHHostKeyCheck, "skip-ssh-host-key-check", "s", false, "skip ssh host key check") + + cmdProjectCreate.MarkFlagRequired("name") + cmdProjectCreate.MarkFlagRequired("repo-url") + cmdProjectCreate.MarkFlagRequired("remote-source") + + cmdProject.AddCommand(cmdProjectCreate) +} + +func projectCreate(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + req := &api.CreateProjectRequest{ + Name: projectCreateOpts.name, + RepoURL: projectCreateOpts.repoURL, + RemoteSourceName: projectCreateOpts.remoteSourceName, + SkipSSHHostKeyCheck: projectCreateOpts.skipSSHHostKeyCheck, + } + + log.Infof("creating project") + project, _, err := gwclient.CreateProject(context.TODO(), req) + if err != nil { + return errors.Wrapf(err, "failed to create project") + } + log.Infof("project %s created, ID: %s", project.Name, project.ID) + + return nil +} diff --git a/cmd/agola/cmd/projectdelete.go b/cmd/agola/cmd/projectdelete.go new file mode 100644 index 0000000..5befd4d --- /dev/null +++ b/cmd/agola/cmd/projectdelete.go @@ -0,0 +1,61 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + + "github.com/pkg/errors" + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdProjectDelete = &cobra.Command{ + Use: "delete", + Short: "delete a project", + Run: func(cmd *cobra.Command, args []string) { + if err := projectDelete(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, +} + +type projectDeleteOptions struct { + name string +} + +var projectDeleteOpts projectDeleteOptions + +func init() { + flags := cmdProjectDelete.Flags() + + flags.StringVarP(&projectDeleteOpts.name, "name", "n", "", "project name") + + cmdProjectDelete.MarkFlagRequired("name") + + cmdProject.AddCommand(cmdProjectDelete) +} + +func projectDelete(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + log.Infof("deleting project") + if _, err := gwclient.DeleteProject(context.TODO(), projectDeleteOpts.name); err != nil { + return errors.Wrapf(err, "failed to delete project") + } + + return nil +} diff --git a/cmd/agola/cmd/projectlist.go b/cmd/agola/cmd/projectlist.go new file mode 100644 index 0000000..2da4259 --- /dev/null +++ b/cmd/agola/cmd/projectlist.go @@ -0,0 +1,68 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + + "github.com/sorintlab/agola/internal/services/gateway/api" + "github.com/spf13/cobra" +) + +var cmdProjectList = &cobra.Command{ + Use: "list", + Run: func(cmd *cobra.Command, args []string) { + if err := projectList(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, + Short: "list", +} + +type projectListOptions struct { + limit int + start string +} + +var projectListOpts projectListOptions + +func init() { + flags := cmdProjectList.PersistentFlags() + + flags.IntVar(&projectListOpts.limit, "limit", 10, "max number of runs to show") + flags.StringVar(&projectListOpts.start, "start", "", "starting project name (excluded) to fetch") + + cmdProject.AddCommand(cmdProjectList) +} + +func printProjects(projectsResponse *api.GetProjectsResponse) { + for _, project := range projectsResponse.Projects { + fmt.Printf("%s: Name: %s\n", project.ID, project.Name) + } +} + +func projectList(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + projectsResponse, _, err := gwclient.GetProjects(context.TODO(), projectListOpts.start, projectListOpts.limit, false) + if err != nil { + return err + } + + printProjects(projectsResponse) + + return nil +} diff --git a/cmd/agola/cmd/projectreconfig.go b/cmd/agola/cmd/projectreconfig.go new file mode 100644 index 0000000..73dabc0 --- /dev/null +++ b/cmd/agola/cmd/projectreconfig.go @@ -0,0 +1,62 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + + "github.com/pkg/errors" + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdProjectReconfig = &cobra.Command{ + Use: "reconfig", + Short: "reconfigures a project remote (reinstalls ssh deploy key and webhooks", + Run: func(cmd *cobra.Command, args []string) { + if err := projectReconfig(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, +} + +type projectReconfigOptions struct { + name string +} + +var projectReconfigOpts projectReconfigOptions + +func init() { + flags := cmdProjectReconfig.Flags() + + flags.StringVarP(&projectReconfigOpts.name, "name", "n", "", "project name") + + cmdProjectReconfig.MarkFlagRequired("name") + + cmdProject.AddCommand(cmdProjectReconfig) +} + +func projectReconfig(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + log.Infof("reconfiguring remote project") + if _, err := gwclient.ReconfigProject(context.TODO(), projectReconfigOpts.name); err != nil { + return errors.Wrapf(err, "failed to reconfigure remote project") + } + log.Infof("project reconfigured") + + return nil +} diff --git a/cmd/agola/cmd/remotesource.go b/cmd/agola/cmd/remotesource.go new file mode 100644 index 0000000..3bd8a1d --- /dev/null +++ b/cmd/agola/cmd/remotesource.go @@ -0,0 +1,28 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var cmdRemoteSource = &cobra.Command{ + Use: "remotesource", + Short: "remotesource", +} + +func init() { + cmdAgola.AddCommand(cmdRemoteSource) +} diff --git a/cmd/agola/cmd/remotesourcecreate.go b/cmd/agola/cmd/remotesourcecreate.go new file mode 100644 index 0000000..29a88dc --- /dev/null +++ b/cmd/agola/cmd/remotesourcecreate.go @@ -0,0 +1,85 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + + "github.com/pkg/errors" + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdRemoteSourceCreate = &cobra.Command{ + Use: "create", + Short: "create a remotesource", + Run: func(cmd *cobra.Command, args []string) { + if err := remoteSourceCreate(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, +} + +type remoteSourceCreateOptions struct { + name string + rsType string + authType string + apiURL string + oauth2ClientID string + oauth2ClientSecret string +} + +var remoteSourceCreateOpts remoteSourceCreateOptions + +func init() { + flags := cmdRemoteSourceCreate.Flags() + + flags.StringVarP(&remoteSourceCreateOpts.name, "name", "n", "", "remotesource name") + flags.StringVar(&remoteSourceCreateOpts.rsType, "type", "", "remotesource type") + flags.StringVar(&remoteSourceCreateOpts.authType, "auth-type", "", "remote source auth type") + flags.StringVar(&remoteSourceCreateOpts.apiURL, "api-url", "", "remotesource api url") + flags.StringVar(&remoteSourceCreateOpts.oauth2ClientID, "clientid", "", "remotesource oauth2 client id") + flags.StringVar(&remoteSourceCreateOpts.oauth2ClientSecret, "secret", "", "remotesource oauth2 secret") + + cmdRemoteSourceCreate.MarkFlagRequired("name") + cmdRemoteSourceCreate.MarkFlagRequired("type") + cmdRemoteSourceCreate.MarkFlagRequired("auth-type") + cmdRemoteSourceCreate.MarkFlagRequired("api-url") + + cmdRemoteSource.AddCommand(cmdRemoteSourceCreate) +} + +func remoteSourceCreate(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + req := &api.CreateRemoteSourceRequest{ + Name: remoteSourceCreateOpts.name, + Type: remoteSourceCreateOpts.rsType, + AuthType: remoteSourceCreateOpts.authType, + APIURL: remoteSourceCreateOpts.apiURL, + Oauth2ClientID: remoteSourceCreateOpts.oauth2ClientID, + Oauth2ClientSecret: remoteSourceCreateOpts.oauth2ClientSecret, + } + + log.Infof("creating remotesource") + remoteSource, _, err := gwclient.CreateRemoteSource(context.TODO(), req) + if err != nil { + return errors.Wrapf(err, "failed to create remotesource") + } + log.Infof("remotesource %s created, ID: %s", remoteSource.Name, remoteSource.ID) + + return nil +} diff --git a/cmd/agola/cmd/remotesourcelist.go b/cmd/agola/cmd/remotesourcelist.go new file mode 100644 index 0000000..da6e4d5 --- /dev/null +++ b/cmd/agola/cmd/remotesourcelist.go @@ -0,0 +1,68 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + + "github.com/sorintlab/agola/internal/services/gateway/api" + "github.com/spf13/cobra" +) + +var cmdRemoteSourceList = &cobra.Command{ + Use: "list", + Run: func(cmd *cobra.Command, args []string) { + if err := remoteSourceList(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, + Short: "list", +} + +type remoteSourceListOptions struct { + limit int + start string +} + +var remoteSourceListOpts remoteSourceListOptions + +func init() { + flags := cmdRemoteSourceList.PersistentFlags() + + flags.IntVar(&remoteSourceListOpts.limit, "limit", 10, "max number of runs to show") + flags.StringVar(&remoteSourceListOpts.start, "start", "", "starting user name (excluded) to fetch") + + cmdRemoteSource.AddCommand(cmdRemoteSourceList) +} + +func printRemoteSources(rssResponse *api.RemoteSourcesResponse) { + for _, rs := range rssResponse.RemoteSources { + fmt.Printf("%s: Name: %s\n", rs.ID, rs.Name) + } +} + +func remoteSourceList(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + rssResponse, _, err := gwclient.GetRemoteSources(context.TODO(), remoteSourceListOpts.start, remoteSourceListOpts.limit, false) + if err != nil { + return err + } + + printRemoteSources(rssResponse) + + return nil +} diff --git a/cmd/agola/cmd/run.go b/cmd/agola/cmd/run.go new file mode 100644 index 0000000..72d1d78 --- /dev/null +++ b/cmd/agola/cmd/run.go @@ -0,0 +1,28 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var cmdRun = &cobra.Command{ + Use: "run", + Short: "run", +} + +func init() { + cmdAgola.AddCommand(cmdRun) +} diff --git a/cmd/agola/cmd/runlist.go b/cmd/agola/cmd/runlist.go new file mode 100644 index 0000000..a30fa8e --- /dev/null +++ b/cmd/agola/cmd/runlist.go @@ -0,0 +1,85 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdRunList = &cobra.Command{ + Use: "list", + Run: func(cmd *cobra.Command, args []string) { + if err := runList(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, + Short: "list", +} + +type runListOptions struct { + statusFilter []string + labelFilter []string + limit int + start string +} + +var runListOpts runListOptions + +func init() { + flags := cmdRunList.PersistentFlags() + + flags.StringSliceVarP(&runListOpts.statusFilter, "status", "s", nil, "filter runs matching the provided status. This option can be repeated multiple times") + flags.StringArrayVarP(&runListOpts.labelFilter, "label", "l", nil, "filter runs matching the provided label. This option can be repeated multiple times, in this case only runs matching all the labels will be returned") + flags.IntVar(&runListOpts.limit, "limit", 10, "max number of runs to show") + flags.StringVar(&runListOpts.start, "start", "", "starting run id (excluded) to fetch") + + cmdRun.AddCommand(cmdRunList) +} + +func printRuns(runs []*api.RunResponse) { + for _, run := range runs { + fmt.Printf("%s: Phase: %s, Result: %s\n", run.ID, run.Phase, run.Result) + for _, task := range run.Tasks { + fmt.Printf("\tTaskName: %s, Status: %s\n", task.Name, task.Status) + } + } +} + +func runList(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + runsResp, _, err := gwclient.GetRuns(context.TODO(), runListOpts.statusFilter, runListOpts.labelFilter, []string{}, runListOpts.start, runListOpts.limit, false) + if err != nil { + return err + } + + runs := make([]*api.RunResponse, len(runsResp.Runs)) + for i, runsResponse := range runsResp.Runs { + run, _, err := gwclient.GetRun(context.TODO(), runsResponse.ID) + if err != nil { + return err + } + runs[i] = run + } + + printRuns(runs) + + return nil +} diff --git a/cmd/agola/cmd/serve.go b/cmd/agola/cmd/serve.go index 6f88d5f..c3ed7eb 100644 --- a/cmd/agola/cmd/serve.go +++ b/cmd/agola/cmd/serve.go @@ -21,6 +21,7 @@ import ( "github.com/sorintlab/agola/cmd" "github.com/sorintlab/agola/internal/services/config" "github.com/sorintlab/agola/internal/services/configstore" + "github.com/sorintlab/agola/internal/services/gateway" "github.com/sorintlab/agola/internal/services/runservice/executor" rsscheduler "github.com/sorintlab/agola/internal/services/runservice/scheduler" "github.com/sorintlab/agola/internal/services/scheduler" @@ -127,11 +128,17 @@ func serve(cmd *cobra.Command, args []string) error { return errors.Wrapf(err, "failed to start scheduler") } + gateway, err := gateway.NewGateway(&c.Gateway) + if err != nil { + return errors.Wrapf(err, "failed to start gateway") + } + errCh := make(chan error) go func() { errCh <- rsex1.Run(ctx) }() go func() { errCh <- rssched1.Run(ctx) }() go func() { errCh <- cs.Run(ctx) }() + go func() { errCh <- gateway.Run(ctx) }() go func() { errCh <- sched1.Run(ctx) }() return <-errCh diff --git a/cmd/agola/cmd/user.go b/cmd/agola/cmd/user.go new file mode 100644 index 0000000..e16569a --- /dev/null +++ b/cmd/agola/cmd/user.go @@ -0,0 +1,28 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var cmdUser = &cobra.Command{ + Use: "user", + Short: "user", +} + +func init() { + cmdAgola.AddCommand(cmdUser) +} diff --git a/cmd/agola/cmd/usercreate.go b/cmd/agola/cmd/usercreate.go new file mode 100644 index 0000000..21c3a67 --- /dev/null +++ b/cmd/agola/cmd/usercreate.go @@ -0,0 +1,69 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + + "github.com/pkg/errors" + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdUserCreate = &cobra.Command{ + Use: "create", + Short: "create a user", + Run: func(cmd *cobra.Command, args []string) { + if err := userCreate(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, +} + +type userCreateOptions struct { + username string +} + +var userCreateOpts userCreateOptions + +func init() { + flags := cmdUserCreate.Flags() + + flags.StringVarP(&userCreateOpts.username, "username", "n", "", "user name") + + cmdUserCreate.MarkFlagRequired("username") + cmdUserCreate.MarkFlagRequired("repo-url") + cmdUserCreate.MarkFlagRequired("token") + + cmdUser.AddCommand(cmdUserCreate) +} + +func userCreate(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + req := &api.CreateUserRequest{ + UserName: userCreateOpts.username, + } + + log.Infof("creating user") + user, _, err := gwclient.CreateUser(context.TODO(), req) + if err != nil { + return errors.Wrapf(err, "failed to create user") + } + log.Infof("user %q created, ID: %q", user.UserName, user.ID) + + return nil +} diff --git a/cmd/agola/cmd/userdelete.go b/cmd/agola/cmd/userdelete.go new file mode 100644 index 0000000..39a21dd --- /dev/null +++ b/cmd/agola/cmd/userdelete.go @@ -0,0 +1,61 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + + "github.com/pkg/errors" + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdUserDelete = &cobra.Command{ + Use: "delete", + Short: "delete a user", + Run: func(cmd *cobra.Command, args []string) { + if err := userDelete(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, +} + +type userDeleteOptions struct { + username string +} + +var userDeleteOpts userDeleteOptions + +func init() { + flags := cmdUserDelete.Flags() + + flags.StringVarP(&userDeleteOpts.username, "username", "n", "", "user name") + + cmdUserDelete.MarkFlagRequired("username") + + cmdUser.AddCommand(cmdUserDelete) +} + +func userDelete(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + log.Infof("deleting user %q", userDeleteOpts.username) + if _, err := gwclient.DeleteUser(context.TODO(), userDeleteOpts.username); err != nil { + return errors.Wrapf(err, "failed to delete user") + } + + return nil +} diff --git a/cmd/agola/cmd/userla.go b/cmd/agola/cmd/userla.go new file mode 100644 index 0000000..899aea3 --- /dev/null +++ b/cmd/agola/cmd/userla.go @@ -0,0 +1,28 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var cmdUserLA = &cobra.Command{ + Use: "linkedaccount", + Short: "linkedaccount", +} + +func init() { + cmdUser.AddCommand(cmdUserLA) +} diff --git a/cmd/agola/cmd/userlacreate.go b/cmd/agola/cmd/userlacreate.go new file mode 100644 index 0000000..2efa58b --- /dev/null +++ b/cmd/agola/cmd/userlacreate.go @@ -0,0 +1,98 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/pkg/errors" + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdUserLACreate = &cobra.Command{ + Use: "create", + Short: "create a user linkedaccount", + Run: func(cmd *cobra.Command, args []string) { + if err := userLACreate(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, +} + +type userLACreateOptions struct { + username string + remoteSourceName string + remoteSourceLoginName string + remoteSourceLoginPassword string +} + +var userLACreateOpts userLACreateOptions + +func init() { + flags := cmdUserLACreate.Flags() + + flags.StringVarP(&userLACreateOpts.username, "username", "n", "", "user name") + flags.StringVarP(&userLACreateOpts.remoteSourceName, "remote-source", "r", "", "remote source name") + flags.StringVar(&userLACreateOpts.remoteSourceLoginName, "remote-name", "", "remote source login name") + flags.StringVar(&userLACreateOpts.remoteSourceLoginPassword, "remote-password", "", "remote source password") + + cmdUserLACreate.MarkFlagRequired("username") + cmdUserLACreate.MarkFlagRequired("remote-source") + + cmdUserLA.AddCommand(cmdUserLACreate) +} + +func userLACreate(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + req := &api.CreateUserLARequest{ + RemoteSourceName: userLACreateOpts.remoteSourceName, + RemoteSourceLoginName: userLACreateOpts.remoteSourceLoginName, + RemoteSourceLoginPassword: userLACreateOpts.remoteSourceLoginPassword, + } + + log.Infof("creating linked account for user %q", userLACreateOpts.username) + resp, _, err := gwclient.CreateUserLA(context.TODO(), userLACreateOpts.username, req) + if err != nil { + return errors.Wrapf(err, "failed to create linked account") + } + if resp.Oauth2Redirect != "" { + log.Infof("visit %s", resp.Oauth2Redirect) + + reader := bufio.NewReader(os.Stdin) + fmt.Print("Enter code: ") + code, _ := reader.ReadString('\n') + code = strings.TrimSpace(code) + log.Infof("code: %s", code) + + req := &api.CreateUserLARequest{ + RemoteSourceName: userLACreateOpts.remoteSourceName, + } + resp, _, err = gwclient.CreateUserLA(context.TODO(), userLACreateOpts.username, req) + if err != nil { + return errors.Wrapf(err, "failed to create linked account") + } + } + + log.Infof("linked account for user %q created, ID: %s", userLACreateOpts.username, resp.LinkedAccount.ID) + + return nil +} diff --git a/cmd/agola/cmd/userladelete.go b/cmd/agola/cmd/userladelete.go new file mode 100644 index 0000000..f77ef82 --- /dev/null +++ b/cmd/agola/cmd/userladelete.go @@ -0,0 +1,70 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + + "github.com/pkg/errors" + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdUserLADelete = &cobra.Command{ + Use: "delete", + Short: "delete a user linkedaccount", + Run: func(cmd *cobra.Command, args []string) { + if err := userLADelete(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, +} + +type userLADeleteOptions struct { + userName string + laID string +} + +var userLADeleteOpts userLADeleteOptions + +func init() { + flags := cmdUserLADelete.Flags() + + flags.StringVarP(&userLADeleteOpts.userName, "username", "n", "", "user name") + flags.StringVar(&userLADeleteOpts.laID, "laid", "", "linked account id") + + cmdUserLADelete.MarkFlagRequired("username") + cmdUserLADelete.MarkFlagRequired("laid") + + cmdUserLA.AddCommand(cmdUserLADelete) +} + +func userLADelete(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + userName := userLADeleteOpts.userName + laID := userLADeleteOpts.laID + + log.Infof("deleting linked account %s for user %q", userName) + _, err := gwclient.DeleteUserLA(context.TODO(), userName, laID) + if err != nil { + return errors.Wrapf(err, "failed to delete linked account") + } + + log.Infof("linked account %q for user %q deleted", userName, laID) + + return nil +} diff --git a/cmd/agola/cmd/userlist.go b/cmd/agola/cmd/userlist.go new file mode 100644 index 0000000..1b58471 --- /dev/null +++ b/cmd/agola/cmd/userlist.go @@ -0,0 +1,68 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + + "github.com/sorintlab/agola/internal/services/gateway/api" + "github.com/spf13/cobra" +) + +var cmdUserList = &cobra.Command{ + Use: "list", + Run: func(cmd *cobra.Command, args []string) { + if err := userList(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, + Short: "list", +} + +type userListOptions struct { + limit int + start string +} + +var userListOpts userListOptions + +func init() { + flags := cmdUserList.PersistentFlags() + + flags.IntVar(&userListOpts.limit, "limit", 10, "max number of runs to show") + flags.StringVar(&userListOpts.start, "start", "", "starting user name (excluded) to fetch") + + cmdUser.AddCommand(cmdUserList) +} + +func printUsers(usersResponse *api.UsersResponse) { + for _, user := range usersResponse.Users { + fmt.Printf("%s: Name: %s\n", user.ID, user.UserName) + } +} + +func userList(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + usersResponse, _, err := gwclient.GetUsers(context.TODO(), userListOpts.start, userListOpts.limit, false) + if err != nil { + return err + } + + printUsers(usersResponse) + + return nil +} diff --git a/cmd/agola/cmd/usertoken.go b/cmd/agola/cmd/usertoken.go new file mode 100644 index 0000000..18ea18b --- /dev/null +++ b/cmd/agola/cmd/usertoken.go @@ -0,0 +1,28 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var cmdUserToken = &cobra.Command{ + Use: "token", + Short: "token", +} + +func init() { + cmdUser.AddCommand(cmdUserToken) +} diff --git a/cmd/agola/cmd/usertokencreate.go b/cmd/agola/cmd/usertokencreate.go new file mode 100644 index 0000000..70ca3d9 --- /dev/null +++ b/cmd/agola/cmd/usertokencreate.go @@ -0,0 +1,72 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "github.com/sorintlab/agola/internal/services/gateway/api" + + "github.com/spf13/cobra" +) + +var cmdUserTokenCreate = &cobra.Command{ + Use: "create", + Short: "create a user linkedaccount", + Run: func(cmd *cobra.Command, args []string) { + if err := userTokenCreate(cmd, args); err != nil { + log.Fatalf("err: %v", err) + } + }, +} + +type userTokenCreateOptions struct { + username string + tokenName string +} + +var userTokenCreateOpts userTokenCreateOptions + +func init() { + flags := cmdUserTokenCreate.Flags() + + flags.StringVarP(&userTokenCreateOpts.username, "username", "n", "", "user name") + flags.StringVarP(&userTokenCreateOpts.tokenName, "tokenname", "t", "", "token name") + + cmdUserTokenCreate.MarkFlagRequired("username") + cmdUserTokenCreate.MarkFlagRequired("tokenname") + + cmdUserToken.AddCommand(cmdUserTokenCreate) +} + +func userTokenCreate(cmd *cobra.Command, args []string) error { + gwclient := api.NewClient(gatewayURL, token) + + req := &api.CreateUserTokenRequest{ + TokenName: userTokenCreateOpts.tokenName, + } + + log.Infof("creating token for user %q", userTokenCreateOpts.username) + resp, _, err := gwclient.CreateUserToken(context.TODO(), userTokenCreateOpts.username, req) + if err != nil { + return errors.Wrapf(err, "failed to create token") + } + log.Infof("token for user %q created: %s", userTokenCreateOpts.username, resp.Token) + fmt.Println(resp.Token) + + return nil +} diff --git a/go.mod b/go.mod index c8b2af3..8795523 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,19 @@ module github.com/sorintlab/agola require ( + code.gitea.io/sdk v0.0.0-20190219191342-62c4fab696b4 github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect github.com/Masterminds/squirrel v0.0.0-20181204161840-e5bf00f96d4a github.com/Microsoft/go-winio v0.4.11 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/bmatcuk/doublestar v1.1.1 github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 // indirect + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/docker/distribution v2.7.1+incompatible // indirect github.com/docker/docker v1.13.1 github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.3.3 // indirect + github.com/elazarl/go-bindata-assetfs v1.0.0 github.com/go-bindata/go-bindata v1.0.0 github.com/go-ini/ini v1.42.0 // indirect github.com/go-sql-driver/mysql v1.4.1 // indirect @@ -38,12 +41,11 @@ require ( github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect github.com/spf13/cobra v0.0.3 + github.com/xanzy/go-gitlab v0.14.1 go.etcd.io/etcd v0.0.0-20181128220305-dedae6eb7c25 go.uber.org/zap v1.9.1 - golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 // indirect - golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e // indirect - golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 // indirect - google.golang.org/appengine v1.4.0 // indirect + golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 + golang.org/x/oauth2 v0.0.0-20190220154721-9b3c75971fc9 gopkg.in/ini.v1 v1.41.0 // indirect gopkg.in/yaml.v2 v2.2.2 gotest.tools v2.2.0+incompatible // indirect diff --git a/go.sum b/go.sum index 9ebe1f6..ee792dc 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,6 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +code.gitea.io/sdk v0.0.0-20190219191342-62c4fab696b4 h1:KKhwkTVL8+8/89BXwGf1kvCGXutY+QnJXNys62+Js7A= +code.gitea.io/sdk v0.0.0-20190219191342-62c4fab696b4/go.mod h1:5bZt0dRznpn2JysytQnV0yCru3FwDv9O5G91jo+lDAk= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Masterminds/squirrel v0.0.0-20181204161840-e5bf00f96d4a h1:pMmt05odIWMlrx89uWavde2DDX8SXzaYnbGW+knFeU0= @@ -34,6 +37,8 @@ github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4 h1:qk/FSDDxo05wdJH28W+p5yivv7LuLYLRXPPD8KQCtZs= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3CKKpKinvZLFk= +github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -59,6 +64,8 @@ github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Z github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= @@ -165,6 +172,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1 github.com/ugorji/go v1.1.1 h1:gmervu+jDMvXTbcHQ0pd2wee85nEoE0BsVyEuzkfK8w= github.com/ugorji/go v1.1.1/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ= github.com/urfave/cli v1.18.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/xanzy/go-gitlab v0.14.1 h1:+CipI8+oQxqWNmKCU/9GQvlQnJ5v366ChHNEI4O83is= +github.com/xanzy/go-gitlab v0.14.1/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18 h1:MPPkRncZLN9Kh4MEFmbnK4h3BD7AUmskWv2+EeZJCCs= github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= go.etcd.io/bbolt v1.3.1-etcd.7 h1:M0l89sIuZ+RkW0rLbUsmxescVzLwLUs+Kvks+0jeHdM= @@ -182,9 +191,14 @@ golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72 golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190220154721-9b3c75971fc9 h1:pfyU+l9dEu0vZzDDMsdAKa1gZbJYEn6urYXj/+Xkz7s= +golang.org/x/oauth2 v0.0.0-20190220154721-9b3c75971fc9/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -194,6 +208,7 @@ golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2 h1:+DCIGbF/swA92ohVg0//6X2IVY3KZs6p9mix0ziNYJM= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180608181217-32ee49c4dd80 h1:GL7nK1hkDKrkor0eVOYcMdIsUGErFnaC2gpBOVC+vbI= diff --git a/internal/services/gateway/api/client.go b/internal/services/gateway/api/client.go new file mode 100644 index 0000000..4c438e5 --- /dev/null +++ b/internal/services/gateway/api/client.go @@ -0,0 +1,289 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/sorintlab/agola/internal/services/types" + + "github.com/pkg/errors" +) + +var jsonContent = http.Header{"content-type": []string{"application/json"}} + +// Client represents a Gogs API client. +type Client struct { + url string + client *http.Client + token string +} + +// NewClient initializes and returns a API client. +func NewClient(url, token string) *Client { + return &Client{ + url: strings.TrimSuffix(url, "/"), + client: &http.Client{}, + token: token, + } +} + +// SetHTTPClient replaces default http.Client with user given one. +func (c *Client) SetHTTPClient(client *http.Client) { + c.client = client +} + +func (c *Client) doRequest(ctx context.Context, method, path string, query url.Values, header http.Header, ibody io.Reader) (*http.Response, error) { + u, err := url.Parse(c.url + "/api/v1alpha" + path) + if err != nil { + return nil, err + } + u.RawQuery = query.Encode() + + req, err := http.NewRequest(method, u.String(), ibody) + req = req.WithContext(ctx) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "token "+c.token) + for k, v := range header { + req.Header[k] = v + } + + return c.client.Do(req) +} + +func (c *Client) getResponse(ctx context.Context, method, path string, query url.Values, header http.Header, ibody io.Reader) (*http.Response, error) { + resp, err := c.doRequest(ctx, method, path, query, header, ibody) + if err != nil { + return nil, err + } + + if resp.StatusCode/100 != 2 { + defer resp.Body.Close() + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if len(data) <= 1 { + return resp, errors.New(resp.Status) + } + + // TODO(sgotti) use a json error response + + return resp, errors.New(string(data)) + } + + return resp, nil +} + +func (c *Client) getParsedResponse(ctx context.Context, method, path string, query url.Values, header http.Header, ibody io.Reader, obj interface{}) (*http.Response, error) { + resp, err := c.getResponse(ctx, method, path, query, header, ibody) + if err != nil { + return resp, err + } + defer resp.Body.Close() + + d := json.NewDecoder(resp.Body) + + return resp, d.Decode(obj) +} + +func (c *Client) GetProject(ctx context.Context, projectID string) (*types.Project, *http.Response, error) { + project := new(types.Project) + resp, err := c.getParsedResponse(ctx, "GET", fmt.Sprintf("/project/%s", projectID), nil, jsonContent, nil, project) + return project, resp, err +} + +func (c *Client) GetProjects(ctx context.Context, start string, limit int, asc bool) (*GetProjectsResponse, *http.Response, error) { + q := url.Values{} + if start != "" { + q.Add("start", start) + } + if limit > 0 { + q.Add("limit", strconv.Itoa(limit)) + } + if asc { + q.Add("asc", "") + } + + projects := new(GetProjectsResponse) + resp, err := c.getParsedResponse(ctx, "GET", "/projects", q, jsonContent, nil, &projects) + return projects, resp, err +} + +func (c *Client) CreateProject(ctx context.Context, req *CreateProjectRequest) (*types.Project, *http.Response, error) { + reqj, err := json.Marshal(req) + if err != nil { + return nil, nil, err + } + + project := new(types.Project) + resp, err := c.getParsedResponse(ctx, "PUT", "/projects", nil, jsonContent, bytes.NewReader(reqj), project) + return project, resp, err +} + +func (c *Client) DeleteProject(ctx context.Context, projectName string) (*http.Response, error) { + return c.getResponse(ctx, "DELETE", fmt.Sprintf("/projects/%s", projectName), nil, jsonContent, nil) +} + +func (c *Client) ReconfigProject(ctx context.Context, projectName string) (*http.Response, error) { + return c.getResponse(ctx, "POST", fmt.Sprintf("/projects/%s/reconfig", projectName), nil, jsonContent, nil) +} + +func (c *Client) GetUser(ctx context.Context, userID string) (*types.User, *http.Response, error) { + user := new(types.User) + resp, err := c.getParsedResponse(ctx, "GET", fmt.Sprintf("/user/%s", userID), nil, jsonContent, nil, user) + return user, resp, err +} + +func (c *Client) GetUsers(ctx context.Context, start string, limit int, asc bool) (*UsersResponse, *http.Response, error) { + q := url.Values{} + if start != "" { + q.Add("start", start) + } + if limit > 0 { + q.Add("limit", strconv.Itoa(limit)) + } + if asc { + q.Add("asc", "") + } + + users := new(UsersResponse) + resp, err := c.getParsedResponse(ctx, "GET", "/users", q, jsonContent, nil, &users) + return users, resp, err +} + +func (c *Client) CreateUser(ctx context.Context, req *CreateUserRequest) (*UserResponse, *http.Response, error) { + reqj, err := json.Marshal(req) + if err != nil { + return nil, nil, err + } + + user := new(UserResponse) + resp, err := c.getParsedResponse(ctx, "PUT", "/users", nil, jsonContent, bytes.NewReader(reqj), user) + return user, resp, err +} + +func (c *Client) DeleteUser(ctx context.Context, userName string) (*http.Response, error) { + return c.getResponse(ctx, "DELETE", fmt.Sprintf("/users/%s", userName), nil, jsonContent, nil) +} + +func (c *Client) CreateUserLA(ctx context.Context, userName string, req *CreateUserLARequest) (*CreateUserLAResponse, *http.Response, error) { + reqj, err := json.Marshal(req) + if err != nil { + return nil, nil, err + } + + la := new(CreateUserLAResponse) + resp, err := c.getParsedResponse(ctx, "PUT", fmt.Sprintf("/users/%s/linkedaccounts", userName), nil, jsonContent, bytes.NewReader(reqj), la) + return la, resp, err +} + +func (c *Client) DeleteUserLA(ctx context.Context, userName, laID string) (*http.Response, error) { + return c.getResponse(ctx, "DELETE", fmt.Sprintf("/users/%s/linkedaccounts/%s", userName, laID), nil, jsonContent, nil) +} + +func (c *Client) CreateUserToken(ctx context.Context, userName string, req *CreateUserTokenRequest) (*CreateUserTokenResponse, *http.Response, error) { + reqj, err := json.Marshal(req) + if err != nil { + return nil, nil, err + } + + tresp := new(CreateUserTokenResponse) + resp, err := c.getParsedResponse(ctx, "PUT", fmt.Sprintf("/users/%s/tokens", userName), nil, jsonContent, bytes.NewReader(reqj), tresp) + return tresp, resp, err +} + +func (c *Client) GetRun(ctx context.Context, runID string) (*RunResponse, *http.Response, error) { + run := new(RunResponse) + resp, err := c.getParsedResponse(ctx, "GET", fmt.Sprintf("/run/%s", runID), nil, jsonContent, nil, run) + return run, resp, err +} + +func (c *Client) GetRuns(ctx context.Context, phaseFilter, groups, runGroups []string, start string, limit int, asc bool) (*GetRunsResponse, *http.Response, error) { + q := url.Values{} + for _, phase := range phaseFilter { + q.Add("phase", phase) + } + for _, group := range groups { + q.Add("group", group) + } + for _, runGroup := range runGroups { + q.Add("rungroup", runGroup) + } + if start != "" { + q.Add("start", start) + } + if limit > 0 { + q.Add("limit", strconv.Itoa(limit)) + } + if asc { + q.Add("asc", "") + } + + getRunsResponse := new(GetRunsResponse) + resp, err := c.getParsedResponse(ctx, "GET", "/runs", q, jsonContent, nil, getRunsResponse) + return getRunsResponse, resp, err +} + +func (c *Client) GetRemoteSource(ctx context.Context, rsID string) (*RemoteSourceResponse, *http.Response, error) { + rs := new(RemoteSourceResponse) + resp, err := c.getParsedResponse(ctx, "GET", fmt.Sprintf("/remotesource/%s", rsID), nil, jsonContent, nil, rs) + return rs, resp, err +} + +func (c *Client) GetRemoteSources(ctx context.Context, start string, limit int, asc bool) (*RemoteSourcesResponse, *http.Response, error) { + q := url.Values{} + if start != "" { + q.Add("start", start) + } + if limit > 0 { + q.Add("limit", strconv.Itoa(limit)) + } + if asc { + q.Add("asc", "") + } + + rss := new(RemoteSourcesResponse) + resp, err := c.getParsedResponse(ctx, "GET", "/remotesources", q, jsonContent, nil, &rss) + return rss, resp, err +} + +func (c *Client) CreateRemoteSource(ctx context.Context, req *CreateRemoteSourceRequest) (*types.RemoteSource, *http.Response, error) { + uj, err := json.Marshal(req) + if err != nil { + return nil, nil, err + } + + rs := new(types.RemoteSource) + resp, err := c.getParsedResponse(ctx, "PUT", "/remotesources", nil, jsonContent, bytes.NewReader(uj), rs) + return rs, resp, err +} + +func (c *Client) DeleteRemoteSource(ctx context.Context, name string) (*http.Response, error) { + return c.getResponse(ctx, "DELETE", fmt.Sprintf("/remotesources/%s", name), nil, jsonContent, nil) +} diff --git a/internal/services/gateway/api/oauth2.go b/internal/services/gateway/api/oauth2.go new file mode 100644 index 0000000..3cf6313 --- /dev/null +++ b/internal/services/gateway/api/oauth2.go @@ -0,0 +1,79 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "encoding/json" + "net/http" + + csapi "github.com/sorintlab/agola/internal/services/configstore/api" + "github.com/sorintlab/agola/internal/services/gateway/command" + + "go.uber.org/zap" +) + +type OAuth2CallbackHandler struct { + log *zap.SugaredLogger + ch *command.CommandHandler + configstoreClient *csapi.Client +} + +type RemoteSourceAuthResult struct { + RequestType string `json:"request_type,omitempty"` + Response interface{} `json:"response,omitempty"` +} + +func NewOAuth2CallbackHandler(logger *zap.Logger, ch *command.CommandHandler, configstoreClient *csapi.Client) *OAuth2CallbackHandler { + return &OAuth2CallbackHandler{log: logger.Sugar(), ch: ch, configstoreClient: configstoreClient} +} + +func (h *OAuth2CallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + query := r.URL.Query() + code := query.Get("code") + state := query.Get("state") + + cresp, err := h.ch.HandleOauth2Callback(ctx, code, state) + if err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var response interface{} + switch cresp.RequestType { + case "createuserla": + authresp := cresp.Response.(*command.CreateUserLAResponse) + response = &CreateUserLAResponse{ + LinkedAccount: authresp.LinkedAccount, + } + + case "loginuser": + authresp := cresp.Response.(*command.LoginUserResponse) + response = &LoginUserResponse{ + Token: authresp.Token, + User: createUserResponse(authresp.User), + } + } + + resp := RemoteSourceAuthResult{ + RequestType: cresp.RequestType, + Response: response, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/internal/services/gateway/api/project.go b/internal/services/gateway/api/project.go new file mode 100644 index 0000000..afde542 --- /dev/null +++ b/internal/services/gateway/api/project.go @@ -0,0 +1,282 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + csapi "github.com/sorintlab/agola/internal/services/configstore/api" + "github.com/sorintlab/agola/internal/services/gateway/command" + "github.com/sorintlab/agola/internal/services/types" + + "github.com/gorilla/mux" + "go.uber.org/zap" +) + +type CreateProjectRequest struct { + Name string `json:"name"` + RepoURL string `json:"repo_url"` + RemoteSourceName string `json:"remote_source_name"` + SkipSSHHostKeyCheck bool `json:"skip_ssh_host_key_check"` +} + +type CreateProjectHandler struct { + log *zap.SugaredLogger + ch *command.CommandHandler + configstoreClient *csapi.Client + exposedURL string +} + +func NewCreateProjectHandler(logger *zap.Logger, ch *command.CommandHandler, configstoreClient *csapi.Client, exposedURL string) *CreateProjectHandler { + return &CreateProjectHandler{log: logger.Sugar(), ch: ch, configstoreClient: configstoreClient, exposedURL: exposedURL} +} + +func (h *CreateProjectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req CreateProjectRequest + d := json.NewDecoder(r.Body) + if err := d.Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + ctxUserID := ctx.Value("userid") + if ctxUserID == nil { + http.Error(w, "no userid specified", http.StatusBadRequest) + return + } + userID := ctxUserID.(string) + h.log.Infof("userID: %q", userID) + + project, err := h.ch.CreateProject(ctx, &command.CreateProjectRequest{ + Name: req.Name, + RepoURL: req.RepoURL, + RemoteSourceName: req.RemoteSourceName, + UserID: userID, + SkipSSHHostKeyCheck: req.SkipSSHHostKeyCheck, + }) + if err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := json.NewEncoder(w).Encode(project); err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + +} + +type ProjectReconfigHandler struct { + log *zap.SugaredLogger + ch *command.CommandHandler + configstoreClient *csapi.Client + exposedURL string +} + +func NewProjectReconfigHandler(logger *zap.Logger, ch *command.CommandHandler, configstoreClient *csapi.Client, exposedURL string) *ProjectReconfigHandler { + return &ProjectReconfigHandler{log: logger.Sugar(), ch: ch, configstoreClient: configstoreClient, exposedURL: exposedURL} +} + +func (h *ProjectReconfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + projectName := vars["projectname"] + + if err := h.ch.ReconfigProject(ctx, projectName); err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type DeleteProjectHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewDeleteProjectHandler(logger *zap.Logger, configstoreClient *csapi.Client) *DeleteProjectHandler { + return &DeleteProjectHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *DeleteProjectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + projectName := vars["projectname"] + + resp, err := h.configstoreClient.DeleteProject(ctx, projectName) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type ProjectHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewProjectHandler(logger *zap.Logger, configstoreClient *csapi.Client) *ProjectHandler { + return &ProjectHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *ProjectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + projectID := vars["projectid"] + + project, resp, err := h.configstoreClient.GetProject(ctx, projectID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + res := createProjectResponse(project) + if err := json.NewEncoder(w).Encode(res); err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type ProjectByNameHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewProjectByNameHandler(logger *zap.Logger, configstoreClient *csapi.Client) *ProjectByNameHandler { + return &ProjectByNameHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *ProjectByNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + projectName := vars["projectname"] + + project, resp, err := h.configstoreClient.GetProjectByName(ctx, projectName) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + res := createProjectResponse(project) + if err := json.NewEncoder(w).Encode(res); err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type GetProjectsResponse struct { + Projects []*ProjectResponse `json:"projects"` +} + +type ProjectResponse struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func createProjectResponse(r *types.Project) *ProjectResponse { + run := &ProjectResponse{ + ID: r.ID, + Name: r.Name, + } + + return run +} + +type ProjectsHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewProjectsHandler(logger *zap.Logger, configstoreClient *csapi.Client) *ProjectsHandler { + return &ProjectsHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *ProjectsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + query := r.URL.Query() + + limitS := query.Get("limit") + limit := DefaultRunsLimit + if limitS != "" { + var err error + limit, err = strconv.Atoi(limitS) + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + } + if limit < 0 { + http.Error(w, "limit must be greater or equal than 0", http.StatusBadRequest) + return + } + if limit > MaxRunsLimit { + limit = MaxRunsLimit + } + asc := false + if _, ok := query["asc"]; ok { + asc = true + } + + start := query.Get("start") + + csprojects, resp, err := h.configstoreClient.GetProjects(ctx, start, limit, asc) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + projects := make([]*ProjectResponse, len(csprojects)) + for i, p := range csprojects { + projects[i] = createProjectResponse(p) + } + getProjectsResponse := &GetProjectsResponse{ + Projects: projects, + } + + if err := json.NewEncoder(w).Encode(getProjectsResponse); err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/internal/services/gateway/api/remotesource.go b/internal/services/gateway/api/remotesource.go new file mode 100644 index 0000000..e15cabb --- /dev/null +++ b/internal/services/gateway/api/remotesource.go @@ -0,0 +1,235 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + + csapi "github.com/sorintlab/agola/internal/services/configstore/api" + "github.com/sorintlab/agola/internal/services/gateway/common" + "github.com/sorintlab/agola/internal/services/types" + "github.com/sorintlab/agola/internal/util" + "go.uber.org/zap" + + "github.com/gorilla/mux" + "github.com/pkg/errors" +) + +type CreateRemoteSourceRequest struct { + Name string `json:"name"` + APIURL string `json:"apiurl"` + Type string `json:"type"` + AuthType string `json:"auth_type"` + Oauth2ClientID string `json:"oauth_2_client_id"` + Oauth2ClientSecret string `json:"oauth_2_client_secret"` +} + +type CreateRemoteSourceHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewCreateRemoteSourceHandler(logger *zap.Logger, configstoreClient *csapi.Client) *CreateRemoteSourceHandler { + return &CreateRemoteSourceHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *CreateRemoteSourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req CreateRemoteSourceRequest + d := json.NewDecoder(r.Body) + if err := d.Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + user, err := h.createRemoteSource(ctx, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := json.NewEncoder(w).Encode(user); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + +} + +func (h *CreateRemoteSourceHandler) createRemoteSource(ctx context.Context, req *CreateRemoteSourceRequest) (*types.RemoteSource, error) { + if !util.ValidateName(req.Name) { + return nil, errors.Errorf("invalid remotesource name %q", req.Name) + } + + if req.Name == "" { + return nil, errors.Errorf("remotesource name required") + } + if req.APIURL == "" { + return nil, errors.Errorf("remotesource api url required") + } + if req.Type == "" { + return nil, errors.Errorf("remotesource type required") + } + if req.AuthType == "" { + return nil, errors.Errorf("remotesource auth type required") + } + + // validate if the remote source type supports the required auth type + if !common.SourceSupportsAuthType(types.RemoteSourceType(req.Type), types.RemoteSourceAuthType(req.AuthType)) { + return nil, errors.Errorf("remotesource type %q doesn't support auth type %q", req.Type, req.AuthType) + } + + if req.AuthType == string(types.RemoteSourceAuthTypeOauth2) { + if req.Oauth2ClientID == "" { + return nil, errors.Errorf("remotesource oauth2 clientid required") + } + if req.Oauth2ClientSecret == "" { + return nil, errors.Errorf("remotesource oauth2 client secret required") + } + } + + rs := &types.RemoteSource{ + Name: req.Name, + Type: types.RemoteSourceType(req.Type), + AuthType: types.RemoteSourceAuthType(req.AuthType), + APIURL: req.APIURL, + Oauth2ClientID: req.Oauth2ClientID, + Oauth2ClientSecret: req.Oauth2ClientSecret, + } + + h.log.Infof("creating remotesource") + rs, _, err := h.configstoreClient.CreateRemoteSource(ctx, rs) + if err != nil { + return nil, errors.Wrapf(err, "failed to create remotesource") + } + h.log.Infof("remotesource %s created, ID: %s", rs.Name, rs.ID) + + return rs, nil +} + +type RemoteSourcesResponse struct { + RemoteSources []*RemoteSourceResponse `json:"remote_sources"` +} + +type RemoteSourceResponse struct { + ID string `json:"id"` + Name string `json:"name"` + AuthType string `json:"auth_type"` +} + +func createRemoteSourceResponse(r *types.RemoteSource) *RemoteSourceResponse { + rs := &RemoteSourceResponse{ + ID: r.ID, + Name: r.Name, + AuthType: string(r.AuthType), + } + return rs +} + +type RemoteSourceHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewRemoteSourceHandler(logger *zap.Logger, configstoreClient *csapi.Client) *RemoteSourceHandler { + return &RemoteSourceHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *RemoteSourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + rsID := vars["id"] + + rs, resp, err := h.configstoreClient.GetRemoteSource(ctx, rsID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + res := createRemoteSourceResponse(rs) + if err := json.NewEncoder(w).Encode(res); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type RemoteSourcesHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewRemoteSourcesHandler(logger *zap.Logger, configstoreClient *csapi.Client) *RemoteSourcesHandler { + return &RemoteSourcesHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *RemoteSourcesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + query := r.URL.Query() + + limitS := query.Get("limit") + limit := DefaultRunsLimit + if limitS != "" { + var err error + limit, err = strconv.Atoi(limitS) + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + } + if limit < 0 { + http.Error(w, "limit must be greater or equal than 0", http.StatusBadRequest) + return + } + if limit > MaxRunsLimit { + limit = MaxRunsLimit + } + asc := false + if _, ok := query["asc"]; ok { + asc = true + } + + start := query.Get("start") + + csRemoteSources, resp, err := h.configstoreClient.GetRemoteSources(ctx, start, limit, asc) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + remoteSources := make([]*RemoteSourceResponse, len(csRemoteSources)) + for i, rs := range csRemoteSources { + remoteSources[i] = createRemoteSourceResponse(rs) + } + remoteSourcesResponse := &RemoteSourcesResponse{ + RemoteSources: remoteSources, + } + + if err := json.NewEncoder(w).Encode(remoteSourcesResponse); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/internal/services/gateway/api/run.go b/internal/services/gateway/api/run.go new file mode 100644 index 0000000..80f23d3 --- /dev/null +++ b/internal/services/gateway/api/run.go @@ -0,0 +1,443 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "strconv" + "time" + + rsapi "github.com/sorintlab/agola/internal/services/runservice/scheduler/api" + rstypes "github.com/sorintlab/agola/internal/services/runservice/types" + "go.uber.org/zap" + + "github.com/gorilla/mux" +) + +type RunsResponse struct { + ID string `json:"id"` + Counter uint64 `json:"counter"` + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + Phase rstypes.RunPhase `json:"phase"` + Result rstypes.RunResult `json:"result"` + + TasksWaitingApproval []string `json:"tasks_waiting_approval"` + + EnqueueTime *time.Time `json:"enqueue_time"` + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` +} + +type RunResponse struct { + ID string `json:"id"` + Counter uint64 `json:"counter"` + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + Phase rstypes.RunPhase `json:"phase"` + Result rstypes.RunResult `json:"result"` + + Tasks map[string]*RunResponseTask `json:"tasks"` + TasksWaitingApproval []string `json:"tasks_waiting_approval"` + + EnqueueTime *time.Time `json:"enqueue_time"` + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` +} + +type RunResponseTask struct { + ID string `json:"id"` + Name string `json:"name"` + Status rstypes.RunTaskStatus `json:"status"` + Level int `json:"level"` + Depends []*rstypes.RunConfigTaskDepend `json:"depends"` + + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` +} + +type RunTaskResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Status rstypes.RunTaskStatus `json:"status"` + + Steps []*RunTaskResponseStep `json:"steps"` + + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` +} + +type RunTaskResponseStep struct { + Phase rstypes.ExecutorTaskPhase `json:"phase"` + Name string `json:"name"` + Command string `json:"command"` + + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` +} + +func createRunResponse(r *rstypes.Run, rc *rstypes.RunConfig) *RunResponse { + run := &RunResponse{ + ID: r.ID, + Counter: r.Counter, + Name: r.Name, + Annotations: r.Annotations, + Phase: r.Phase, + Result: r.Result, + Tasks: make(map[string]*RunResponseTask), + TasksWaitingApproval: r.TasksWaitingApproval(), + + EnqueueTime: r.EnqueueTime, + StartTime: r.StartTime, + EndTime: r.EndTime, + } + + for name, rt := range r.RunTasks { + rct := rc.Tasks[rt.ID] + run.Tasks[name] = createRunResponseTask(r, rt, rct) + } + + return run +} + +func createRunResponseTask(r *rstypes.Run, rt *rstypes.RunTask, rct *rstypes.RunConfigTask) *RunResponseTask { + t := &RunResponseTask{ + ID: rt.ID, + Name: rct.Name, + Status: rt.Status, + + StartTime: rt.StartTime, + EndTime: rt.EndTime, + + Level: rct.Level, + Depends: rct.Depends, + } + + return t +} + +func createRunTaskResponse(rt *rstypes.RunTask, rct *rstypes.RunConfigTask) *RunTaskResponse { + t := &RunTaskResponse{ + ID: rt.ID, + Name: rct.Name, + Status: rt.Status, + Steps: make([]*RunTaskResponseStep, len(rt.Steps)), + + StartTime: rt.StartTime, + EndTime: rt.EndTime, + } + + for i := 0; i < len(t.Steps); i++ { + s := &RunTaskResponseStep{ + StartTime: rt.Steps[i].StartTime, + EndTime: rt.Steps[i].EndTime, + } + rcts := rct.Steps[i] + s.Phase = rt.Steps[i].Phase + switch rcts := rcts.(type) { + case *rstypes.RunStep: + s.Name = rcts.Name + s.Command = rcts.Command + case *rstypes.SaveToWorkspaceStep: + s.Name = "save to workspace" + case *rstypes.RestoreWorkspaceStep: + s.Name = "restore workspace" + } + + t.Steps[i] = s + } + + return t +} + +type RunHandler struct { + log *zap.SugaredLogger + runserviceClient *rsapi.Client +} + +func NewRunHandler(logger *zap.Logger, runserviceClient *rsapi.Client) *RunHandler { + return &RunHandler{log: logger.Sugar(), runserviceClient: runserviceClient} +} + +func (h *RunHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + runID := vars["runid"] + + runResp, resp, err := h.runserviceClient.GetRun(ctx, runID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + res := createRunResponse(runResp.Run, runResp.RunConfig) + if err := json.NewEncoder(w).Encode(res); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type RuntaskHandler struct { + log *zap.SugaredLogger + runserviceClient *rsapi.Client +} + +func NewRuntaskHandler(logger *zap.Logger, runserviceClient *rsapi.Client) *RuntaskHandler { + return &RuntaskHandler{log: logger.Sugar(), runserviceClient: runserviceClient} +} + +func (h *RuntaskHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + runID := vars["runid"] + taskID := vars["taskid"] + + runResp, resp, err := h.runserviceClient.GetRun(ctx, runID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + run := runResp.Run + rc := runResp.RunConfig + + rt, ok := run.RunTasks[taskID] + if !ok { + http.Error(w, "", http.StatusNotFound) + return + } + rct := rc.Tasks[rt.ID] + + res := createRunTaskResponse(rt, rct) + + if err := json.NewEncoder(w).Encode(res); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +const ( + DefaultRunsLimit = 25 + MaxRunsLimit = 40 +) + +type GetRunsResponse struct { + Runs []*RunsResponse `json:"runs"` +} + +func createRunsResponse(r *rstypes.Run) *RunsResponse { + run := &RunsResponse{ + ID: r.ID, + Counter: r.Counter, + Name: r.Name, + Annotations: r.Annotations, + Phase: r.Phase, + Result: r.Result, + + TasksWaitingApproval: r.TasksWaitingApproval(), + + EnqueueTime: r.EnqueueTime, + StartTime: r.StartTime, + EndTime: r.EndTime, + } + + return run +} + +type RunsHandler struct { + log *zap.SugaredLogger + runserviceClient *rsapi.Client +} + +func NewRunsHandler(logger *zap.Logger, runserviceClient *rsapi.Client) *RunsHandler { + return &RunsHandler{log: logger.Sugar(), runserviceClient: runserviceClient} +} + +func (h *RunsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + query := r.URL.Query() + phaseFilter := query["phase"] + groups := query["group"] + changeGroups := query["changegroup"] + + limitS := query.Get("limit") + limit := DefaultRunsLimit + if limitS != "" { + var err error + limit, err = strconv.Atoi(limitS) + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + } + if limit < 0 { + http.Error(w, "limit must be greater or equal than 0", http.StatusBadRequest) + return + } + if limit > MaxRunsLimit { + limit = MaxRunsLimit + } + asc := false + if _, ok := query["asc"]; ok { + asc = true + } + + start := query.Get("start") + + runsResp, resp, err := h.runserviceClient.GetRuns(ctx, phaseFilter, groups, changeGroups, start, limit, asc) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + runs := make([]*RunsResponse, len(runsResp.Runs)) + for i, r := range runsResp.Runs { + runs[i] = createRunsResponse(r) + } + getRunsResponse := &GetRunsResponse{ + Runs: runs, + } + + if err := json.NewEncoder(w).Encode(getRunsResponse); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type LogsHandler struct { + log *zap.SugaredLogger + runserviceClient *rsapi.Client +} + +func NewLogsHandler(logger *zap.Logger, runserviceClient *rsapi.Client) *LogsHandler { + return &LogsHandler{log: logger.Sugar(), runserviceClient: runserviceClient} +} + +func (h *LogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // TODO(sgotti) Check authorized call from client + + runID := r.URL.Query().Get("runID") + if runID == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + taskID := r.URL.Query().Get("taskID") + if taskID == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + s := r.URL.Query().Get("step") + if s == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + step, err := strconv.Atoi(s) + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + follow := false + if _, ok := r.URL.Query()["follow"]; ok { + follow = true + } + stream := false + if _, ok := r.URL.Query()["stream"]; ok { + stream = true + } + if follow { + stream = true + } + + resp, err := h.runserviceClient.GetLogs(ctx, runID, taskID, step, follow, stream) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, "", http.StatusInternalServerError) + return + } + + if stream { + w.Header().Set("Content-Type", "text/event-stream") + } + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + defer resp.Body.Close() + if stream { + if err := sendLogs(w, resp.Body); err != nil { + h.log.Errorf("err: %+v", err) + return + } + } else { + if _, err := io.Copy(w, resp.Body); err != nil { + h.log.Errorf("err: %+v", err) + return + } + } +} + +// sendLogs is used during streaming to flush logs lines +// TODO(sgotti) there's no need to do br.ReadBytes since the response is +// already flushed by the runservice. +func sendLogs(w io.Writer, r io.Reader) error { + br := bufio.NewReader(r) + + var flusher http.Flusher + if fl, ok := w.(http.Flusher); ok { + flusher = fl + } + stop := false + for { + if stop { + return nil + } + data, err := br.ReadBytes('\n') + if err != nil { + if err != io.EOF { + return err + } + if len(data) == 0 { + return nil + } + stop = true + } + if _, err := w.Write(data); err != nil { + return err + } + if flusher != nil { + flusher.Flush() + } + } +} diff --git a/internal/services/gateway/api/user.go b/internal/services/gateway/api/user.go new file mode 100644 index 0000000..48debbf --- /dev/null +++ b/internal/services/gateway/api/user.go @@ -0,0 +1,537 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + + csapi "github.com/sorintlab/agola/internal/services/configstore/api" + "github.com/sorintlab/agola/internal/services/gateway/command" + "github.com/sorintlab/agola/internal/services/types" + "github.com/sorintlab/agola/internal/util" + "go.uber.org/zap" + + "github.com/gorilla/mux" + "github.com/pkg/errors" +) + +type CreateUserRequest struct { + UserName string `json:"username"` +} + +type CreateUserHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewCreateUserHandler(logger *zap.Logger, configstoreClient *csapi.Client) *CreateUserHandler { + return &CreateUserHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *CreateUserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req CreateUserRequest + d := json.NewDecoder(r.Body) + if err := d.Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + user, err := h.createUser(ctx, &req) + if err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := json.NewEncoder(w).Encode(user); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + +} + +func (h *CreateUserHandler) createUser(ctx context.Context, req *CreateUserRequest) (*UserResponse, error) { + if !util.ValidateName(req.UserName) { + return nil, errors.Errorf("invalid user name %q", req.UserName) + } + + u := &types.User{ + UserName: req.UserName, + } + + h.log.Infof("creating user") + u, _, err := h.configstoreClient.CreateUser(ctx, u) + if err != nil { + return nil, errors.Wrapf(err, "failed to create user") + } + h.log.Infof("user %s created, ID: %s", u.UserName, u.ID) + + res := createUserResponse(u) + return res, nil +} + +type DeleteUserHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewDeleteUserHandler(logger *zap.Logger, configstoreClient *csapi.Client) *DeleteUserHandler { + return &DeleteUserHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *DeleteUserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + userName := vars["username"] + + resp, err := h.configstoreClient.DeleteUser(ctx, userName) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type CurrentUserHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewCurrentUserHandler(logger *zap.Logger, configstoreClient *csapi.Client) *CurrentUserHandler { + return &CurrentUserHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *CurrentUserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIDVal := ctx.Value("userid") + if userIDVal == nil { + http.Error(w, "", http.StatusBadRequest) + return + } + userID := userIDVal.(string) + + user, resp, err := h.configstoreClient.GetUser(ctx, userID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + res := createUserResponse(user) + if err := json.NewEncoder(w).Encode(res); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type UserHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewUserHandler(logger *zap.Logger, configstoreClient *csapi.Client) *UserHandler { + return &UserHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + userID := vars["userid"] + + user, resp, err := h.configstoreClient.GetUser(ctx, userID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + res := createUserResponse(user) + if err := json.NewEncoder(w).Encode(res); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type UserByNameHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewUserByNameHandler(logger *zap.Logger, configstoreClient *csapi.Client) *UserByNameHandler { + return &UserByNameHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *UserByNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + userName := vars["username"] + + user, resp, err := h.configstoreClient.GetUserByName(ctx, userName) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + res := createUserResponse(user) + if err := json.NewEncoder(w).Encode(res); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type UsersResponse struct { + Users []*UserResponse `json:"users"` +} + +type UserResponse struct { + ID string `json:"id"` + UserName string `json:"username"` +} + +func createUserResponse(r *types.User) *UserResponse { + user := &UserResponse{ + ID: r.ID, + UserName: r.UserName, + } + return user +} + +type UsersHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewUsersHandler(logger *zap.Logger, configstoreClient *csapi.Client) *UsersHandler { + return &UsersHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *UsersHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + query := r.URL.Query() + + limitS := query.Get("limit") + limit := DefaultRunsLimit + if limitS != "" { + var err error + limit, err = strconv.Atoi(limitS) + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + } + if limit < 0 { + http.Error(w, "limit must be greater or equal than 0", http.StatusBadRequest) + return + } + if limit > MaxRunsLimit { + limit = MaxRunsLimit + } + asc := false + if _, ok := query["asc"]; ok { + asc = true + } + + start := query.Get("start") + + csusers, resp, err := h.configstoreClient.GetUsers(ctx, start, limit, asc) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + users := make([]*UserResponse, len(csusers)) + for i, p := range csusers { + users[i] = createUserResponse(p) + } + usersResponse := &UsersResponse{ + Users: users, + } + + if err := json.NewEncoder(w).Encode(usersResponse); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type CreateUserLARequest struct { + RemoteSourceName string `json:"remote_source_name"` + RemoteSourceLoginName string `json:"remote_login_name"` + RemoteSourceLoginPassword string `json:"remote_login_password"` +} + +type CreateUserLAResponse struct { + LinkedAccount *types.LinkedAccount `json:"linked_account"` + Oauth2Redirect string `json:"oauth2_redirect"` +} + +type CreateUserLAHandler struct { + log *zap.SugaredLogger + ch *command.CommandHandler + configstoreClient *csapi.Client +} + +func NewCreateUserLAHandler(logger *zap.Logger, ch *command.CommandHandler, configstoreClient *csapi.Client) *CreateUserLAHandler { + return &CreateUserLAHandler{log: logger.Sugar(), ch: ch, configstoreClient: configstoreClient} +} + +func (h *CreateUserLAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + userName := vars["username"] + + var req *CreateUserLARequest + d := json.NewDecoder(r.Body) + if err := d.Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := h.createUserLA(ctx, userName, req) + if err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + +} + +func (h *CreateUserLAHandler) createUserLA(ctx context.Context, userName string, req *CreateUserLARequest) (*CreateUserLAResponse, error) { + remoteSourceName := req.RemoteSourceName + user, _, err := h.configstoreClient.GetUserByName(ctx, userName) + if err != nil { + return nil, errors.Wrapf(err, "failed to get user %q", userName) + } + rs, _, err := h.configstoreClient.GetRemoteSourceByName(ctx, remoteSourceName) + if err != nil { + return nil, errors.Wrapf(err, "failed to get remote source %q", remoteSourceName) + } + h.log.Infof("rs: %s", util.Dump(rs)) + var la *types.LinkedAccount + for _, v := range user.LinkedAccounts { + if v.RemoteSourceID == rs.ID { + la = v + break + } + } + h.log.Infof("la: %s", util.Dump(la)) + if la != nil { + return nil, errors.Errorf("user %q already have a linked account for remote source %q", userName, rs.Name) + } + + creq := &command.CreateUserLARequest{ + UserName: userName, + RemoteSourceName: rs.Name, + } + + h.log.Infof("creating linked account") + cresp, err := h.ch.HandleRemoteSourceAuth(ctx, rs, req.RemoteSourceLoginName, req.RemoteSourceLoginPassword, "createuserla", creq) + if err != nil { + return nil, err + } + if cresp.Oauth2Redirect != "" { + return &CreateUserLAResponse{ + Oauth2Redirect: cresp.Oauth2Redirect, + }, nil + } + authresp := cresp.Response.(*command.CreateUserLAResponse) + + resp := &CreateUserLAResponse{ + LinkedAccount: authresp.LinkedAccount, + } + h.log.Infof("linked account %q for user %q created", resp.LinkedAccount.ID, userName) + return resp, nil +} + +type DeleteUserLAHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewDeleteUserLAHandler(logger *zap.Logger, configstoreClient *csapi.Client) *DeleteUserLAHandler { + return &DeleteUserLAHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *DeleteUserLAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + userName := vars["username"] + laID := vars["laid"] + + _, err := h.configstoreClient.DeleteUserLA(ctx, userName, laID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type CreateUserTokenRequest struct { + TokenName string `json:"token_name"` +} + +type CreateUserTokenResponse struct { + Token string `json:"token"` +} + +type CreateUserTokenHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client +} + +func NewCreateUserTokenHandler(logger *zap.Logger, configstoreClient *csapi.Client) *CreateUserTokenHandler { + return &CreateUserTokenHandler{log: logger.Sugar(), configstoreClient: configstoreClient} +} + +func (h *CreateUserTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + userName := vars["username"] + + var req CreateUserTokenRequest + d := json.NewDecoder(r.Body) + if err := d.Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + creq := &csapi.CreateUserTokenRequest{ + TokenName: req.TokenName, + } + h.log.Infof("creating user %q token", userName) + cresp, _, err := h.configstoreClient.CreateUserToken(ctx, userName, creq) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.log.Infof("user %q token created", userName) + + resp := &CreateUserTokenResponse{ + Token: cresp.Token, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +type LoginUserRequest struct { + RemoteSourceName string `json:"remote_source_name"` + LoginName string `json:"login_name"` + LoginPassword string `json:"password"` +} + +type LoginUserResponse struct { + Oauth2Redirect string `json:"oauth2_redirect"` + Token string `json:"token"` + User *UserResponse `json:"user"` +} + +type LoginUserHandler struct { + log *zap.SugaredLogger + ch *command.CommandHandler + configstoreClient *csapi.Client +} + +func NewLoginUserHandler(logger *zap.Logger, ch *command.CommandHandler, configstoreClient *csapi.Client) *LoginUserHandler { + return &LoginUserHandler{log: logger.Sugar(), ch: ch, configstoreClient: configstoreClient} +} + +func (h *LoginUserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req *LoginUserRequest + d := json.NewDecoder(r.Body) + if err := d.Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := h.loginUser(ctx, req) + if err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + +} + +func (h *LoginUserHandler) loginUser(ctx context.Context, req *LoginUserRequest) (*LoginUserResponse, error) { + remoteSourceName := req.RemoteSourceName + rs, _, err := h.configstoreClient.GetRemoteSourceByName(ctx, remoteSourceName) + if err != nil { + return nil, errors.Wrapf(err, "failed to get remote source %q", remoteSourceName) + } + h.log.Infof("rs: %s", util.Dump(rs)) + + creq := &command.LoginUserRequest{ + RemoteSourceName: rs.Name, + } + + h.log.Infof("logging in user") + cresp, err := h.ch.HandleRemoteSourceAuth(ctx, rs, req.LoginName, req.LoginPassword, "loginuser", creq) + if err != nil { + return nil, err + } + if cresp.Oauth2Redirect != "" { + return &LoginUserResponse{ + Oauth2Redirect: cresp.Oauth2Redirect, + }, nil + } + authresp := cresp.Response.(*command.LoginUserResponse) + + resp := &LoginUserResponse{ + Token: authresp.Token, + User: createUserResponse(authresp.User), + } + return resp, nil +} + +type RemoteSourceAuthResponse struct { + Oauth2Redirect string `json:"oauth_2_redirect"` + Response interface{} `json:"response"` +} diff --git a/internal/services/gateway/command/command.go b/internal/services/gateway/command/command.go new file mode 100644 index 0000000..52b8839 --- /dev/null +++ b/internal/services/gateway/command/command.go @@ -0,0 +1,40 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + csapi "github.com/sorintlab/agola/internal/services/configstore/api" + "github.com/sorintlab/agola/internal/services/gateway/common" + + "go.uber.org/zap" +) + +type CommandHandler struct { + log *zap.SugaredLogger + sd *common.TokenSigningData + configstoreClient *csapi.Client + apiExposedURL string + webExposedURL string +} + +func NewCommandHandler(logger *zap.Logger, sd *common.TokenSigningData, configstoreClient *csapi.Client, apiExposedURL, webExposedURL string) *CommandHandler { + return &CommandHandler{ + log: logger.Sugar(), + sd: sd, + configstoreClient: configstoreClient, + apiExposedURL: apiExposedURL, + webExposedURL: webExposedURL, + } +} diff --git a/internal/services/gateway/command/project.go b/internal/services/gateway/command/project.go new file mode 100644 index 0000000..eb26b27 --- /dev/null +++ b/internal/services/gateway/command/project.go @@ -0,0 +1,174 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "context" + "fmt" + "net/url" + "path" + "strings" + + "github.com/sorintlab/agola/internal/services/gateway/common" + "github.com/sorintlab/agola/internal/services/types" + "github.com/sorintlab/agola/internal/util" + + "github.com/pkg/errors" +) + +type CreateProjectRequest struct { + Name string + RemoteSourceName string + RepoURL string + UserID string + SkipSSHHostKeyCheck bool +} + +func (c *CommandHandler) CreateProject(ctx context.Context, req *CreateProjectRequest) (*types.Project, error) { + if !util.ValidateName(req.Name) { + return nil, errors.Errorf("invalid project name %q", req.Name) + } + + u, err := url.Parse(req.RepoURL) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse repo url") + } + + repoOwner := strings.TrimPrefix(path.Dir(u.Path), "/") + repoName := path.Base(u.Path) + + u.RawQuery = "" + u.Path = "" + host := u.Hostname() + c.log.Infof("repoOwner: %s, repoName: %s", repoOwner, repoName) + + cloneURL := fmt.Sprintf("git@%s:%s/%s.git", host, repoOwner, repoName) + c.log.Infof("cloneURL: %s", cloneURL) + + c.log.Infof("generating ssh key pairs") + privateKey, _, err := util.GenSSHKeyPair(4096) + if err != nil { + return nil, errors.Wrapf(err, "failed to generate ssh key pair") + } + + user, _, err := c.configstoreClient.GetUser(ctx, req.UserID) + if err != nil { + return nil, errors.Wrapf(err, "failed to get user %q", req.UserID) + } + rs, _, err := c.configstoreClient.GetRemoteSourceByName(ctx, req.RemoteSourceName) + if err != nil { + return nil, errors.Wrapf(err, "failed to get remote source %q", req.RemoteSourceName) + } + c.log.Infof("rs: %s", util.Dump(rs)) + var la *types.LinkedAccount + for _, v := range user.LinkedAccounts { + if v.RemoteSourceID == rs.ID { + la = v + break + } + } + c.log.Infof("la: %s", util.Dump(la)) + if la == nil { + return nil, errors.Errorf("user doesn't have a linked account for remote source %q", rs.Name) + } + + p := &types.Project{ + Name: req.Name, + LinkedAccountID: la.ID, + Path: fmt.Sprintf("%s/%s", repoOwner, repoName), + CloneURL: cloneURL, + SkipSSHHostKeyCheck: req.SkipSSHHostKeyCheck, + SSHPrivateKey: string(privateKey), + } + + c.log.Infof("creating project") + p, _, err = c.configstoreClient.CreateProject(ctx, p) + if err != nil { + return nil, errors.Wrapf(err, "failed to create project") + } + c.log.Infof("project %s created, ID: %s", p.Name, p.ID) + + return p, c.SetupProject(ctx, rs, la, &SetupProjectRequest{ + Project: p, + RepoOwner: repoOwner, + RepoName: repoName, + }) +} + +type SetupProjectRequest struct { + Project *types.Project + RepoOwner string + RepoName string +} + +func (c *CommandHandler) SetupProject(ctx context.Context, rs *types.RemoteSource, la *types.LinkedAccount, conf *SetupProjectRequest) error { + c.log.Infof("setupproject") + + gitsource, err := common.GetGitSource(rs, la) + + pubKey, err := util.ExtractPublicKey([]byte(conf.Project.SSHPrivateKey)) + if err != nil { + return errors.Wrapf(err, "failed to create gitea client") + } + + webhookURL := fmt.Sprintf("%s/webhooks?projectid=%s", c.apiExposedURL, conf.Project.ID) + + c.log.Infof("creating/updating deploy key: %s", string(pubKey)) + if err := gitsource.UpdateDeployKey(conf.RepoOwner, conf.RepoName, "agola deploy key", string(pubKey), true); err != nil { + return errors.Wrapf(err, "failed to create deploy key") + } + c.log.Infof("deleting existing webhooks") + if err := gitsource.DeleteRepoWebhook(conf.RepoOwner, conf.RepoName, webhookURL); err != nil { + return errors.Wrapf(err, "failed to delete repository webhook") + } + c.log.Infof("creating webhook to url: %s", webhookURL) + if err := gitsource.CreateRepoWebhook(conf.RepoOwner, conf.RepoName, webhookURL, ""); err != nil { + return errors.Wrapf(err, "failed to create repository webhook") + } + + return nil +} + +func (c *CommandHandler) ReconfigProject(ctx context.Context, projectName string) error { + p, _, err := c.configstoreClient.GetProjectByName(ctx, projectName) + if err != nil { + return err + } + + user, _, err := c.configstoreClient.GetUserByLinkedAccount(ctx, p.LinkedAccountID) + if err != nil { + return errors.Wrapf(err, "failed to get user with linked account id %q", p.LinkedAccountID) + } + + la := user.LinkedAccounts[p.LinkedAccountID] + c.log.Infof("la: %s", util.Dump(la)) + if la == nil { + return errors.Errorf("linked account %q in user %q doesn't exist", p.LinkedAccountID, user.UserName) + } + + rs, _, err := c.configstoreClient.GetRemoteSource(ctx, la.RemoteSourceID) + if err != nil { + return errors.Wrapf(err, "failed to get remote source %q", la.RemoteSourceID) + } + + repoOwner := strings.TrimPrefix(path.Dir(p.Path), "/") + repoName := path.Base(p.Path) + + return c.SetupProject(ctx, rs, la, &SetupProjectRequest{ + Project: p, + RepoOwner: repoOwner, + RepoName: repoName, + }) +} diff --git a/internal/services/gateway/command/user.go b/internal/services/gateway/command/user.go new file mode 100644 index 0000000..240193c --- /dev/null +++ b/internal/services/gateway/command/user.go @@ -0,0 +1,347 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "context" + "encoding/json" + + csapi "github.com/sorintlab/agola/internal/services/configstore/api" + "github.com/sorintlab/agola/internal/services/gateway/common" + "github.com/sorintlab/agola/internal/services/types" + "github.com/sorintlab/agola/internal/util" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/pkg/errors" +) + +type CreateUserLARequest struct { + UserName string + RemoteSourceName string + RemoteSourceUserAccessToken string + RemoteSourceOauth2AccessToken string + RemoteSourceOauth2RefreshToken string +} + +func (c *CommandHandler) CreateUserLA(ctx context.Context, req *CreateUserLARequest) (*types.LinkedAccount, error) { + userName := req.UserName + user, _, err := c.configstoreClient.GetUserByName(ctx, userName) + if err != nil { + return nil, errors.Wrapf(err, "failed to get user %q", userName) + } + rs, _, err := c.configstoreClient.GetRemoteSourceByName(ctx, req.RemoteSourceName) + if err != nil { + return nil, errors.Wrapf(err, "failed to get remote source %q", req.RemoteSourceName) + } + c.log.Infof("rs: %s", util.Dump(rs)) + var la *types.LinkedAccount + for _, v := range user.LinkedAccounts { + if v.RemoteSourceID == rs.ID { + la = v + break + } + } + c.log.Infof("la: %s", util.Dump(la)) + if la != nil { + return nil, errors.Errorf("user %q already have a linked account for remote source %q", userName, rs.Name) + } + + accessToken, err := common.GetAccessToken(rs.AuthType, req.RemoteSourceUserAccessToken, req.RemoteSourceOauth2AccessToken) + if err != nil { + return nil, err + } + userSource, err := common.GetUserSource(rs, accessToken) + if err != nil { + return nil, err + } + + remoteUserInfo, err := userSource.GetUserInfo() + if err != nil { + return nil, errors.Wrapf(err, "failed to retrieve remote user info for remote source %q", rs.ID) + } + if remoteUserInfo.ID == "" { + return nil, errors.Errorf("empty remote user id for remote source %q", rs.ID) + } + + creq := &csapi.CreateUserLARequest{ + RemoteSourceName: req.RemoteSourceName, + RemoteUserID: remoteUserInfo.ID, + RemoteUserName: remoteUserInfo.LoginName, + Oauth2AccessToken: req.RemoteSourceOauth2AccessToken, + Oauth2RefreshToken: req.RemoteSourceOauth2RefreshToken, + UserAccessToken: req.RemoteSourceUserAccessToken, + } + + c.log.Infof("creating linked account") + la, _, err = c.configstoreClient.CreateUserLA(ctx, userName, creq) + if err != nil { + return nil, errors.Wrapf(err, "failed to create linked account") + } + c.log.Infof("linked account %q for user %q created", la.ID, userName) + + return la, nil +} + +type LoginUserRequest struct { + RemoteSourceName string + RemoteSourceUserAccessToken string + RemoteSourceOauth2AccessToken string + RemoteSourceOauth2RefreshToken string +} +type LoginUserResponse struct { + Token string + User *types.User +} + +func (c *CommandHandler) LoginUser(ctx context.Context, req *LoginUserRequest) (*LoginUserResponse, error) { + rs, _, err := c.configstoreClient.GetRemoteSourceByName(ctx, req.RemoteSourceName) + if err != nil { + return nil, errors.Wrapf(err, "failed to get remote source %q", req.RemoteSourceName) + } + c.log.Infof("rs: %s", util.Dump(rs)) + + accessToken, err := common.GetAccessToken(rs.AuthType, req.RemoteSourceUserAccessToken, req.RemoteSourceOauth2AccessToken) + if err != nil { + return nil, err + } + userSource, err := common.GetUserSource(rs, accessToken) + if err != nil { + return nil, err + } + + remoteUserInfo, err := userSource.GetUserInfo() + if err != nil { + return nil, errors.Wrapf(err, "failed to retrieve remote user info for remote source %q", rs.ID) + } + if remoteUserInfo.ID == "" { + return nil, errors.Errorf("empty remote user id for remote source %q", rs.ID) + } + + user, _, err := c.configstoreClient.GetUserByLinkedAccountRemoteUserAndSource(ctx, remoteUserInfo.ID, rs.ID) + if err != nil { + return nil, errors.Wrapf(err, "failed to get user for remote user id %q and remote source %q", remoteUserInfo.ID, rs.ID) + } + + var la *types.LinkedAccount + for _, v := range user.LinkedAccounts { + if v.RemoteSourceID == rs.ID { + la = v + break + } + } + c.log.Infof("la: %s", util.Dump(la)) + if la == nil { + return nil, errors.Errorf("linked account for user %q for remote source %q doesn't exist", user.UserName, rs.Name) + } + + // Update oauth tokens if they have changed since the getuserinfo request may have updated them + if la.Oauth2AccessToken != req.RemoteSourceOauth2AccessToken || + la.Oauth2RefreshToken != req.RemoteSourceOauth2RefreshToken || + la.UserAccessToken != req.RemoteSourceUserAccessToken { + + la.Oauth2AccessToken = req.RemoteSourceOauth2AccessToken + la.Oauth2RefreshToken = req.RemoteSourceOauth2RefreshToken + la.UserAccessToken = req.RemoteSourceUserAccessToken + + creq := &csapi.UpdateUserLARequest{ + RemoteUserID: la.RemoteUserID, + RemoteUserName: la.RemoteUserName, + Oauth2AccessToken: la.Oauth2AccessToken, + Oauth2RefreshToken: la.Oauth2RefreshToken, + UserAccessToken: la.UserAccessToken, + } + + c.log.Infof("updating user %q linked account", user.UserName) + la, _, err = c.configstoreClient.UpdateUserLA(ctx, user.UserName, la.ID, creq) + if err != nil { + return nil, errors.Wrapf(err, "failed to update user") + } + c.log.Infof("linked account %q for user %q updated", la.ID, user.UserName) + } + + // generate jwt token + token, err := common.GenerateLoginJWTToken(c.sd, user.ID) + if err != nil { + return nil, err + } + return &LoginUserResponse{ + Token: token, + User: user, + }, nil +} + +type RemoteSourceAuthResponse struct { + Oauth2Redirect string + Response interface{} +} + +func (c *CommandHandler) HandleRemoteSourceAuth(ctx context.Context, rs *types.RemoteSource, loginName, loginPassword, requestType string, req interface{}) (*RemoteSourceAuthResponse, error) { + switch rs.AuthType { + case types.RemoteSourceAuthTypeOauth2: + oauth2Source, err := common.GetOauth2Source(rs, "") + if err != nil { + return nil, errors.Wrapf(err, "failed to create git source") + } + token, err := common.GenerateJWTToken(c.sd, rs.Name, requestType, req) + if err != nil { + return nil, err + } + redirect, err := oauth2Source.GetOauth2AuthorizationURL(c.webExposedURL+"/oauth2/callback", token) + if err != nil { + return nil, err + } + c.log.Infof("oauth2 redirect: %s", redirect) + + return &RemoteSourceAuthResponse{ + Oauth2Redirect: redirect, + }, nil + + case types.RemoteSourceAuthTypePassword: + passwordSource, err := common.GetPasswordSource(rs, "") + if err != nil { + return nil, errors.Wrapf(err, "failed to create git source") + } + accessToken, err := passwordSource.LoginPassword(loginName, loginPassword) + if err != nil { + return nil, errors.Wrapf(err, "failed to login to remote source %q with login name %q", rs.Name, loginName) + } + c.log.Infof("access token: %s", accessToken) + requestj, err := json.Marshal(req) + if err != nil { + return nil, err + } + cres, err := c.HandleRemoteSourceAuthRequest(ctx, requestType, string(requestj), accessToken, "", "") + if err != nil { + return nil, err + } + return &RemoteSourceAuthResponse{ + Response: cres.Response, + }, nil + + default: + return nil, errors.Errorf("unknown remote source authentication type: %q", rs.AuthType) + } +} + +type RemoteSourceAuthResult struct { + RequestType string + Response interface{} +} + +type CreateUserLAResponse struct { + LinkedAccount *types.LinkedAccount +} + +func (c *CommandHandler) HandleRemoteSourceAuthRequest(ctx context.Context, requestType, requestString string, userAccessToken, Oauth2AccessToken, Oauth2RefreshToken string) (*RemoteSourceAuthResult, error) { + switch requestType { + case "createuserla": + var req *CreateUserLARequest + if err := json.Unmarshal([]byte(requestString), &req); err != nil { + return nil, errors.Errorf("failed to unmarshal request") + } + + creq := &CreateUserLARequest{ + UserName: req.UserName, + RemoteSourceName: req.RemoteSourceName, + RemoteSourceUserAccessToken: userAccessToken, + RemoteSourceOauth2AccessToken: Oauth2AccessToken, + RemoteSourceOauth2RefreshToken: Oauth2RefreshToken, + } + la, err := c.CreateUserLA(ctx, creq) + if err != nil { + return nil, err + } + return &RemoteSourceAuthResult{ + RequestType: requestType, + Response: &CreateUserLAResponse{ + LinkedAccount: la, + }, + }, nil + + case "loginuser": + var req *LoginUserRequest + if err := json.Unmarshal([]byte(requestString), &req); err != nil { + return nil, errors.Errorf("failed to unmarshal request") + } + + creq := &LoginUserRequest{ + RemoteSourceName: req.RemoteSourceName, + RemoteSourceUserAccessToken: userAccessToken, + RemoteSourceOauth2AccessToken: Oauth2AccessToken, + RemoteSourceOauth2RefreshToken: Oauth2RefreshToken, + } + cresp, err := c.LoginUser(ctx, creq) + if err != nil { + return nil, err + } + return &RemoteSourceAuthResult{ + RequestType: requestType, + Response: &LoginUserResponse{ + Token: cresp.Token, + User: cresp.User, + }, + }, nil + + default: + return nil, errors.Errorf("unknown request") + } +} + +func (c *CommandHandler) HandleOauth2Callback(ctx context.Context, code, state string) (*RemoteSourceAuthResult, error) { + token, err := jwt.Parse(state, func(token *jwt.Token) (interface{}, error) { + if token.Method != c.sd.Method { + return nil, errors.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + var key interface{} + switch c.sd.Method { + case jwt.SigningMethodRS256: + key = c.sd.PrivateKey + case jwt.SigningMethodHS256: + key = c.sd.Key + default: + return nil, errors.Errorf("unsupported signing method %q", c.sd.Method.Alg()) + } + return key, nil + }) + if err != nil { + return nil, errors.Wrap(err, "failed to parse jwt") + } + if !token.Valid { + return nil, errors.Errorf("invalid token") + } + + claims := token.Claims.(jwt.MapClaims) + remoteSourceName := claims["remote_source_name"].(string) + requestType := claims["request_type"].(string) + requestString := claims["request"].(string) + + rs, _, err := c.configstoreClient.GetRemoteSourceByName(ctx, remoteSourceName) + if err != nil { + return nil, errors.Wrapf(err, "failed to get remote source %q", remoteSourceName) + } + c.log.Infof("rs: %s", util.Dump(rs)) + + oauth2Source, err := common.GetOauth2Source(rs, "") + if err != nil { + return nil, errors.Wrapf(err, "failed to create gitlab source") + } + + oauth2Token, err := oauth2Source.RequestOauth2Token(c.webExposedURL+"/oauth2/callback", code) + if err != nil { + return nil, err + } + + return c.HandleRemoteSourceAuthRequest(ctx, requestType, requestString, "", oauth2Token.AccessToken, oauth2Token.RefreshToken) +} diff --git a/internal/services/gateway/common/gitsource.go b/internal/services/gateway/common/gitsource.go new file mode 100644 index 0000000..f334cd7 --- /dev/null +++ b/internal/services/gateway/common/gitsource.go @@ -0,0 +1,143 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "fmt" + + "github.com/pkg/errors" + gitsource "github.com/sorintlab/agola/internal/gitsources" + "github.com/sorintlab/agola/internal/gitsources/gitea" + "github.com/sorintlab/agola/internal/gitsources/gitlab" + "github.com/sorintlab/agola/internal/services/types" +) + +func SourceSupportedAuthTypes(rsType types.RemoteSourceType) []types.RemoteSourceAuthType { + switch rsType { + case types.RemoteSourceTypeGitea: + return []types.RemoteSourceAuthType{types.RemoteSourceAuthTypePassword} + case types.RemoteSourceTypeGithub: + fallthrough + case types.RemoteSourceTypeGitlab: + return []types.RemoteSourceAuthType{types.RemoteSourceAuthTypeOauth2} + + default: + panic(fmt.Errorf("unsupported remote source type: %q", rsType)) + } +} + +func SourceSupportsAuthType(rsType types.RemoteSourceType, authType types.RemoteSourceAuthType) bool { + supportedAuthTypes := SourceSupportedAuthTypes(rsType) + for _, st := range supportedAuthTypes { + if st == authType { + return true + } + } + return false +} + +func newGitea(rs *types.RemoteSource, accessToken string) (*gitea.Client, error) { + return gitea.New(gitea.Opts{ + URL: rs.APIURL, + SkipVerify: rs.SkipVerify, + Token: accessToken, + }) +} + +func newGitlab(rs *types.RemoteSource, accessToken string) (*gitlab.Client, error) { + return gitlab.New(gitlab.Opts{ + URL: rs.APIURL, + SkipVerify: rs.SkipVerify, + Token: accessToken, + Oauth2ClientID: rs.Oauth2ClientID, + Oauth2Secret: rs.Oauth2ClientSecret, + }) +} + +func GetAccessToken(authType types.RemoteSourceAuthType, userAccessToken, oauth2AccessToken string) (string, error) { + switch authType { + case types.RemoteSourceAuthTypePassword: + return userAccessToken, nil + case types.RemoteSourceAuthTypeOauth2: + return oauth2AccessToken, nil + default: + return "", errors.Errorf("invalid remote source auth type %q", authType) + } +} + +func GetGitSource(rs *types.RemoteSource, la *types.LinkedAccount) (gitsource.GitSource, error) { + var accessToken string + if la != nil { + var err error + accessToken, err = GetAccessToken(rs.AuthType, la.UserAccessToken, la.Oauth2AccessToken) + if err != nil { + return nil, err + } + } + + var gitSource gitsource.GitSource + var err error + switch rs.Type { + case types.RemoteSourceTypeGitea: + gitSource, err = newGitea(rs, accessToken) + case types.RemoteSourceTypeGitlab: + gitSource, err = newGitlab(rs, accessToken) + default: + return nil, errors.Errorf("remote source %s isn't a valid git source", rs.Name) + } + + return gitSource, err +} + +func GetUserSource(rs *types.RemoteSource, accessToken string) (gitsource.UserSource, error) { + var userSource gitsource.UserSource + var err error + switch rs.AuthType { + case types.RemoteSourceAuthTypeOauth2: + userSource, err = GetOauth2Source(rs, accessToken) + case types.RemoteSourceAuthTypePassword: + userSource, err = GetPasswordSource(rs, accessToken) + default: + return nil, errors.Errorf("unknown remote source auth type") + } + + return userSource, err +} + +func GetOauth2Source(rs *types.RemoteSource, accessToken string) (gitsource.Oauth2Source, error) { + var oauth2Source gitsource.Oauth2Source + var err error + switch rs.Type { + case types.RemoteSourceTypeGitlab: + oauth2Source, err = newGitlab(rs, accessToken) + default: + return nil, errors.Errorf("remote source %s isn't a valid oauth2 source", rs.Name) + } + + return oauth2Source, err +} + +func GetPasswordSource(rs *types.RemoteSource, accessToken string) (gitsource.PasswordSource, error) { + var passwordSource gitsource.PasswordSource + var err error + switch rs.Type { + case types.RemoteSourceTypeGitea: + passwordSource, err = newGitea(rs, accessToken) + default: + return nil, errors.Errorf("remote source %s isn't a valid oauth2 source", rs.Name) + } + + return passwordSource, err +} diff --git a/internal/services/gateway/common/jwt.go b/internal/services/gateway/common/jwt.go new file mode 100644 index 0000000..c7e0871 --- /dev/null +++ b/internal/services/gateway/common/jwt.go @@ -0,0 +1,77 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "crypto/rsa" + "encoding/json" + "time" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/pkg/errors" +) + +type TokenSigningData struct { + Duration time.Duration + Method jwt.SigningMethod + PrivateKey *rsa.PrivateKey + PublicKey *rsa.PublicKey + Key []byte +} + +func GenerateJWTToken(sd *TokenSigningData, remoteSourceName, requestType string, request interface{}) (string, error) { + requestj, err := json.Marshal(request) + if err != nil { + return "", err + } + + token := jwt.NewWithClaims(sd.Method, jwt.MapClaims{ + "exp": time.Now().Add(sd.Duration).Unix(), + "remote_source_name": remoteSourceName, + "request_type": requestType, + "request": string(requestj), + }) + + var key interface{} + switch sd.Method { + case jwt.SigningMethodRS256: + key = sd.PrivateKey + case jwt.SigningMethodHS256: + key = sd.Key + default: + errors.Errorf("unsupported signing method %q", sd.Method.Alg()) + } + // Sign and get the complete encoded token as a string + return token.SignedString(key) +} + +func GenerateLoginJWTToken(sd *TokenSigningData, userID string) (string, error) { + token := jwt.NewWithClaims(sd.Method, jwt.MapClaims{ + "sub": userID, + "exp": time.Now().Add(sd.Duration).Unix(), + }) + + var key interface{} + switch sd.Method { + case jwt.SigningMethodRS256: + key = sd.PrivateKey + case jwt.SigningMethodHS256: + key = sd.Key + default: + errors.Errorf("unsupported signing method %q", sd.Method.Alg()) + } + // Sign and get the complete encoded token as a string + return token.SignedString(key) +} diff --git a/internal/services/gateway/gateway.go b/internal/services/gateway/gateway.go new file mode 100644 index 0000000..678b007 --- /dev/null +++ b/internal/services/gateway/gateway.go @@ -0,0 +1,253 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package gateway + +import ( + "context" + "crypto/tls" + "io/ioutil" + "net/http" + + scommon "github.com/sorintlab/agola/internal/common" + slog "github.com/sorintlab/agola/internal/log" + "github.com/sorintlab/agola/internal/objectstorage" + "github.com/sorintlab/agola/internal/services/config" + csapi "github.com/sorintlab/agola/internal/services/configstore/api" + "github.com/sorintlab/agola/internal/services/gateway/api" + "github.com/sorintlab/agola/internal/services/gateway/command" + "github.com/sorintlab/agola/internal/services/gateway/common" + "github.com/sorintlab/agola/internal/services/gateway/handlers" + rsapi "github.com/sorintlab/agola/internal/services/runservice/scheduler/api" + "github.com/sorintlab/agola/internal/util" + + jwt "github.com/dgrijalva/jwt-go" + ghandlers "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/pkg/errors" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var level = zap.NewAtomicLevelAt(zapcore.InfoLevel) +var logger = slog.New(level) +var log = logger.Sugar() + +type Gateway struct { + c *config.Gateway + + lts *objectstorage.ObjStorage + runserviceClient *rsapi.Client + configstoreClient *csapi.Client + ch *command.CommandHandler + sd *common.TokenSigningData +} + +func NewGateway(c *config.Gateway) (*Gateway, error) { + if c.Debug { + level.SetLevel(zapcore.DebugLevel) + } + + if c.Web.ListenAddress == "" { + return nil, errors.Errorf("listen address undefined") + } + + if c.Web.TLS { + if c.Web.TLSKeyFile == "" { + return nil, errors.Errorf("no tls key file specified") + } + if c.Web.TLSCertFile == "" { + return nil, errors.Errorf("no tls cert file specified") + } + } + + sd := &common.TokenSigningData{Duration: c.TokenSigning.Duration} + switch c.TokenSigning.Method { + case "hmac": + sd.Method = jwt.SigningMethodHS256 + if c.TokenSigning.Key == "" { + return nil, errors.Errorf("empty token signing key for hmac method") + } + sd.Key = []byte(c.TokenSigning.Key) + case "rsa": + if c.TokenSigning.PrivateKeyPath == "" { + return nil, errors.Errorf("token signing private key file for rsa method not defined") + } + if c.TokenSigning.PublicKeyPath == "" { + return nil, errors.Errorf("token signing public key file for rsa method not defined") + } + + sd.Method = jwt.SigningMethodRS256 + privateKeyData, err := ioutil.ReadFile(c.TokenSigning.PrivateKeyPath) + if err != nil { + return nil, errors.Wrapf(err, "error reading token signing private key") + } + sd.PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyData) + if err != nil { + return nil, errors.Wrapf(err, "error parsing token signing private key") + } + publicKeyData, err := ioutil.ReadFile(c.TokenSigning.PublicKeyPath) + if err != nil { + return nil, errors.Wrapf(err, "error reading token signing public key") + } + sd.PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyData) + if err != nil { + return nil, errors.Wrapf(err, "error parsing token signing public key") + } + case "": + return nil, errors.Errorf("missing token signing method") + default: + return nil, errors.Errorf("unknown token signing method: %q", c.TokenSigning.Method) + } + + lts, err := scommon.NewLTS(&c.LTS) + if err != nil { + return nil, err + } + + configstoreClient := csapi.NewClient(c.ConfigStoreURL) + + ch := command.NewCommandHandler(logger, sd, configstoreClient, c.APIExposedURL, c.WebExposedURL) + + return &Gateway{ + c: c, + lts: lts, + runserviceClient: rsapi.NewClient(c.RunServiceURL), + configstoreClient: configstoreClient, + ch: ch, + sd: sd, + }, nil +} + +func (g *Gateway) Run(ctx context.Context) error { + // noop coors handler + corsHandler := func(h http.Handler) http.Handler { + return h + } + + corsAllowedMethodsOptions := ghandlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "DELETE"}) + corsAllowedHeadersOptions := ghandlers.AllowedHeaders([]string{"Accept", "Accept-Encoding", "Authorization", "Content-Length", "Content-Type", "X-CSRF-Token", "Authorization"}) + corsAllowedOriginsOptions := ghandlers.AllowedOrigins([]string{"*"}) + corsHandler = ghandlers.CORS(corsAllowedMethodsOptions, corsAllowedHeadersOptions, corsAllowedOriginsOptions) + + webhooksHandler := &webhooksHandler{log: log, configstoreClient: g.configstoreClient, runserviceClient: g.runserviceClient, apiExposedURL: g.c.APIExposedURL} + + projectHandler := api.NewProjectHandler(logger, g.configstoreClient) + projectByNameHandler := api.NewProjectByNameHandler(logger, g.configstoreClient) + projectsHandler := api.NewProjectsHandler(logger, g.configstoreClient) + createProjectHandler := api.NewCreateProjectHandler(logger, g.ch, g.configstoreClient, g.c.APIExposedURL) + deleteProjectHandler := api.NewDeleteProjectHandler(logger, g.configstoreClient) + projectReconfigHandler := api.NewProjectReconfigHandler(logger, g.ch, g.configstoreClient, g.c.APIExposedURL) + + currentUserHandler := api.NewCurrentUserHandler(logger, g.configstoreClient) + userHandler := api.NewUserHandler(logger, g.configstoreClient) + userByNameHandler := api.NewUserByNameHandler(logger, g.configstoreClient) + usersHandler := api.NewUsersHandler(logger, g.configstoreClient) + createUserHandler := api.NewCreateUserHandler(logger, g.configstoreClient) + deleteUserHandler := api.NewDeleteUserHandler(logger, g.configstoreClient) + + createUserLAHandler := api.NewCreateUserLAHandler(logger, g.ch, g.configstoreClient) + deleteUserLAHandler := api.NewDeleteUserLAHandler(logger, g.configstoreClient) + createUserTokenHandler := api.NewCreateUserTokenHandler(logger, g.configstoreClient) + + remoteSourceHandler := api.NewRemoteSourceHandler(logger, g.configstoreClient) + createRemoteSourceHandler := api.NewCreateRemoteSourceHandler(logger, g.configstoreClient) + remoteSourcesHandler := api.NewRemoteSourcesHandler(logger, g.configstoreClient) + + runHandler := api.NewRunHandler(logger, g.runserviceClient) + runsHandler := api.NewRunsHandler(logger, g.runserviceClient) + runtaskHandler := api.NewRuntaskHandler(logger, g.runserviceClient) + + logsHandler := api.NewLogsHandler(logger, g.runserviceClient) + + loginUserHandler := api.NewLoginUserHandler(logger, g.ch, g.configstoreClient) + oauth2callbackHandler := api.NewOAuth2CallbackHandler(logger, g.ch, g.configstoreClient) + + router := mux.NewRouter() + + apirouter := mux.NewRouter().PathPrefix("/api/v1alpha").Subrouter() + + authForcedHandler := handlers.NewAuthHandler(logger, g.configstoreClient, g.c.AdminToken, g.sd, true) + authOptionalHandler := handlers.NewAuthHandler(logger, g.configstoreClient, g.c.AdminToken, g.sd, false) + + router.PathPrefix("/api/v1alpha").Handler(apirouter) + + apirouter.Handle("/logs", logsHandler).Methods("GET") + + apirouter.Handle("/project/{projectid}", authForcedHandler(projectHandler)).Methods("GET") + apirouter.Handle("/projects", authForcedHandler(projectsHandler)).Methods("GET") + apirouter.Handle("/projects", authForcedHandler(createProjectHandler)).Methods("PUT") + apirouter.Handle("/projects/{projectname}", authForcedHandler(projectByNameHandler)).Methods("GET") + apirouter.Handle("/projects/{projectname}", authForcedHandler(deleteProjectHandler)).Methods("DELETE") + apirouter.Handle("/projects/{projectname}/reconfig", authForcedHandler(projectReconfigHandler)).Methods("POST") + + apirouter.Handle("/user", authForcedHandler(currentUserHandler)).Methods("GET") + apirouter.Handle("/user/{userid}", authForcedHandler(userHandler)).Methods("GET") + apirouter.Handle("/users", authForcedHandler(usersHandler)).Methods("GET") + apirouter.Handle("/users", authForcedHandler(createUserHandler)).Methods("PUT") + apirouter.Handle("/users/{username}", authForcedHandler(userByNameHandler)).Methods("GET") + apirouter.Handle("/users/{username}", authForcedHandler(deleteUserHandler)).Methods("DELETE") + + apirouter.Handle("/users/{username}/linkedaccounts", authForcedHandler(createUserLAHandler)).Methods("PUT") + apirouter.Handle("/users/{username}/linkedaccounts/{laid}", authForcedHandler(deleteUserLAHandler)).Methods("DELETE") + apirouter.Handle("/users/{username}/tokens", authForcedHandler(createUserTokenHandler)).Methods("PUT") + + apirouter.Handle("/remotesource/{id}", authForcedHandler(remoteSourceHandler)).Methods("GET") + apirouter.Handle("/remotesources", authForcedHandler(createRemoteSourceHandler)).Methods("PUT") + apirouter.Handle("/remotesources", authOptionalHandler(remoteSourcesHandler)).Methods("GET") + + apirouter.Handle("/run/{runid}", authForcedHandler(runHandler)).Methods("GET") + apirouter.Handle("/run/{runid}/task/{taskid}", authForcedHandler(runtaskHandler)).Methods("GET") + apirouter.Handle("/runs", authForcedHandler(runsHandler)).Methods("GET") + + router.Handle("/login", loginUserHandler).Methods("POST") + router.Handle("/oauth2/callback", oauth2callbackHandler).Methods("GET") + + router.Handle("/webhooks", webhooksHandler).Methods("POST") + router.PathPrefix("/").HandlerFunc(handlers.NewWebBundleHandlerFunc(g.c.APIExposedURL)) + + mainrouter := mux.NewRouter() + mainrouter.PathPrefix("/").Handler(corsHandler(router)) + + var tlsConfig *tls.Config + if g.c.Web.TLS { + var err error + tlsConfig, err = util.NewTLSConfig(g.c.Web.TLSCertFile, g.c.Web.TLSKeyFile, "", false) + if err != nil { + log.Errorf("err: %+v") + return err + } + } + + httpServer := http.Server{ + Addr: g.c.Web.ListenAddress, + Handler: mainrouter, + TLSConfig: tlsConfig, + } + + lerrCh := make(chan error) + go func() { + lerrCh <- httpServer.ListenAndServe() + }() + + select { + case <-ctx.Done(): + log.Infof("configstore exiting") + httpServer.Close() + return nil + case err := <-lerrCh: + log.Errorf("http server listen error: %v", err) + return err + } +} diff --git a/internal/services/gateway/handlers/auth.go b/internal/services/gateway/handlers/auth.go new file mode 100644 index 0000000..c839a31 --- /dev/null +++ b/internal/services/gateway/handlers/auth.go @@ -0,0 +1,170 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "context" + "net/http" + "strings" + + csapi "github.com/sorintlab/agola/internal/services/configstore/api" + "github.com/sorintlab/agola/internal/services/gateway/common" + + jwt "github.com/dgrijalva/jwt-go" + jwtrequest "github.com/dgrijalva/jwt-go/request" + "github.com/pkg/errors" + "go.uber.org/zap" +) + +type AuthHandler struct { + log *zap.SugaredLogger + next http.Handler + + configstoreClient *csapi.Client + adminToken string + + sd *common.TokenSigningData + + required bool +} + +func NewAuthHandler(logger *zap.Logger, configstoreClient *csapi.Client, adminToken string, sd *common.TokenSigningData, required bool) func(http.Handler) http.Handler { + return func(h http.Handler) http.Handler { + return &AuthHandler{ + log: logger.Sugar(), + next: h, + configstoreClient: configstoreClient, + adminToken: adminToken, + sd: sd, + required: required, + } + } +} + +func (h *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + tokenString, _ := TokenExtractor.ExtractToken(r) + if h.adminToken != "" && tokenString != "" { + if tokenString == h.adminToken { + ctx = context.WithValue(ctx, "admin", true) + h.next.ServeHTTP(w, r.WithContext(ctx)) + return + } else { + user, resp, err := h.configstoreClient.GetUserByToken(ctx, tokenString) + if err != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, "", http.StatusUnauthorized) + return + } + if err != nil { + http.Error(w, "", http.StatusInternalServerError) + return + } + + // pass userid to handlers via context + ctx = context.WithValue(ctx, "userid", user.ID) + ctx = context.WithValue(ctx, "username", user.UserName) + h.next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + + tokenString, _ = BearerTokenExtractor.ExtractToken(r) + if tokenString != "" { + token, err := jwtrequest.ParseFromRequest(r, jwtrequest.AuthorizationHeaderExtractor, func(token *jwt.Token) (interface{}, error) { + sd := h.sd + if token.Method != sd.Method { + return nil, errors.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + var key interface{} + switch sd.Method { + case jwt.SigningMethodRS256: + key = sd.PrivateKey + case jwt.SigningMethodHS256: + key = sd.Key + default: + return nil, errors.Errorf("unsupported signing method %q", sd.Method.Alg()) + } + return key, nil + }) + if err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, "", http.StatusUnauthorized) + return + } + if !token.Valid { + http.Error(w, "", http.StatusUnauthorized) + return + } + // Set username in the request context + claims := token.Claims.(jwt.MapClaims) + userID := claims["sub"].(string) + + user, resp, err := h.configstoreClient.GetUser(ctx, userID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + http.Error(w, "", http.StatusUnauthorized) + return + } + http.Error(w, "", http.StatusInternalServerError) + return + } + + // pass userid to handlers via context + ctx = context.WithValue(ctx, "userid", user.ID) + ctx = context.WithValue(ctx, "username", user.UserName) + h.next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + if h.required { + http.Error(w, "", http.StatusUnauthorized) + return + } + + h.next.ServeHTTP(w, r.WithContext(ctx)) +} + +func stripPrefixFromTokenString(prefix string) func(tok string) (string, error) { + return func(tok string) (string, error) { + pl := len(prefix) + if len(tok) > pl && strings.ToUpper(tok[0:pl+1]) == strings.ToUpper(prefix+" ") { + return tok[pl+1:], nil + } + return "", nil + } +} + +// TokenExtractor extracts a token in format "token THETOKEN" from Authorization +// header +// Uses PostExtractionFilter to strip "token " prefix from header +var TokenExtractor = &jwtrequest.PostExtractionFilter{ + jwtrequest.MultiExtractor{ + jwtrequest.HeaderExtractor{"Authorization"}, + jwtrequest.ArgumentExtractor{"access_token"}, + }, + stripPrefixFromTokenString("token"), +} + +// BearerTokenExtractor extracts a bearer token in format "bearer THETOKEN" from +// Authorization header +// Uses PostExtractionFilter to strip "Bearer " prefix from header +var BearerTokenExtractor = &jwtrequest.PostExtractionFilter{ + jwtrequest.MultiExtractor{ + jwtrequest.HeaderExtractor{"Authorization"}, + jwtrequest.ArgumentExtractor{"access_token"}, + }, + stripPrefixFromTokenString("bearer"), +} diff --git a/internal/services/gateway/handlers/webbundle.go b/internal/services/gateway/handlers/webbundle.go new file mode 100644 index 0000000..5c8ac28 --- /dev/null +++ b/internal/services/gateway/handlers/webbundle.go @@ -0,0 +1,96 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "bytes" + "net/http" + "strings" + "text/template" + + "github.com/sorintlab/agola/webbundle" + + assetfs "github.com/elazarl/go-bindata-assetfs" +) + +// TODO(sgotti) now the test web ui directly calls the run api url, but this is +// temporary and all requests should pass from the gateway + +const configTplText = ` +const CONFIG = { + API_URL: '{{.ApiURL}}', + API_BASE_PATH: '{{.ApiBasePath}}', + authType: '{{.AuthType}}' +} + +window.CONFIG = CONFIG +` + +func NewWebBundleHandlerFunc(gatewayURL string) func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + configTpl, err := template.New("config").Parse(configTplText) + if err != nil { + panic(err) + } + + configTplData := struct { + ApiURL string + ApiBasePath string + AuthType string + }{ + gatewayURL, + "/api/v1alpha", + "local", + } + configTpl.Execute(&buf, configTplData) + + config := buf.Bytes() + + return func(w http.ResponseWriter, r *http.Request) { + // Setup serving of bundled webapp from the root path, registered after api + // handlers or it'll match all the requested paths + fileServerHandler := http.FileServer(&assetfs.AssetFS{ + Asset: webbundle.Asset, + AssetDir: webbundle.AssetDir, + AssetInfo: webbundle.AssetInfo, + }) + // check if the required file is available in the webapp asset and serve it + if _, err := webbundle.Asset(r.URL.Path[1:]); err == nil { + fileServerHandler.ServeHTTP(w, r) + return + } + // config.js is the external webapp config file not provided by the + // asset and not needed when served from the api server + if r.URL.Path == "/config.js" { + _, err := w.Write(config) + if err != nil { + http.Error(w, "", http.StatusInternalServerError) + } + return + } + + // skip /api requests + if strings.HasPrefix(r.URL.Path, "/api/") { + http.Error(w, "", http.StatusNotFound) + return + } + + // Fallback to index.html for every other page. Required for the SPA since + // on browser reload it'll ask the current app url but we have to + // provide the index.html + r.URL.Path = "/" + fileServerHandler.ServeHTTP(w, r) + } +} diff --git a/internal/services/gateway/webhook.go b/internal/services/gateway/webhook.go new file mode 100644 index 0000000..4e6f1b5 --- /dev/null +++ b/internal/services/gateway/webhook.go @@ -0,0 +1,247 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package gateway + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + + "github.com/sorintlab/agola/internal/config" + gitsource "github.com/sorintlab/agola/internal/gitsources" + "github.com/sorintlab/agola/internal/runconfig" + csapi "github.com/sorintlab/agola/internal/services/configstore/api" + "github.com/sorintlab/agola/internal/services/gateway/common" + rsapi "github.com/sorintlab/agola/internal/services/runservice/scheduler/api" + "github.com/sorintlab/agola/internal/services/types" + "github.com/sorintlab/agola/internal/util" + + "github.com/pkg/errors" + "go.uber.org/zap" +) + +const ( + agolaDefaultConfigPath = ".agola/config.yml" + + // List of runs annotations + AnnotationEventType = "event_type" + AnnotationRunType = "runtype" + AnnotationProjectID = "projectid" + AnnotationUserID = "userid" + // AnnotationVirtualBranch represent a "virtual branch": i.e a normal branch, a pr (with name pr-$prid), a tag (with name tag-tagname) + AnnotationVirtualBranch = "virtual_branch" + + AnnotationCommitSHA = "commit_sha" + AnnotationRef = "ref" + AnnotationSender = "sender" + AnnotationMessage = "message" + AnnotationCommitLink = "commit_link" + AnnotationCompareLink = "compare_link" + + AnnotationBranch = "branch" + AnnotationBranchLink = "branch_link" + AnnotationTag = "tag" + AnnotationTagLink = "tag_link" + AnnotationPullRequestID = "pull_request_id" + AnnotationPullRequestLink = "pull_request_link" +) + +func genAnnotationVirtualBranch(webhookData *types.WebhookData) string { + switch webhookData.Event { + case types.WebhookEventPush: + return "branch-" + webhookData.Branch + case types.WebhookEventTag: + return "tag-" + webhookData.Tag + case types.WebhookEventPullRequest: + return "pr-" + webhookData.PullRequestID + } + + panic(fmt.Errorf("invalid webhook event type: %q", webhookData.Event)) +} + +func genGroup(baseGroupID string, webhookData *types.WebhookData) string { + // we pathescape the branch name to handle branches with slashes and make the + // branch a single path entry + switch webhookData.Event { + case types.WebhookEventPush: + return path.Join(baseGroupID, "branch-"+url.PathEscape(webhookData.Branch)) + case types.WebhookEventTag: + return path.Join(baseGroupID, "tag-"+url.PathEscape(webhookData.Tag)) + case types.WebhookEventPullRequest: + return path.Join(baseGroupID, "pr-"+url.PathEscape(webhookData.PullRequestID)) + } + + panic(fmt.Errorf("invalid webhook event type: %q", webhookData.Event)) +} + +type webhooksHandler struct { + log *zap.SugaredLogger + configstoreClient *csapi.Client + runserviceClient *rsapi.Client + apiExposedURL string +} + +func (h *webhooksHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + code, userErr, err := h.handleWebhook(r) + if err != nil { + h.log.Errorf("err: %+v", err) + http.Error(w, userErr, code) + } +} + +func (h *webhooksHandler) handleWebhook(r *http.Request) (int, string, error) { + ctx := r.Context() + + projectID := r.URL.Query().Get("projectid") + + defer r.Body.Close() + + var gitSource gitsource.GitSource + project, _, err := h.configstoreClient.GetProject(ctx, projectID) + if err != nil { + return http.StatusBadRequest, "", errors.Wrapf(err, "failed to get project %s", projectID) + } + h.log.Debugf("project: %s", util.Dump(project)) + + user, _, err := h.configstoreClient.GetUserByLinkedAccount(ctx, project.LinkedAccountID) + if err != nil { + return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to get user by linked account %q", project.LinkedAccountID) + } + la := user.LinkedAccounts[project.LinkedAccountID] + h.log.Infof("la: %s", util.Dump(la)) + if la == nil { + return http.StatusInternalServerError, "", errors.Errorf("linked account %q in user %q doesn't exist", project.LinkedAccountID, user.UserName) + } + rs, _, err := h.configstoreClient.GetRemoteSource(ctx, la.RemoteSourceID) + if err != nil { + return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to get remote source %q", la.RemoteSourceID) + } + + gitSource, err = common.GetGitSource(rs, la) + if err != nil { + return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to create gitea client") + } + + sshPrivKey := project.SSHPrivateKey + cloneURL := project.CloneURL + skipSSHHostKeyCheck := project.SkipSSHHostKeyCheck + runType := types.RunTypeProject + webhookData, err := gitSource.ParseWebhook(r) + if err != nil { + return http.StatusBadRequest, "", errors.Wrapf(err, "failed to parse webhook") + } + webhookData.ProjectID = projectID + + h.log.Infof("webhookData: %s", util.Dump(webhookData)) + + var data []byte + err = util.ExponentialBackoff(util.FetchFileBackoff, func() (bool, error) { + var err error + data, err = gitSource.GetFile(webhookData.Repo.Owner, webhookData.Repo.Name, webhookData.CommitSHA, agolaDefaultConfigPath) + if err == nil { + return true, nil + } + h.log.Errorf("get file err: %v", err) + return false, nil + }) + if err != nil { + return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to fetch config file") + } + h.log.Debug("data: %s", data) + + gitURL, err := util.ParseGitURL(cloneURL) + if err != nil { + return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to parse clone url") + } + + env := map[string]string{ + "CI": "true", + "AGOLA_SSHPRIVKEY": sshPrivKey, + "AGOLA_REPOSITORY_URL": cloneURL, + "AGOLA_GIT_HOST": gitURL.Host, + "AGOLA_GIT_REF": webhookData.Ref, + "AGOLA_GIT_COMMITSHA": webhookData.CommitSHA, + } + + if skipSSHHostKeyCheck { + env["AGOLA_SKIPSSHHOSTKEYCHECK"] = "1" + } + + annotations := map[string]string{ + AnnotationProjectID: webhookData.ProjectID, + AnnotationRunType: string(runType), + AnnotationEventType: string(webhookData.Event), + AnnotationVirtualBranch: genAnnotationVirtualBranch(webhookData), + AnnotationCommitSHA: webhookData.CommitSHA, + AnnotationRef: webhookData.Ref, + AnnotationSender: webhookData.Sender, + AnnotationMessage: webhookData.Message, + AnnotationCommitLink: webhookData.CommitLink, + AnnotationCompareLink: webhookData.CompareLink, + } + + if webhookData.Event == types.WebhookEventPush { + annotations[AnnotationBranch] = webhookData.Branch + annotations[AnnotationBranchLink] = webhookData.BranchLink + } + if webhookData.Event == types.WebhookEventTag { + annotations[AnnotationTag] = webhookData.Tag + annotations[AnnotationTagLink] = webhookData.TagLink + } + if webhookData.Event == types.WebhookEventPullRequest { + annotations[AnnotationPullRequestID] = webhookData.PullRequestID + annotations[AnnotationPullRequestLink] = webhookData.PullRequestLink + } + + group := genGroup(webhookData.ProjectID, webhookData) + + if err := h.createRuns(ctx, data, group, annotations, env); err != nil { + return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to create run") + } + //if err := gitSource.CreateStatus(webhookData.Repo.Owner, webhookData.Repo.Name, webhookData.CommitSHA, gitsource.CommitStatusPending, "localhost:8080", "build %s", "agola"); err != nil { + // h.log.Errorf("failed to update commit status: %v", err) + //} + + return 0, "", nil +} + +func (h *webhooksHandler) createRuns(ctx context.Context, configData []byte, group string, annotations, env map[string]string) error { + config, err := config.ParseConfig([]byte(configData)) + if err != nil { + return err + } + //h.log.Debugf("config: %v", util.Dump(config)) + + //h.log.Debugf("pipeline: %s", createRunOpts.PipelineName) + for _, pipeline := range config.Pipelines { + rc := runconfig.GenRunConfig(config, pipeline.Name, env) + + h.log.Debugf("rc: %s", util.Dump(rc)) + h.log.Infof("group: %s", group) + createRunReq := &rsapi.RunCreateRequest{ + RunConfig: rc, + Group: group, + Annotations: annotations, + } + + if _, err := h.runserviceClient.CreateRun(ctx, createRunReq); err != nil { + return err + } + } + + return nil +} diff --git a/internal/services/types/types.go b/internal/services/types/types.go index 3e05248..8c8443e 100644 --- a/internal/services/types/types.go +++ b/internal/services/types/types.go @@ -18,57 +18,13 @@ import ( "time" ) -type WebhookEvent string - -const ( - WebhookEventPush WebhookEvent = "push" - WebhookEventTag WebhookEvent = "tag" - WebhookEventPullRequest WebhookEvent = "pull_request" -) - -type RunType string - -const ( - RunTypeProject RunType = "project" - RunTypeUser RunType = "user" -) - -type WebhookData struct { - Event WebhookEvent `json:"event,omitempty"` - ProjectID string `json:"project_id,omitempty"` - - CompareLink string `json:"compare_link,omitempty"` // Pimray link to source. It can be the commit - CommitLink string `json:"commit_link,omitempty"` // Pimray link to source. It can be the commit - CommitSHA string `json:"commit_sha,omitempty"` // commit SHA (SHA1 but also future SHA like SHA256) - OldCommitSHA string `json:"old_commit_sha,omitempty"` // commit SHA of the head before this push - Ref string `json:"ref,omitempty"` // Ref containing the commit SHA - Message string `json:"message,omitempty"` // Message to use (Push last commit message summary, PR title, Tag message etc...) - Sender string `json:"sender,omitempty"` - Avatar string `json:"avatar,omitempty"` - - Branch string `json:"branch,omitempty"` - BranchLink string `json:"branch_link,omitempty"` - - Tag string `json:"tag,omitempty"` - TagLink string `json:"tag_link,omitempty"` - - // use a string if on some platform (current or future) some PRs id will not be numbers - PullRequestID string `json:"pull_request_id,omitempty"` - PullRequestLink string `json:"link,omitempty"` // Link to pull request - - Repo WebhookDataRepo `json:"repo,omitempty"` -} - -type WebhookDataRepo struct { - Name string `json:"name,omitempty"` - Owner string `json:"owner,omitempty"` - FullName string `json:"full_name,omitempty"` - RepoURL string `json:"repo_url,omitempty"` -} - // Configstore types type User struct { + // The type version. Increase when a breaking change is done. Usually not + // needed when adding fields. + Version string `json:"version,omitempty"` + ID string `json:"id,omitempty"` UserName string `json:"user_name,omitempty"` @@ -97,6 +53,10 @@ const ( ) type RemoteSource struct { + // The type version. Increase when a breaking change is done. Usually not + // needed when adding fields. + Version string `json:"version,omitempty"` + ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -113,6 +73,10 @@ type RemoteSource struct { } type LinkedAccount struct { + // The type version. Increase when a breaking change is done. Usually not + // needed when adding fields. + Version string `json:"version,omitempty"` + ID string `json:"id,omitempty"` RemoteUserID string `json:"remote_user_id,omitempty"` @@ -128,6 +92,10 @@ type LinkedAccount struct { } type Project struct { + // The type version. Increase when a breaking change is done. Usually not + // needed when adding fields. + Version string `json:"version,omitempty"` + ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` diff --git a/internal/services/types/webhook.go b/internal/services/types/webhook.go new file mode 100644 index 0000000..f86bbf4 --- /dev/null +++ b/internal/services/types/webhook.go @@ -0,0 +1,63 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +type WebhookEvent string + +const ( + WebhookEventPush WebhookEvent = "push" + WebhookEventTag WebhookEvent = "tag" + WebhookEventPullRequest WebhookEvent = "pull_request" +) + +type RunType string + +const ( + RunTypeProject RunType = "project" + RunTypeUser RunType = "user" +) + +type WebhookData struct { + Event WebhookEvent `json:"event,omitempty"` + ProjectID string `json:"project_id,omitempty"` + + CompareLink string `json:"compare_link,omitempty"` // Pimray link to source. It can be the commit + CommitLink string `json:"commit_link,omitempty"` // Pimray link to source. It can be the commit + CommitSHA string `json:"commit_sha,omitempty"` // commit SHA (SHA1 but also future SHA like SHA256) + OldCommitSHA string `json:"old_commit_sha,omitempty"` // commit SHA of the head before this push + Ref string `json:"ref,omitempty"` // Ref containing the commit SHA + Message string `json:"message,omitempty"` // Message to use (Push last commit message summary, PR title, Tag message etc...) + Sender string `json:"sender,omitempty"` + Avatar string `json:"avatar,omitempty"` + + Branch string `json:"branch,omitempty"` + BranchLink string `json:"branch_link,omitempty"` + + Tag string `json:"tag,omitempty"` + TagLink string `json:"tag_link,omitempty"` + + // use a string if on some platform (current or future) some PRs id will not be numbers + PullRequestID string `json:"pull_request_id,omitempty"` + PullRequestLink string `json:"link,omitempty"` // Link to pull request + + Repo WebhookDataRepo `json:"repo,omitempty"` +} + +type WebhookDataRepo struct { + Name string `json:"name,omitempty"` + Owner string `json:"owner,omitempty"` + FullName string `json:"full_name,omitempty"` + RepoURL string `json:"repo_url,omitempty"` +} diff --git a/internal/util/backoff.go b/internal/util/backoff.go new file mode 100644 index 0000000..0fe238a --- /dev/null +++ b/internal/util/backoff.go @@ -0,0 +1,106 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "errors" + "math/rand" + "time" +) + +// DefaultRetry is the recommended retry for a conflict where multiple clients +// are making changes to the same resource. +var DefaultRetry = Backoff{ + Steps: 5, + Duration: 10 * time.Millisecond, + Factor: 1.0, + Jitter: 0.1, +} + +// DefaultBackoff is the recommended backoff for a conflict where a client +// may be attempting to make an unrelated modification to a resource under +// active management by one or more controllers. +var DefaultBackoff = Backoff{ + Steps: 4, + Duration: 10 * time.Millisecond, + Factor: 5.0, + Jitter: 0.1, +} + +// DefaultBackoff is the recommended backoff for a conflict where a client +// may be attempting to make an unrelated modification to a resource under +// active management by one or more controllers. +var FetchFileBackoff = Backoff{ + Steps: 4, + Duration: 500 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, +} + +// Jitter returns a time.Duration between duration and duration + maxFactor * +// duration. +// +// This allows clients to avoid converging on periodic behavior. If maxFactor +// is 0.0, a suggested default value will be chosen. +func Jitter(duration time.Duration, maxFactor float64) time.Duration { + if maxFactor <= 0.0 { + maxFactor = 1.0 + } + wait := duration + time.Duration(rand.Float64()*maxFactor*float64(duration)) + return wait +} + +// ErrWaitTimeout is returned when the condition exited without success. +var ErrWaitTimeout = errors.New("timed out waiting for the condition") + +// ConditionFunc returns true if the condition is satisfied, or an error +// if the loop should be aborted. +type ConditionFunc func() (done bool, err error) + +// Backoff holds parameters applied to a Backoff function. +type Backoff struct { + Duration time.Duration // the base duration + Factor float64 // Duration is multiplied by factor each iteration + Jitter float64 // The amount of jitter applied each iteration + Steps int // Exit with error after this many steps +} + +// ExponentialBackoff repeats a condition check with exponential backoff. +// +// It checks the condition up to Steps times, increasing the wait by multiplying +// the previous duration by Factor. +// +// If Jitter is greater than zero, a random amount of each duration is added +// (between duration and duration*(1+jitter)). +// +// If the condition never returns true, ErrWaitTimeout is returned. All other +// errors terminate immediately. +func ExponentialBackoff(backoff Backoff, condition ConditionFunc) error { + duration := backoff.Duration + for i := 0; i < backoff.Steps; i++ { + if i != 0 { + adjusted := duration + if backoff.Jitter > 0.0 { + adjusted = Jitter(duration, backoff.Jitter) + } + time.Sleep(adjusted) + duration = time.Duration(float64(duration) * backoff.Factor) + } + if ok, err := condition(); err != nil || ok { + return err + } + } + return ErrWaitTimeout +} diff --git a/internal/util/ssh.go b/internal/util/ssh.go new file mode 100644 index 0000000..2f8da5d --- /dev/null +++ b/internal/util/ssh.go @@ -0,0 +1,80 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + + "golang.org/x/crypto/ssh" +) + +// GenSSHKeyPair generate an ssh keypair in rsa format, returning the private +// key (in pem encoding) and the public key (in the OpenSSH base64 format) +func GenSSHKeyPair(bits int) ([]byte, []byte, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, nil, err + } + + err = privateKey.Validate() + if err != nil { + return nil, nil, err + } + privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + + var privateBuf bytes.Buffer + if err := pem.Encode(&privateBuf, privateKeyPEM); err != nil { + return nil, nil, errors.New("failed to pem encode private key") + } + + pub, err := ssh.NewPublicKey(&privateKey.PublicKey) + if err != nil { + return nil, nil, errors.New("failed to generate public key") + } + + // remove trailing \n returned by ssh.MarshalAuthorizedKey + return privateBuf.Bytes(), bytes.TrimSuffix(ssh.MarshalAuthorizedKey(pub), []byte("\n")), nil +} + +// ExtraxtPublicKey extracts the public key from a ssh private key in pem format +func ExtractPublicKey(privateKeyPEM []byte) ([]byte, error) { + block, _ := pem.Decode(privateKeyPEM) + if block == nil || block.Type != "RSA PRIVATE KEY" { + return nil, errors.New("failed to decode PEM block containing rsa private key") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, errors.New("failed to parse rsa private key") + } + + err = privateKey.Validate() + if err != nil { + return nil, errors.New("failed to validate rsa private key") + } + + pub, err := ssh.NewPublicKey(&privateKey.PublicKey) + if err != nil { + return nil, errors.New("failed to generate public key") + } + + // remove trailing \n returned by ssh.MarshalAuthorizedKey + return bytes.TrimSuffix(ssh.MarshalAuthorizedKey(pub), []byte("\n")), nil +} diff --git a/internal/util/validation.go b/internal/util/validation.go new file mode 100644 index 0000000..11c9e46 --- /dev/null +++ b/internal/util/validation.go @@ -0,0 +1,30 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "errors" + "regexp" +) + +var nameRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9]*([-]?[a-zA-Z0-9]+)+$`) + +var ( + ErrValidation = errors.New("validation error") +) + +func ValidateName(s string) bool { + return nameRegexp.MatchString(s) +} diff --git a/internal/util/validation_test.go b/internal/util/validation_test.go new file mode 100644 index 0000000..986fe85 --- /dev/null +++ b/internal/util/validation_test.go @@ -0,0 +1,59 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import "testing" + +var ( + goodNames = []string{ + "bar", + "foo-bar", + "foo-bar-baz", + "foo1", + "foo-1", + "foo-1-bar", + "f12oo-bar33", + } + badNames = []string{ + "", + "foo bar", + " foo bar", + "foo bar ", + "-bar", + "bar-", + "-foo-bar", + "foo-bar-", + "foo--bar", + "foo.bar", + "foo_bar", + "foo#bar", + "1foobar", + } +) + +func TestValidateName(t *testing.T) { + for _, name := range goodNames { + ok := ValidateName(name) + if !ok { + t.Errorf("expect valid name for %q", name) + } + } + for _, name := range badNames { + ok := ValidateName(name) + if ok { + t.Errorf("expect invalid name for %q", name) + } + } +}