From 2bd128af13c58e4de1633bc7b452f0b091872af6 Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Tue, 3 May 2022 19:49:14 +0000 Subject: [PATCH] Runners now send status messages --- apiserver/controllers/controllers.go | 93 +++++++++++++++++++ apiserver/routers/routers.go | 22 ++++- auth/context.go | 88 ++++++++++++++++++ auth/instance_middleware.go | 132 +++++++++++++++++++++++++++ auth/jwt.go | 39 -------- cloudconfig/templates.go | 8 +- cmd/run-cli/client/client.go | 51 +++++++++++ cmd/run-cli/cmd/provider.go | 4 +- cmd/run-cli/cmd/repo_instances.go | 52 +++++++++++ cmd/run-cli/cmd/repository.go | 23 +++++ cmd/run-cli/cmd/runner.go | 130 ++++++++++++++++++++++---- cmd/runner-manager/main.go | 7 +- config/config.go | 1 + database/common/common.go | 1 + database/sql/models.go | 16 +++- database/sql/sql.go | 66 +++++++++++--- params/params.go | 8 ++ params/requests.go | 5 + runner/pool/repository.go | 35 +++---- runner/providers/lxd/lxd.go | 1 + runner/repositories.go | 62 ++++++++++++- util/util.go | 2 +- 22 files changed, 741 insertions(+), 105 deletions(-) create mode 100644 auth/instance_middleware.go create mode 100644 cmd/run-cli/cmd/repo_instances.go diff --git a/apiserver/controllers/controllers.go b/apiserver/controllers/controllers.go index b5c42140..aa611c12 100644 --- a/apiserver/controllers/controllers.go +++ b/apiserver/controllers/controllers.go @@ -449,3 +449,96 @@ func (a *APIController) UpdateRepoPoolHandler(w http.ResponseWriter, r *http.Req w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(pool) } + +func (a *APIController) ListRepoInstancesHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + repoID, ok := vars["repoID"] + if !ok { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(params.APIErrorResponse{ + Error: "Bad Request", + Details: "No repo ID specified", + }) + return + } + + instances, err := a.r.ListRepoInstances(ctx, repoID) + if err != nil { + log.Printf("listing pools: %+v", err) + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(instances) +} + +func (a *APIController) ListPoolInstancesHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + poolID, ok := vars["poolID"] + if !ok { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(params.APIErrorResponse{ + Error: "Bad Request", + Details: "No repo ID specified", + }) + return + } + + instances, err := a.r.ListPoolInstances(ctx, poolID) + if err != nil { + log.Printf("listing pools: %+v", err) + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(instances) +} + +func (a *APIController) GetInstanceHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + instanceName, ok := vars["instanceName"] + if !ok { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(params.APIErrorResponse{ + Error: "Bad Request", + Details: "No repo ID specified", + }) + return + } + + instance, err := a.r.GetInstance(ctx, instanceName) + if err != nil { + log.Printf("listing pools: %+v", err) + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(instance) +} + +func (a *APIController) InstanceStatusMessageHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var updateMessage runnerParams.InstanceUpdateMessage + if err := json.NewDecoder(r.Body).Decode(&updateMessage); err != nil { + log.Printf("failed to decode: %+v", err) + handleError(w, gErrors.ErrBadRequest) + return + } + + log.Printf("Update body is: %v", updateMessage) + if err := a.r.AddInstanceStatusMessage(ctx, updateMessage); err != nil { + log.Printf("error saving status message: %+v", err) + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} diff --git a/apiserver/routers/routers.go b/apiserver/routers/routers.go index 516b69ed..696a84da 100644 --- a/apiserver/routers/routers.go +++ b/apiserver/routers/routers.go @@ -12,7 +12,7 @@ import ( "runner-manager/auth" ) -func NewAPIRouter(han *controllers.APIController, logWriter io.Writer, authMiddleware, initMiddleware auth.Middleware) *mux.Router { +func NewAPIRouter(han *controllers.APIController, logWriter io.Writer, authMiddleware, initMiddleware, instanceMiddleware auth.Middleware) *mux.Router { router := mux.NewRouter() log := gorillaHandlers.CombinedLoggingHandler @@ -28,6 +28,11 @@ func NewAPIRouter(han *controllers.APIController, logWriter io.Writer, authMiddl firstRunRouter := apiSubRouter.PathPrefix("/first-run").Subrouter() firstRunRouter.Handle("/", log(os.Stdout, http.HandlerFunc(han.FirstRunHandler))).Methods("POST", "OPTIONS") + // Instance callback + callbackRouter := apiSubRouter.PathPrefix("/callbacks").Subrouter() + callbackRouter.Handle("/status/", log(os.Stdout, http.HandlerFunc(han.InstanceStatusMessageHandler))).Methods("POST", "OPTIONS") + callbackRouter.Handle("/status", log(os.Stdout, http.HandlerFunc(han.InstanceStatusMessageHandler))).Methods("POST", "OPTIONS") + callbackRouter.Use(instanceMiddleware.Middleware) // Login authRouter := apiSubRouter.PathPrefix("/auth").Subrouter() authRouter.Handle("/{login:login\\/?}", log(os.Stdout, http.HandlerFunc(han.LoginHandler))).Methods("POST", "OPTIONS") @@ -37,6 +42,17 @@ func NewAPIRouter(han *controllers.APIController, logWriter io.Writer, authMiddl apiRouter.Use(initMiddleware.Middleware) apiRouter.Use(authMiddleware.Middleware) + // Runners (instances) + // List pool instances + apiRouter.Handle("/pools/instances/{poolID}/", log(os.Stdout, http.HandlerFunc(han.ListPoolInstancesHandler))).Methods("GET", "OPTIONS") + apiRouter.Handle("/pools/instances/{poolID}", log(os.Stdout, http.HandlerFunc(han.ListPoolInstancesHandler))).Methods("GET", "OPTIONS") + // Get instance + apiRouter.Handle("/instances/{instanceName}/", log(os.Stdout, http.HandlerFunc(han.GetInstanceHandler))).Methods("GET", "OPTIONS") + apiRouter.Handle("/instances/{instanceName}", log(os.Stdout, http.HandlerFunc(han.GetInstanceHandler))).Methods("GET", "OPTIONS") + // Delete instance + // apiRouter.Handle("/instances/{instanceName}/", log(os.Stdout, http.HandlerFunc(han.CatchAll))).Methods("DELETE", "OPTIONS") + // apiRouter.Handle("/instances/{instanceName}", log(os.Stdout, http.HandlerFunc(han.CatchAll))).Methods("DELETE", "OPTIONS") + ///////////////////// // Repos and pools // ///////////////////// @@ -56,6 +72,10 @@ func NewAPIRouter(han *controllers.APIController, logWriter io.Writer, authMiddl apiRouter.Handle("/repositories/{repoID}/pools/", log(os.Stdout, http.HandlerFunc(han.CreateRepoPoolHandler))).Methods("POST", "OPTIONS") apiRouter.Handle("/repositories/{repoID}/pools", log(os.Stdout, http.HandlerFunc(han.CreateRepoPoolHandler))).Methods("POST", "OPTIONS") + // Repo instances list + apiRouter.Handle("/repositories/{repoID}/instances/", log(os.Stdout, http.HandlerFunc(han.ListRepoInstancesHandler))).Methods("GET", "OPTIONS") + apiRouter.Handle("/repositories/{repoID}/instances", log(os.Stdout, http.HandlerFunc(han.ListRepoInstancesHandler))).Methods("GET", "OPTIONS") + // Get repo apiRouter.Handle("/repositories/{repoID}/", log(os.Stdout, http.HandlerFunc(han.GetRepoByIDHandler))).Methods("GET", "OPTIONS") apiRouter.Handle("/repositories/{repoID}", log(os.Stdout, http.HandlerFunc(han.GetRepoByIDHandler))).Methods("GET", "OPTIONS") diff --git a/auth/context.go b/auth/context.go index 9e99f425..130015ed 100644 --- a/auth/context.go +++ b/auth/context.go @@ -8,6 +8,21 @@ import ( type contextFlags string +/* +// InstanceJWTClaims holds JWT claims +type InstanceJWTClaims struct { + ID string `json:"id"` + Name string `json:"name"` + PoolID string `json:"provider_id"` + // Scope is either repository or organization + Scope common.PoolType `json:"scope"` + // Entity is the repo or org name + Entity string `json:"entity"` + jwt.StandardClaims +} + +*/ + const ( isAdminKey contextFlags = "is_admin" fullNameKey contextFlags = "full_name" @@ -15,8 +30,81 @@ const ( UserIDFlag contextFlags = "user_id" isEnabledFlag contextFlags = "is_enabled" jwtTokenFlag contextFlags = "jwt_token" + + instanceIDKey contextFlags = "id" + instanceNameKey contextFlags = "name" + instancePoolIDKey contextFlags = "pool_id" + instancePoolTypeKey contextFlags = "scope" + instanceEntityKey contextFlags = "entity" ) +func SetInstanceID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, instanceIDKey, id) +} + +func InstanceID(ctx context.Context) string { + elem := ctx.Value(instanceIDKey) + if elem == nil { + return "" + } + return elem.(string) +} + +func SetInstanceName(ctx context.Context, val string) context.Context { + return context.WithValue(ctx, instanceNameKey, val) +} + +func InstanceName(ctx context.Context) string { + elem := ctx.Value(instanceNameKey) + if elem == nil { + return "" + } + return elem.(string) +} + +func SetInstancePoolID(ctx context.Context, val string) context.Context { + return context.WithValue(ctx, instancePoolIDKey, val) +} + +func InstancePoolID(ctx context.Context) string { + elem := ctx.Value(instancePoolIDKey) + if elem == nil { + return "" + } + return elem.(string) +} + +func SetInstancePoolType(ctx context.Context, val string) context.Context { + return context.WithValue(ctx, instancePoolTypeKey, val) +} + +func InstancePoolType(ctx context.Context) string { + elem := ctx.Value(instancePoolTypeKey) + if elem == nil { + return "" + } + return elem.(string) +} + +func SetInstanceEntity(ctx context.Context, val string) context.Context { + return context.WithValue(ctx, instanceEntityKey, val) +} + +func InstanceEntity(ctx context.Context) string { + elem := ctx.Value(instanceEntityKey) + if elem == nil { + return "" + } + return elem.(string) +} + +func PopulateInstanceContext(ctx context.Context, instance params.Instance) context.Context { + ctx = SetInstanceID(ctx, instance.ID) + ctx = SetInstanceName(ctx, instance.Name) + ctx = SetInstancePoolID(ctx, instance.PoolID) + return ctx +} + // PopulateContext sets the appropriate fields in the context, based on // the user object func PopulateContext(ctx context.Context, user params.User) context.Context { diff --git a/auth/instance_middleware.go b/auth/instance_middleware.go new file mode 100644 index 00000000..d577219b --- /dev/null +++ b/auth/instance_middleware.go @@ -0,0 +1,132 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "runner-manager/config" + dbCommon "runner-manager/database/common" + runnerErrors "runner-manager/errors" + "runner-manager/params" + "runner-manager/runner/common" + "strings" + "time" + + "github.com/golang-jwt/jwt" + "github.com/pkg/errors" +) + +// InstanceJWTClaims holds JWT claims +type InstanceJWTClaims struct { + ID string `json:"id"` + Name string `json:"name"` + PoolID string `json:"provider_id"` + // Scope is either repository or organization + Scope common.PoolType `json:"scope"` + // Entity is the repo or org name + Entity string `json:"entity"` + jwt.StandardClaims +} + +func NewInstanceJWTToken(instance params.Instance, secret, entity string, poolType common.PoolType) (string, error) { + // make TTL configurable? + expireToken := time.Now().Add(3 * time.Hour).Unix() + claims := InstanceJWTClaims{ + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expireToken, + Issuer: "runner-manager", + }, + ID: instance.ID, + Name: instance.Name, + PoolID: instance.PoolID, + Scope: poolType, + Entity: entity, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + return "", errors.Wrap(err, "signing token") + } + + return tokenString, nil +} + +// instanceMiddleware is the authentication middleware +// used with gorilla +type instanceMiddleware struct { + store dbCommon.Store + auth *Authenticator + cfg config.JWTAuth +} + +// NewjwtMiddleware returns a populated jwtMiddleware +func NewInstanceMiddleware(store dbCommon.Store, cfg config.JWTAuth) (Middleware, error) { + return &instanceMiddleware{ + store: store, + cfg: cfg, + }, nil +} + +func (amw *instanceMiddleware) claimsToContext(ctx context.Context, claims *InstanceJWTClaims) (context.Context, error) { + if claims == nil { + return ctx, runnerErrors.ErrUnauthorized + } + + if claims.Name == "" { + return nil, runnerErrors.ErrUnauthorized + } + + instanceInfo, err := amw.store.GetInstanceByName(ctx, claims.Name) + if err != nil { + return ctx, runnerErrors.ErrUnauthorized + } + + ctx = PopulateInstanceContext(ctx, instanceInfo) + return ctx, nil +} + +// Middleware implements the middleware interface +func (amw *instanceMiddleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: Log error details when authentication fails + ctx := r.Context() + authorizationHeader := r.Header.Get("authorization") + if authorizationHeader == "" { + invalidAuthResponse(w) + return + } + + bearerToken := strings.Split(authorizationHeader, " ") + if len(bearerToken) != 2 { + invalidAuthResponse(w) + return + } + + claims := &InstanceJWTClaims{} + token, err := jwt.ParseWithClaims(bearerToken[1], claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("invalid signing method") + } + return []byte(amw.cfg.Secret), nil + }) + + if err != nil { + invalidAuthResponse(w) + return + } + + if !token.Valid { + invalidAuthResponse(w) + return + } + + ctx, err = amw.claimsToContext(ctx, claims) + if err != nil { + invalidAuthResponse(w) + return + } + + // ctx = SetJWTClaim(ctx, *claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/auth/jwt.go b/auth/jwt.go index ad52ca30..0fed395a 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -6,31 +6,15 @@ import ( "fmt" "net/http" "strings" - "time" apiParams "runner-manager/apiserver/params" "runner-manager/config" dbCommon "runner-manager/database/common" runnerErrors "runner-manager/errors" - "runner-manager/params" - "runner-manager/runner/common" "github.com/golang-jwt/jwt" - "github.com/pkg/errors" ) -// InstanceJWTClaims holds JWT claims -type InstanceJWTClaims struct { - ID string `json:"id"` - Name string `json:"name"` - PoolID string `json:"provider_id"` - // Scope is either repository or organization - Scope common.PoolType `json:"scope"` - // Entity is the repo or org name - Entity string `json:"entity"` - jwt.StandardClaims -} - // JWTClaims holds JWT claims type JWTClaims struct { UserID string `json:"user"` @@ -40,29 +24,6 @@ type JWTClaims struct { jwt.StandardClaims } -func NewInstanceJWTToken(instance params.Instance, secret, entity string, poolType common.PoolType) (string, error) { - // make TTL configurable? - expireToken := time.Now().Add(3 * time.Hour).Unix() - claims := InstanceJWTClaims{ - StandardClaims: jwt.StandardClaims{ - ExpiresAt: expireToken, - Issuer: "runner-manager", - }, - ID: instance.ID, - Name: instance.Name, - PoolID: instance.PoolID, - Scope: poolType, - Entity: entity, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(secret)) - if err != nil { - return "", errors.Wrap(err, "signing token") - } - - return tokenString, nil -} - // jwtMiddleware is the authentication middleware // used with gorilla type jwtMiddleware struct { diff --git a/cloudconfig/templates.go b/cloudconfig/templates.go index a0d38351..04f92325 100644 --- a/cloudconfig/templates.go +++ b/cloudconfig/templates.go @@ -17,22 +17,22 @@ BEARER_TOKEN="{{ .CallbackToken }}" function call() { PAYLOAD="$1" - curl -s -X POST -d \'${PAYLOAD}\' -H 'Accept: application/json' -H "Authorization: Bearer ${BEARER_TOKEN}" "${CALLBACK_URL}" || echo "failed to call home: exit code ($?)" + curl -s -X POST -d "${PAYLOAD}" -H 'Accept: application/json' -H "Authorization: Bearer ${BEARER_TOKEN}" "${CALLBACK_URL}" || echo "failed to call home: exit code ($?)" } function sendStatus() { MSG="$1" - call '{"status": "installing", "message": "'$MSG'"}' + call "{\"status\": \"installing\", \"message\": \"$MSG\"}" } function success() { MSG="$1" - call '{"status": "active", "message": "'$MSG'"}' + call "{\"status\": \"idle\", \"message\": \"$MSG\"}" } function fail() { MSG="$1" - call '{"status": "failed", "message": "'$MSG'"}' + call "{\"status\": \"failed\", \"message\": \"$MSG\"}" exit 1 } diff --git a/cmd/run-cli/client/client.go b/cmd/run-cli/client/client.go index 4a877834..daef0b3d 100644 --- a/cmd/run-cli/client/client.go +++ b/cmd/run-cli/client/client.go @@ -281,3 +281,54 @@ func (c *Client) UpdateRepoPool(repoID, poolID string, param params.UpdatePoolPa } return response, nil } + +func (c *Client) ListRepoInstances(repoID string) ([]params.Instance, error) { + url := fmt.Sprintf("%s/api/v1/repositories/%s/instances", c.Config.BaseURL, repoID) + + var response []params.Instance + resp, err := c.client.R(). + SetResult(&response). + Get(url) + if err != nil || resp.IsError() { + apiErr, decErr := c.decodeAPIError(resp.Body()) + if decErr != nil { + return response, errors.Wrap(decErr, "sending request") + } + return response, fmt.Errorf("error performing login: %s", apiErr.Details) + } + return response, nil +} + +func (c *Client) ListPoolInstances(poolID string) ([]params.Instance, error) { + url := fmt.Sprintf("%s/api/v1/pools/instances/%s", c.Config.BaseURL, poolID) + + var response []params.Instance + resp, err := c.client.R(). + SetResult(&response). + Get(url) + if err != nil || resp.IsError() { + apiErr, decErr := c.decodeAPIError(resp.Body()) + if decErr != nil { + return response, errors.Wrap(decErr, "sending request") + } + return response, fmt.Errorf("error performing login: %s", apiErr.Details) + } + return response, nil +} + +func (c *Client) GetInstanceByName(instanceName string) (params.Instance, error) { + url := fmt.Sprintf("%s/api/v1/instances/%s", c.Config.BaseURL, instanceName) + + var response params.Instance + resp, err := c.client.R(). + SetResult(&response). + Get(url) + if err != nil || resp.IsError() { + apiErr, decErr := c.decodeAPIError(resp.Body()) + if decErr != nil { + return response, errors.Wrap(decErr, "sending request") + } + return response, fmt.Errorf("error performing login: %s", apiErr.Details) + } + return response, nil +} diff --git a/cmd/run-cli/cmd/provider.go b/cmd/run-cli/cmd/provider.go index c445c42c..e108cae7 100644 --- a/cmd/run-cli/cmd/provider.go +++ b/cmd/run-cli/cmd/provider.go @@ -52,10 +52,10 @@ func init() { func formatProviders(providers []params.Provider) { t := table.NewWriter() - header := table.Row{"Name", "Description"} + header := table.Row{"Name", "Description", "Type"} t.AppendHeader(header) for _, val := range providers { - t.AppendRow(table.Row{val.Name, val.ProviderType}) + t.AppendRow(table.Row{val.Name, val.Description, val.ProviderType}) t.AppendSeparator() } fmt.Println(t.Render()) diff --git a/cmd/run-cli/cmd/repo_instances.go b/cmd/run-cli/cmd/repo_instances.go new file mode 100644 index 00000000..4a38a81c --- /dev/null +++ b/cmd/run-cli/cmd/repo_instances.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// repoPoolCmd represents the pool command +var repoInstancesCmd = &cobra.Command{ + Use: "runner", + SilenceUsage: true, + Short: "List runners", + Long: `List runners from all pools defined in this repository.`, + Run: nil, +} + +var repoRunnerListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List repository runners", + Long: `List all runners for a given repository.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return needsInitError + } + + if len(args) == 0 { + return fmt.Errorf("requires a repository ID") + } + + if len(args) > 1 { + return fmt.Errorf("too many arguments") + } + + instances, err := cli.ListRepoInstances(args[0]) + if err != nil { + return err + } + formatInstances(instances) + return nil + }, +} + +func init() { + repoInstancesCmd.AddCommand( + repoRunnerListCmd, + ) + + repositoryCmd.AddCommand(repoInstancesCmd) +} diff --git a/cmd/run-cli/cmd/repository.go b/cmd/run-cli/cmd/repository.go index c0f3e6ec..13336a29 100644 --- a/cmd/run-cli/cmd/repository.go +++ b/cmd/run-cli/cmd/repository.go @@ -126,6 +126,29 @@ var repoDeleteCmd = &cobra.Command{ }, } +var repoInstanceListCmd = &cobra.Command{ + Use: "delete", + Aliases: []string{"remove", "rm", "del"}, + Short: "Removes one repository", + Long: `Delete one repository from the manager.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return needsInitError + } + if len(args) == 0 { + return fmt.Errorf("requires a repository ID") + } + if len(args) > 1 { + return fmt.Errorf("too many arguments") + } + if err := cli.DeleteRepository(args[0]); err != nil { + return err + } + return nil + }, +} + func init() { repoAddCmd.Flags().StringVar(&repoOwner, "owner", "", "The owner of this repository") diff --git a/cmd/run-cli/cmd/runner.go b/cmd/run-cli/cmd/runner.go index f92f082d..49d0a816 100644 --- a/cmd/run-cli/cmd/runner.go +++ b/cmd/run-cli/cmd/runner.go @@ -6,7 +6,9 @@ package cmd import ( "fmt" + "runner-manager/params" + "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" ) @@ -14,28 +16,120 @@ import ( var runnerCmd = &cobra.Command{ Use: "runner", SilenceUsage: true, - Short: "A brief description of your command", - Long: `A longer description that spans multiple lines and likely contains examples -and usage of using your command. For example: + Short: "List runners in a pool", + Long: `Given a pool ID, of either a repository or an organization, +list all instances.`, + Run: nil, +} -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("runner called") +var runnerListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List pool runners", + Long: `List all configured pools for a given repository.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return needsInitError + } + + if len(args) == 0 { + return fmt.Errorf("requires a pool ID") + } + + if len(args) > 1 { + return fmt.Errorf("too many arguments") + } + + instances, err := cli.ListPoolInstances(args[0]) + if err != nil { + return err + } + formatInstances(instances) + return nil + }, +} + +var runnerShowCmd = &cobra.Command{ + Use: "show", + Short: "Show details for a runner", + Long: `Displays a detailed view of a single runner.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return needsInitError + } + + if len(args) == 0 { + return fmt.Errorf("requires a runner name") + } + + if len(args) > 1 { + return fmt.Errorf("too many arguments") + } + + instance, err := cli.GetInstanceByName(args[0]) + if err != nil { + return err + } + formatSingleInstance(instance) + return nil }, } func init() { + runnerCmd.AddCommand( + runnerListCmd, + runnerShowCmd, + ) + rootCmd.AddCommand(runnerCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // runnerCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // runnerCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} + +func formatInstances(param []params.Instance) { + t := table.NewWriter() + header := table.Row{"Name", "Status", "Runner Status", "Pool ID"} + t.AppendHeader(header) + + for _, inst := range param { + t.AppendRow(table.Row{inst.Name, inst.Status, inst.RunnerStatus, inst.PoolID}) + t.AppendSeparator() + } + fmt.Println(t.Render()) +} + +func formatSingleInstance(instance params.Instance) { + t := table.NewWriter() + + header := table.Row{"Field", "Value"} + + t.AppendHeader(header) + t.AppendRow(table.Row{"ID", instance.ID}, table.RowConfig{AutoMerge: false}) + t.AppendRow(table.Row{"Provider ID", instance.ProviderID}, table.RowConfig{AutoMerge: false}) + t.AppendRow(table.Row{"Name", instance.Name}, table.RowConfig{AutoMerge: false}) + t.AppendRow(table.Row{"OS Type", instance.OSType}, table.RowConfig{AutoMerge: false}) + t.AppendRow(table.Row{"OS Architecture", instance.OSArch}, table.RowConfig{AutoMerge: false}) + t.AppendRow(table.Row{"OS Name", instance.OSName}, table.RowConfig{AutoMerge: false}) + t.AppendRow(table.Row{"OS Version", instance.OSVersion}, table.RowConfig{AutoMerge: false}) + t.AppendRow(table.Row{"Status", instance.Status}, table.RowConfig{AutoMerge: false}) + t.AppendRow(table.Row{"Runner Status", instance.RunnerStatus}, table.RowConfig{AutoMerge: false}) + t.AppendRow(table.Row{"Pool ID", instance.PoolID}, table.RowConfig{AutoMerge: false}) + + if len(instance.Addresses) > 0 { + for _, addr := range instance.Addresses { + t.AppendRow(table.Row{"Addresses", addr}, table.RowConfig{AutoMerge: true}) + } + } + + if len(instance.StatusMessages) > 0 { + for _, msg := range instance.StatusMessages { + t.AppendRow(table.Row{"Status Updates", fmt.Sprintf("%s: %s", msg.CreatedAt.Format("2006-01-02T15:04:05"), msg.Message)}, table.RowConfig{AutoMerge: true}) + } + } + + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: false}, + }) + fmt.Println(t.Render()) } diff --git a/cmd/runner-manager/main.go b/cmd/runner-manager/main.go index 934f349c..be865f3b 100644 --- a/cmd/runner-manager/main.go +++ b/cmd/runner-manager/main.go @@ -90,6 +90,11 @@ func main() { log.Fatalf("failed to create controller: %+v", err) } + instanceMiddleware, err := auth.NewInstanceMiddleware(db, cfg.JWTAuth) + if err != nil { + log.Fatal(err) + } + jwtMiddleware, err := auth.NewjwtMiddleware(db, cfg.JWTAuth) if err != nil { log.Fatal(err) @@ -100,7 +105,7 @@ func main() { log.Fatal(err) } - router := routers.NewAPIRouter(controller, logWriter, jwtMiddleware, initMiddleware) + router := routers.NewAPIRouter(controller, logWriter, jwtMiddleware, initMiddleware, instanceMiddleware) tlsCfg, err := cfg.APIServer.APITLSConfig() if err != nil { diff --git a/config/config.go b/config/config.go index 672259e8..81503fef 100644 --- a/config/config.go +++ b/config/config.go @@ -165,6 +165,7 @@ func (g *Github) Validate() error { type Provider struct { Name string `toml:"name" json:"name"` ProviderType ProviderType `toml:"provider_type" json:"provider-type"` + Description string `toml:"description" json:"description"` LXD LXD `toml:"lxd" json:"lxd"` } diff --git a/database/common/common.go b/database/common/common.go index c2b36447..d9f3e4d9 100644 --- a/database/common/common.go +++ b/database/common/common.go @@ -47,6 +47,7 @@ type Store interface { // GetInstance(ctx context.Context, poolID string, instanceID string) (params.Instance, error) GetPoolInstanceByName(ctx context.Context, poolID string, instanceName string) (params.Instance, error) GetInstanceByName(ctx context.Context, instanceName string) (params.Instance, error) + AddInstanceStatusMessage(ctx context.Context, instanceID string, statusMessage string) error GetUser(ctx context.Context, user string) (params.User, error) GetUserByID(ctx context.Context, userID string) (params.User, error) diff --git a/database/sql/models.go b/database/sql/models.go index ccd463be..b531a2ae 100644 --- a/database/sql/models.go +++ b/database/sql/models.go @@ -83,6 +83,18 @@ type Address struct { Address string Type string + + InstanceID uuid.UUID + Instance Instance `gorm:"foreignKey:InstanceID"` +} + +type InstanceStatusUpdate struct { + Base + + Message string `gorm:"type:text"` + + InstanceID uuid.UUID + Instance Instance `gorm:"foreignKey:InstanceID"` } type Instance struct { @@ -94,13 +106,15 @@ type Instance struct { OSArch config.OSArch OSName string OSVersion string - Addresses []Address `gorm:"foreignKey:id"` + Addresses []Address `gorm:"foreignKey:InstanceID"` Status common.InstanceStatus RunnerStatus common.RunnerStatus CallbackURL string PoolID uuid.UUID Pool Pool `gorm:"foreignKey:PoolID"` + + StatusMessages []InstanceStatusUpdate `gorm:"foreignKey:InstanceID"` } type User struct { diff --git a/database/sql/sql.go b/database/sql/sql.go index ba9237e5..1d424464 100644 --- a/database/sql/sql.go +++ b/database/sql/sql.go @@ -45,6 +45,7 @@ func (s *sqlDatabase) migrateDB() error { &Repository{}, &Organization{}, &Address{}, + &InstanceStatusUpdate{}, &Instance{}, &ControllerInfo{}, &User{}, @@ -470,7 +471,9 @@ func (s *sqlDatabase) CreateRepositoryPool(ctx context.Context, repoId string, p } for _, tt := range tags { - s.conn.Model(&newPool).Association("Tags").Append(&tt) + if err := s.conn.Model(&newPool).Association("Tags").Append(&tt); err != nil { + return params.Pool{}, errors.Wrap(err, "saving tag") + } } pool, err := s.getPoolByID(ctx, newPool.ID.String(), "Tags") @@ -783,22 +786,30 @@ func (s *sqlDatabase) sqlToParamsInstance(instance Instance) params.Instance { id = *instance.ProviderID } ret := params.Instance{ - ID: instance.ID.String(), - ProviderID: id, - Name: instance.Name, - OSType: instance.OSType, - OSName: instance.OSName, - OSVersion: instance.OSVersion, - OSArch: instance.OSArch, - Status: instance.Status, - RunnerStatus: instance.RunnerStatus, - PoolID: instance.PoolID.String(), - CallbackURL: instance.CallbackURL, + ID: instance.ID.String(), + ProviderID: id, + Name: instance.Name, + OSType: instance.OSType, + OSName: instance.OSName, + OSVersion: instance.OSVersion, + OSArch: instance.OSArch, + Status: instance.Status, + RunnerStatus: instance.RunnerStatus, + PoolID: instance.PoolID.String(), + CallbackURL: instance.CallbackURL, + StatusMessages: []params.StatusMessage{}, } for _, addr := range instance.Addresses { ret.Addresses = append(ret.Addresses, s.sqlAddressToParamsAddress(addr)) } + + for _, msg := range instance.StatusMessages { + ret.StatusMessages = append(ret.StatusMessages, params.StatusMessage{ + CreatedAt: msg.CreatedAt, + Message: msg.Message, + }) + } return ret } @@ -864,9 +875,18 @@ func (s *sqlDatabase) getPoolInstanceByName(ctx context.Context, poolID string, return instance, nil } -func (s *sqlDatabase) getInstanceByName(ctx context.Context, instanceName string) (Instance, error) { +func (s *sqlDatabase) getInstanceByName(ctx context.Context, instanceName string, preload ...string) (Instance, error) { var instance Instance - q := s.conn.Model(&Instance{}). + + q := s.conn + + if len(preload) > 0 { + for _, item := range preload { + q = q.Preload(item) + } + } + + q = q.Model(&Instance{}). Preload(clause.Associations). Where("name = ?", instanceName). First(&instance) @@ -885,7 +905,7 @@ func (s *sqlDatabase) GetPoolInstanceByName(ctx context.Context, poolID string, } func (s *sqlDatabase) GetInstanceByName(ctx context.Context, instanceName string) (params.Instance, error) { - instance, err := s.getInstanceByName(ctx, instanceName) + instance, err := s.getInstanceByName(ctx, instanceName, "StatusMessages") if err != nil { return params.Instance{}, errors.Wrap(err, "fetching instance") } @@ -906,6 +926,22 @@ func (s *sqlDatabase) DeleteInstance(ctx context.Context, poolID string, instanc return nil } +func (s *sqlDatabase) AddInstanceStatusMessage(ctx context.Context, instanceID string, statusMessage string) error { + instance, err := s.getInstanceByID(ctx, instanceID) + if err != nil { + return errors.Wrap(err, "updating instance") + } + + msg := InstanceStatusUpdate{ + Message: statusMessage, + } + + if err := s.conn.Model(&instance).Association("StatusMessages").Append(&msg); err != nil { + return errors.Wrap(err, "adding status message") + } + return nil +} + func (s *sqlDatabase) UpdateInstance(ctx context.Context, instanceID string, param params.UpdateInstanceParams) (params.Instance, error) { instance, err := s.getInstanceByID(ctx, instanceID) if err != nil { diff --git a/params/params.go b/params/params.go index 7189448c..ae7c5a64 100644 --- a/params/params.go +++ b/params/params.go @@ -21,6 +21,11 @@ type Address struct { Type AddressType `json:"type"` } +type StatusMessage struct { + CreatedAt time.Time `json:"created_at"` + Message string `json:"message"` +} + type Instance struct { // ID is the database ID of this instance. ID string `json:"id"` @@ -50,6 +55,8 @@ type Instance struct { RunnerStatus common.RunnerStatus `json:"runner_status"` PoolID string `json:"pool_id"` + StatusMessages []StatusMessage `json:"status_messages,omitempty"` + // Do not serialize sensitive info. CallbackURL string `json:"-"` } @@ -157,4 +164,5 @@ type GithubCredentials struct { type Provider struct { Name string `json:"name"` ProviderType config.ProviderType `json:"type"` + Description string `json:"description"` } diff --git a/params/requests.go b/params/requests.go index 4bb277d5..3e6d081e 100644 --- a/params/requests.go +++ b/params/requests.go @@ -148,3 +148,8 @@ type UpdateRepositoryParams struct { CredentialsName string `json:"credentials_name"` WebhookSecret string `json:"webhook_secret"` } + +type InstanceUpdateMessage struct { + Status common.RunnerStatus `json:"status"` + Message string `json:"message"` +} diff --git a/runner/pool/repository.go b/runner/pool/repository.go index c172d13c..5bcc3a0f 100644 --- a/runner/pool/repository.go +++ b/runner/pool/repository.go @@ -382,7 +382,7 @@ func (r *Repository) updateArgsFromProviderInstance(providerInstance params.Inst return params.UpdateInstanceParams{ ProviderID: providerInstance.ProviderID, OSName: providerInstance.OSName, - OSVersion: providerInstance.OSName, + OSVersion: providerInstance.OSVersion, Addresses: providerInstance.Addresses, Status: providerInstance.Status, RunnerStatus: providerInstance.RunnerStatus, @@ -464,8 +464,6 @@ func (r *Repository) addInstanceToProvider(instance params.Instance) error { return nil } -// TODO: add function to set runner status to idle when instance calls home on callback url - func (r *Repository) AddRunner(ctx context.Context, poolID string) error { pool, err := r.store.GetRepositoryPool(r.ctx, r.id, poolID) if err != nil { @@ -484,11 +482,23 @@ func (r *Repository) AddRunner(ctx context.Context, poolID string) error { CallbackURL: r.cfg.Internal.InstanceCallbackURL, } - _, err = r.store.CreateInstance(r.ctx, poolID, createParams) + instance, err := r.store.CreateInstance(r.ctx, poolID, createParams) if err != nil { return errors.Wrap(err, "creating instance") } + updateParams := params.UpdateInstanceParams{ + OSName: instance.OSName, + OSVersion: instance.OSVersion, + Addresses: instance.Addresses, + Status: instance.Status, + ProviderID: instance.ProviderID, + } + + if _, err := r.store.UpdateInstance(r.ctx, instance.ID, updateParams); err != nil { + return errors.Wrap(err, "updating runner state") + } + return nil } @@ -656,20 +666,3 @@ func (r *Repository) HandleWorkflowJob(job params.WorkflowJob) error { } return nil } - -func (r *Repository) ListInstances() ([]params.Instance, error) { - return nil, nil -} - -func (r *Repository) GetInstance() (params.Instance, error) { - return params.Instance{}, nil -} -func (r *Repository) DeleteInstance() error { - return nil -} -func (r *Repository) StopInstance() error { - return nil -} -func (r *Repository) StartInstance() error { - return nil -} diff --git a/runner/providers/lxd/lxd.go b/runner/providers/lxd/lxd.go index 2c9f8fe9..9cbc1f66 100644 --- a/runner/providers/lxd/lxd.go +++ b/runner/providers/lxd/lxd.go @@ -235,6 +235,7 @@ func (l *LXD) AsParams() params.Provider { return params.Provider{ Name: l.cfg.Name, ProviderType: l.cfg.ProviderType, + Description: l.cfg.Description, } } diff --git a/runner/repositories.go b/runner/repositories.go index 6c5ff7d1..ad935406 100644 --- a/runner/repositories.go +++ b/runner/repositories.go @@ -283,8 +283,16 @@ func (r *Runner) ListRepoPools(ctx context.Context, repoID string) ([]params.Poo return pools, nil } -func (r *Runner) ListPoolInstances(ctx context.Context) error { - return nil +func (r *Runner) ListPoolInstances(ctx context.Context, poolID string) ([]params.Instance, error) { + if !auth.IsAdmin(ctx) { + return nil, runnerErrors.ErrUnauthorized + } + + instances, err := r.store.ListInstances(ctx, poolID) + if err != nil { + return []params.Instance{}, errors.Wrap(err, "fetching instances") + } + return instances, nil } func (r *Runner) loadRepoPoolManager(repo params.Repository) (common.PoolManager, error) { @@ -345,3 +353,53 @@ func (r *Runner) UpdateRepoPool(ctx context.Context, repoID, poolID string, para } return newPool, nil } + +func (r *Runner) ListRepoInstances(ctx context.Context, repoID string) ([]params.Instance, error) { + if !auth.IsAdmin(ctx) { + return nil, runnerErrors.ErrUnauthorized + } + + instances, err := r.store.ListRepoInstances(ctx, repoID) + if err != nil { + return []params.Instance{}, errors.Wrap(err, "fetching instances") + } + return instances, nil +} + +// TODO: move these in another file + +func (r *Runner) GetInstance(ctx context.Context, instanceName string) (params.Instance, error) { + if !auth.IsAdmin(ctx) { + return params.Instance{}, runnerErrors.ErrUnauthorized + } + + instance, err := r.store.GetInstanceByName(ctx, instanceName) + if err != nil { + return params.Instance{}, errors.Wrap(err, "fetching instance") + } + return instance, nil +} + +func (r *Runner) AddInstanceStatusMessage(ctx context.Context, param params.InstanceUpdateMessage) error { + instanceID := auth.InstanceID(ctx) + if instanceID == "" { + return runnerErrors.ErrUnauthorized + } + + if err := r.store.AddInstanceStatusMessage(ctx, instanceID, param.Message); err != nil { + return errors.Wrap(err, "adding status update") + } + + // if param.Status == providerCommon.RunnerIdle { + // } + + updateParams := params.UpdateInstanceParams{ + RunnerStatus: param.Status, + } + + if _, err := r.store.UpdateInstance(r.ctx, instanceID, updateParams); err != nil { + return errors.Wrap(err, "updating runner state") + } + + return nil +} diff --git a/util/util.go b/util/util.go index 49887fbf..b8881c86 100644 --- a/util/util.go +++ b/util/util.go @@ -205,7 +205,7 @@ func GetCloudConfig(bootstrapParams params.BootstrapInstance, tools github.Runne cloudCfg.AddSSHKey(bootstrapParams.SSHKeys...) cloudCfg.AddFile(installScript, "/install_runner.sh", "root:root", "755") cloudCfg.AddRunCmd("/install_runner.sh") - cloudCfg.AddRunCmd("rm -f /install_runner.sh") + // cloudCfg.AddRunCmd("rm -f /install_runner.sh") asStr, err := cloudCfg.Serialize() if err != nil {