diff --git a/apiserver/controllers/controllers.go b/apiserver/controllers/controllers.go index 51d497c2..a7257340 100644 --- a/apiserver/controllers/controllers.go +++ b/apiserver/controllers/controllers.go @@ -306,3 +306,17 @@ func (a *APIController) ListProviders(w http.ResponseWriter, r *http.Request) { log.Printf("failed to encode response: %q", err) } } + +func (a *APIController) ListAllJobs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + jobs, err := a.r.ListAllJobs(ctx) + if err != nil { + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(jobs); err != nil { + log.Printf("failed to encode response: %q", err) + } +} diff --git a/apiserver/routers/routers.go b/apiserver/routers/routers.go index 7e707ae6..217d7f38 100644 --- a/apiserver/routers/routers.go +++ b/apiserver/routers/routers.go @@ -94,6 +94,13 @@ func NewAPIRouter(han *controllers.APIController, logWriter io.Writer, authMiddl apiRouter.Handle("/metrics-token/", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS") apiRouter.Handle("/metrics-token", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS") + ////////// + // Jobs // + ////////// + // List all jobs + apiRouter.Handle("/jobs/", http.HandlerFunc(han.ListAllJobs)).Methods("GET", "OPTIONS") + apiRouter.Handle("/jobs", http.HandlerFunc(han.ListAllJobs)).Methods("GET", "OPTIONS") + /////////// // Pools // /////////// diff --git a/cmd/garm-cli/client/client.go b/cmd/garm-cli/client/client.go index 7058e277..d9e07c3c 100644 --- a/cmd/garm-cli/client/client.go +++ b/cmd/garm-cli/client/client.go @@ -183,6 +183,19 @@ func (c *Client) DeleteRunner(instanceName string) error { return nil } +func (c *Client) ListAllJobs() ([]params.Job, error) { + url := fmt.Sprintf("%s/api/v1/jobs", c.Config.BaseURL) + + var response []params.Job + resp, err := c.client.R(). + SetResult(&response). + Get(url) + if err != nil || resp.IsError() { + return response, c.handleError(err, resp) + } + return response, nil +} + func (c *Client) ListPoolInstances(poolID string) ([]params.Instance, error) { url := fmt.Sprintf("%s/api/v1/pools/%s/instances", c.Config.BaseURL, poolID) diff --git a/cmd/garm-cli/cmd/jobs.go b/cmd/garm-cli/cmd/jobs.go new file mode 100644 index 00000000..949dda6f --- /dev/null +++ b/cmd/garm-cli/cmd/jobs.go @@ -0,0 +1,77 @@ +// Copyright 2023 Cloudbase Solutions SRL +// +// 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 ( + "fmt" + + "github.com/cloudbase/garm/params" + "github.com/google/uuid" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" +) + +// runnerCmd represents the runner command +var jobsCmd = &cobra.Command{ + Use: "job", + SilenceUsage: true, + Short: "Information about jobs", + Long: `Query information about jobs.`, + Run: nil, +} + +var jobsListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List jobs", + Long: `List all jobs currently recorded in the system.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return errNeedsInitError + } + + jobs, err := cli.ListAllJobs() + if err != nil { + return err + } + formatJobs(jobs) + return nil + }, +} + +func formatJobs(jobs []params.Job) { + t := table.NewWriter() + header := table.Row{"ID", "Name", "Status", "Conclusion", "Runner Name", "Locked by"} + t.AppendHeader(header) + + for _, job := range jobs { + lockedBy := "" + if job.LockedBy != uuid.Nil { + lockedBy = job.LockedBy.String() + } + t.AppendRow(table.Row{job.ID, job.Name, job.Status, job.Conclusion, job.RunnerName, lockedBy}) + t.AppendSeparator() + } + fmt.Println(t.Render()) +} + +func init() { + jobsCmd.AddCommand( + jobsListCmd, + ) + + rootCmd.AddCommand(jobsCmd) +} diff --git a/database/common/common.go b/database/common/common.go index 477e6ccd..2e92035a 100644 --- a/database/common/common.go +++ b/database/common/common.go @@ -116,6 +116,7 @@ type JobsStore interface { CreateOrUpdateJob(ctx context.Context, job params.Job) (params.Job, error) ListEntityJobsByStatus(ctx context.Context, entityType params.PoolType, entityID string, status params.JobStatus) ([]params.Job, error) ListJobsByStatus(ctx context.Context, status params.JobStatus) ([]params.Job, error) + ListAllJobs(ctx context.Context) ([]params.Job, error) GetJobByID(ctx context.Context, jobID int64) (params.Job, error) DeleteJob(ctx context.Context, jobID int64) error diff --git a/database/sql/jobs.go b/database/sql/jobs.go index 0690498d..2ee22a96 100644 --- a/database/sql/jobs.go +++ b/database/sql/jobs.go @@ -261,6 +261,28 @@ func (s *sqlDatabase) ListEntityJobsByStatus(ctx context.Context, entityType par return ret, nil } +func (s *sqlDatabase) ListAllJobs(ctx context.Context) ([]params.Job, error) { + var jobs []WorkflowJob + query := s.conn.Model(&WorkflowJob{}) + + if err := query.Find(&jobs); err.Error != nil { + if errors.Is(err.Error, gorm.ErrRecordNotFound) { + return []params.Job{}, nil + } + return nil, err.Error + } + + ret := make([]params.Job, len(jobs)) + for idx, job := range jobs { + jobParam, err := sqlWorkflowJobToParamsJob(job) + if err != nil { + return nil, errors.Wrap(err, "converting job") + } + ret[idx] = jobParam + } + return ret, nil +} + // GetJobByID gets a job by id. func (s *sqlDatabase) GetJobByID(ctx context.Context, jobID int64) (params.Job, error) { var job WorkflowJob diff --git a/params/params.go b/params/params.go index ab1e6301..4f94c22d 100644 --- a/params/params.go +++ b/params/params.go @@ -429,9 +429,9 @@ type Job struct { // ID is the ID of the job. ID int64 `json:"id"` // RunID is the ID of the workflow run. A run may have multiple jobs. - RunID int64 + RunID int64 `json:"run_id"` // Action is the specific activity that triggered the event. - Action string `json:"run_id"` + Action string `json:"action"` // Conclusion is the outcome of the job. // Possible values: "success", "failure", "neutral", "cancelled", "skipped", // "timed_out", "action_required" diff --git a/runner/pools.go b/runner/pools.go index 60956010..58dce91b 100644 --- a/runner/pools.go +++ b/runner/pools.go @@ -125,3 +125,15 @@ func (r *Runner) UpdatePoolByID(ctx context.Context, poolID string, param params } return newPool, nil } + +func (r *Runner) ListAllJobs(ctx context.Context) ([]params.Job, error) { + if !auth.IsAdmin(ctx) { + return []params.Job{}, runnerErrors.ErrUnauthorized + } + + jobs, err := r.store.ListAllJobs(ctx) + if err != nil { + return nil, errors.Wrap(err, "fetching jobs") + } + return jobs, nil +}