From febb8c5276e2d94b7cbdace888102805c88d6c51 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Mon, 30 Sep 2024 12:33:16 +0100 Subject: [PATCH] Implement org/user agents (#3539) --- cmd/server/docs/docs.go | 195 ++++++++++++++- server/api/agent.go | 222 ++++++++++++++++-- server/grpc/auth_server.go | 13 +- server/grpc/rpc.go | 112 ++++++++- server/model/agent.go | 47 +++- server/model/agent_test.go | 90 +++++++ server/model/task.go | 12 + server/model/task_test.go | 87 +++++++ server/pipeline/queue.go | 16 +- server/queue/fifo.go | 6 +- server/queue/queue.go | 5 +- server/router/api.go | 13 +- server/store/datastore/agent.go | 7 +- server/store/datastore/agent_test.go | 50 +++- .../datastore/migration/015_add_org_agents.go | 49 ++++ server/store/datastore/migration/migration.go | 1 + server/store/mocks/store.go | 30 +++ server/store/store.go | 1 + web/src/assets/locales/en.json | 9 + .../admin/settings/AdminAgentsTab.vue | 205 +--------------- .../admin/settings/AdminQueueTab.vue | 7 +- web/src/components/agent/AgentForm.vue | 104 ++++++++ web/src/components/agent/AgentList.vue | 67 ++++++ web/src/components/agent/AgentManager.vue | 101 ++++++++ .../components/org/settings/OrgAgentsTab.vue | 28 +++ web/src/components/user/UserAgentsTab.vue | 28 +++ web/src/lib/api/index.ts | 21 +- web/src/lib/api/types/agent.ts | 2 + web/src/lib/api/types/queue.ts | 4 +- web/src/views/User.vue | 4 + web/src/views/org/OrgSettings.vue | 5 + woodpecker-go/woodpecker/types.go | 1 + 32 files changed, 1292 insertions(+), 250 deletions(-) create mode 100644 server/model/agent_test.go create mode 100644 server/model/task_test.go create mode 100644 server/store/datastore/migration/015_add_org_agents.go create mode 100644 web/src/components/agent/AgentForm.vue create mode 100644 web/src/components/agent/AgentList.vue create mode 100644 web/src/components/agent/AgentManager.vue create mode 100644 web/src/components/org/settings/OrgAgentsTab.vue create mode 100644 web/src/components/user/UserAgentsTab.vue diff --git a/cmd/server/docs/docs.go b/cmd/server/docs/docs.go index 16e6670c6..e91133386 100644 --- a/cmd/server/docs/docs.go +++ b/cmd/server/docs/docs.go @@ -101,7 +101,7 @@ const docTemplate = `{ } } }, - "/agents/{agent}": { + "/agents/{agent_id}": { "get": { "produces": [ "application/json" @@ -211,7 +211,7 @@ const docTemplate = `{ } } }, - "/agents/{agent}/tasks": { + "/agents/{agent_id}/tasks": { "get": { "produces": [ "application/json" @@ -1063,6 +1063,193 @@ const docTemplate = `{ } } }, + "/orgs/{org_id}/agents": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "List agents for an organization", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "integer", + "description": "the organization's id", + "name": "org_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 1, + "description": "for response pagination, page offset number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 50, + "description": "for response pagination, max items per page", + "name": "perPage", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Agent" + } + } + } + } + }, + "post": { + "description": "Creates a new agent with a random token, scoped to the specified organization", + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Create a new organization-scoped agent", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "integer", + "description": "the organization's id", + "name": "org_id", + "in": "path", + "required": true + }, + { + "description": "the agent's data (only 'name' and 'no_schedule' are read)", + "name": "agent", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Agent" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Agent" + } + } + } + } + }, + "/orgs/{org_id}/agents/{agent_id}": { + "delete": { + "produces": [ + "text/plain" + ], + "tags": [ + "Agents" + ], + "summary": "Delete an organization-scoped agent", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "integer", + "description": "the organization's id", + "name": "org_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "the agent's id", + "name": "agent_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "patch": { + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Update an organization-scoped agent", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "integer", + "description": "the organization's id", + "name": "org_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "the agent's id", + "name": "agent_id", + "in": "path", + "required": true + }, + { + "description": "the agent's updated data", + "name": "agent", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Agent" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Agent" + } + } + } + } + }, "/orgs/{org_id}/permissions": { "get": { "produces": [ @@ -4418,6 +4605,10 @@ const docTemplate = `{ "no_schedule": { "type": "boolean" }, + "org_id": { + "description": "OrgID is counted as unset if set to -1, this is done to ensure a new(Agent) still enforce the OrgID check by default", + "type": "integer" + }, "owner_id": { "type": "integer" }, diff --git a/server/api/agent.go b/server/api/agent.go index 7b2d5f8d6..bf473c1d4 100644 --- a/server/api/agent.go +++ b/server/api/agent.go @@ -15,12 +15,10 @@ package api import ( - "encoding/base32" "net/http" "strconv" "github.com/gin-gonic/gin" - "github.com/gorilla/securecookie" "go.woodpecker-ci.org/woodpecker/v2/server" "go.woodpecker-ci.org/woodpecker/v2/server/model" @@ -28,6 +26,10 @@ import ( "go.woodpecker-ci.org/woodpecker/v2/server/store" ) +// +// Global Agents. +// + // GetAgents // // @Summary List agents @@ -50,14 +52,14 @@ func GetAgents(c *gin.Context) { // GetAgent // // @Summary Get an agent -// @Router /agents/{agent} [get] +// @Router /agents/{agent_id} [get] // @Produce json // @Success 200 {object} Agent // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param agent path int true "the agent's id" func GetAgent(c *gin.Context) { - agentID, err := strconv.ParseInt(c.Param("agent"), 10, 64) + agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return @@ -74,14 +76,14 @@ func GetAgent(c *gin.Context) { // GetAgentTasks // // @Summary List agent tasks -// @Router /agents/{agent}/tasks [get] +// @Router /agents/{agent_id}/tasks [get] // @Produce json // @Success 200 {array} Task // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param agent path int true "the agent's id" func GetAgentTasks(c *gin.Context) { - agentID, err := strconv.ParseInt(c.Param("agent"), 10, 64) + agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return @@ -107,7 +109,7 @@ func GetAgentTasks(c *gin.Context) { // PatchAgent // // @Summary Update an agent -// @Router /agents/{agent} [patch] +// @Router /agents/{agent_id} [patch] // @Produce json // @Success 200 {object} Agent // @Tags Agents @@ -124,7 +126,7 @@ func PatchAgent(c *gin.Context) { return } - agentID, err := strconv.ParseInt(c.Param("agent"), 10, 64) + agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return @@ -135,6 +137,8 @@ func PatchAgent(c *gin.Context) { handleDBError(c, err) return } + + // Update allowed fields agent.Name = in.Name agent.NoSchedule = in.NoSchedule if agent.NoSchedule { @@ -172,11 +176,10 @@ func PostAgent(c *gin.Context) { agent := &model.Agent{ Name: in.Name, - NoSchedule: in.NoSchedule, OwnerID: user.ID, - Token: base32.StdEncoding.EncodeToString( - securecookie.GenerateRandomKey(32), - ), + OrgID: model.IDNotSet, + NoSchedule: in.NoSchedule, + Token: model.GenerateNewAgentToken(), } if err = store.FromContext(c).AgentCreate(agent); err != nil { c.String(http.StatusInternalServerError, err.Error()) @@ -188,7 +191,7 @@ func PostAgent(c *gin.Context) { // DeleteAgent // // @Summary Delete an agent -// @Router /agents/{agent} [delete] +// @Router /agents/{agent_id} [delete] // @Produce plain // @Success 200 // @Tags Agents @@ -197,7 +200,7 @@ func PostAgent(c *gin.Context) { func DeleteAgent(c *gin.Context) { _store := store.FromContext(c) - agentID, err := strconv.ParseInt(c.Param("agent"), 10, 64) + agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return @@ -227,3 +230,194 @@ func DeleteAgent(c *gin.Context) { } c.Status(http.StatusNoContent) } + +// +// Org/User Agents. +// + +// PostOrgAgent +// +// @Summary Create a new organization-scoped agent +// @Description Creates a new agent with a random token, scoped to the specified organization +// @Router /orgs/{org_id}/agents [post] +// @Produce json +// @Success 200 {object} Agent +// @Tags Agents +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param org_id path int true "the organization's id" +// @Param agent body Agent true "the agent's data (only 'name' and 'no_schedule' are read)" +func PostOrgAgent(c *gin.Context) { + _store := store.FromContext(c) + user := session.User(c) + + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Invalid organization ID") + return + } + + in := new(model.Agent) + err = c.Bind(in) + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + agent := &model.Agent{ + Name: in.Name, + OwnerID: user.ID, + OrgID: orgID, + NoSchedule: in.NoSchedule, + Token: model.GenerateNewAgentToken(), + } + + if err = _store.AgentCreate(agent); err != nil { + c.String(http.StatusInternalServerError, err.Error()) + return + } + + c.JSON(http.StatusOK, agent) +} + +// GetOrgAgents +// +// @Summary List agents for an organization +// @Router /orgs/{org_id}/agents [get] +// @Produce json +// @Success 200 {array} Agent +// @Tags Agents +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param org_id path int true "the organization's id" +// @Param page query int false "for response pagination, page offset number" default(1) +// @Param perPage query int false "for response pagination, max items per page" default(50) +func GetOrgAgents(c *gin.Context) { + _store := store.FromContext(c) + + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing org id. %s", err) + return + } + + agents, err := _store.AgentListForOrg(orgID, session.Pagination(c)) + if err != nil { + c.String(http.StatusInternalServerError, "Error getting agent list. %s", err) + return + } + + c.JSON(http.StatusOK, agents) +} + +// PatchOrgAgent +// +// @Summary Update an organization-scoped agent +// @Router /orgs/{org_id}/agents/{agent_id} [patch] +// @Produce json +// @Success 200 {object} Agent +// @Tags Agents +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param org_id path int true "the organization's id" +// @Param agent_id path int true "the agent's id" +// @Param agent body Agent true "the agent's updated data" +func PatchOrgAgent(c *gin.Context) { + _store := store.FromContext(c) + + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Invalid organization ID") + return + } + + agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Invalid agent ID") + return + } + + agent, err := _store.AgentFind(agentID) + if err != nil { + c.String(http.StatusNotFound, "Agent not found") + return + } + + if agent.OrgID != orgID { + c.String(http.StatusBadRequest, "Agent does not belong to this organization") + return + } + + in := new(model.Agent) + if err := c.Bind(in); err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + // Update allowed fields + agent.Name = in.Name + agent.NoSchedule = in.NoSchedule + if agent.NoSchedule { + server.Config.Services.Queue.KickAgentWorkers(agent.ID) + } + + if err := _store.AgentUpdate(agent); err != nil { + c.String(http.StatusInternalServerError, err.Error()) + return + } + + c.JSON(http.StatusOK, agent) +} + +// DeleteOrgAgent +// +// @Summary Delete an organization-scoped agent +// @Router /orgs/{org_id}/agents/{agent_id} [delete] +// @Produce plain +// @Success 204 +// @Tags Agents +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param org_id path int true "the organization's id" +// @Param agent_id path int true "the agent's id" +func DeleteOrgAgent(c *gin.Context) { + _store := store.FromContext(c) + + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Invalid organization ID") + return + } + + agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Invalid agent ID") + return + } + + agent, err := _store.AgentFind(agentID) + if err != nil { + c.String(http.StatusNotFound, "Agent not found") + return + } + + if agent.OrgID != orgID { + c.String(http.StatusBadRequest, "Agent does not belong to this organization") + return + } + + // Check if the agent has any running tasks + info := server.Config.Services.Queue.Info(c) + for _, task := range info.Running { + if task.AgentID == agent.ID { + c.String(http.StatusConflict, "Agent has running tasks") + return + } + } + + // Kick workers to remove the agent from the queue + server.Config.Services.Queue.KickAgentWorkers(agent.ID) + + if err := _store.AgentDelete(agent); err != nil { + c.String(http.StatusInternalServerError, "Error deleting agent. %s", err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/server/grpc/auth_server.go b/server/grpc/auth_server.go index f9652c85f..a12317f93 100644 --- a/server/grpc/auth_server.go +++ b/server/grpc/auth_server.go @@ -60,13 +60,12 @@ func (s *WoodpeckerAuthServer) getAgent(agentID int64, agentToken string) (*mode // global agent secret auth if s.agentMasterToken != "" { if agentToken == s.agentMasterToken && agentID == -1 { - agent := new(model.Agent) - agent.Name = "" - agent.OwnerID = -1 // system agent - agent.Token = s.agentMasterToken - agent.Backend = "" - agent.Platform = "" - agent.Capacity = -1 + agent := &model.Agent{ + OwnerID: model.IDNotSet, + OrgID: model.IDNotSet, + Token: s.agentMasterToken, + Capacity: -1, + } err := s.store.AgentCreate(agent) if err != nil { log.Error().Err(err).Msg("error creating system agent") diff --git a/server/grpc/rpc.go b/server/grpc/rpc.go index 52edf1f99..614c4eb7d 100644 --- a/server/grpc/rpc.go +++ b/server/grpc/rpc.go @@ -57,8 +57,6 @@ func (s *RPC) Next(c context.Context, agentFilter rpc.Filter) (*rpc.Workflow, er log.Debug().Msgf("agent connected: %s: polling", hostname) } - filterFn := createFilterFunc(agentFilter) - agent, err := s.getAgentFromContext(c) if err != nil { return nil, err @@ -69,6 +67,20 @@ func (s *RPC) Next(c context.Context, agentFilter rpc.Filter) (*rpc.Workflow, er return nil, nil } + agentServerLabels, err := agent.GetServerLabels() + if err != nil { + return nil, err + } + + // enforce labels from server by overwriting agent labels + for k, v := range agentServerLabels { + agentFilter.Labels[k] = v + } + + log.Trace().Msgf("Agent %s[%d] tries to pull task with labels: %v", agent.Name, agent.ID, agentFilter.Labels) + + filterFn := createFilterFunc(agentFilter) + for { // poll blocks until a task is available or the context is canceled / worker is kicked task, err := s.queue.Poll(c, agent.ID, filterFn) @@ -91,6 +103,15 @@ func (s *RPC) Next(c context.Context, agentFilter rpc.Filter) (*rpc.Workflow, er // Wait blocks until the workflow with the given ID is done. func (s *RPC) Wait(c context.Context, workflowID string) error { + agent, err := s.getAgentFromContext(c) + if err != nil { + return err + } + + if err := s.checkAgentPermissionByWorkflow(c, agent, workflowID, nil, nil); err != nil { + return err + } + return s.queue.Wait(c, workflowID) } @@ -106,11 +127,15 @@ func (s *RPC) Extend(c context.Context, workflowID string) error { return err } - return s.queue.Extend(c, workflowID) + if err := s.checkAgentPermissionByWorkflow(c, agent, workflowID, nil, nil); err != nil { + return err + } + + return s.queue.Extend(c, agent.ID, workflowID) } // Update updates the state of a step. -func (s *RPC) Update(_ context.Context, strWorkflowID string, state rpc.StepState) error { +func (s *RPC) Update(c context.Context, strWorkflowID string, state rpc.StepState) error { workflowID, err := strconv.ParseInt(strWorkflowID, 10, 64) if err != nil { return err @@ -128,6 +153,11 @@ func (s *RPC) Update(_ context.Context, strWorkflowID string, state rpc.StepStat return err } + agent, err := s.getAgentFromContext(c) + if err != nil { + return err + } + step, err := s.store.StepByUUID(state.StepUUID) if err != nil { log.Error().Err(err).Msgf("cannot find step with uuid %s", state.StepUUID) @@ -149,6 +179,11 @@ func (s *RPC) Update(_ context.Context, strWorkflowID string, state rpc.StepStat return err } + // check before agent can alter some state + if err := s.checkAgentPermissionByWorkflow(c, agent, strWorkflowID, currentPipeline, repo); err != nil { + return err + } + if err := pipeline.UpdateStepStatus(s.store, step, state); err != nil { log.Error().Err(err).Msg("rpc.update: cannot update step") } @@ -192,6 +227,7 @@ func (s *RPC) Init(c context.Context, strWorkflowID string, state rpc.WorkflowSt if err != nil { return err } + workflow.AgentID = agent.ID currentPipeline, err := s.store.GetPipeline(workflow.PipelineID) @@ -206,6 +242,11 @@ func (s *RPC) Init(c context.Context, strWorkflowID string, state rpc.WorkflowSt return err } + // check before agent can alter some state + if err := s.checkAgentPermissionByWorkflow(c, agent, strWorkflowID, currentPipeline, repo); err != nil { + return err + } + if currentPipeline.Status == model.StatusPending { if currentPipeline, err = pipeline.UpdateToStatusRunning(s.store, *currentPipeline, state.Started); err != nil { log.Error().Err(err).Msgf("init: cannot update pipeline %d state", currentPipeline.ID) @@ -272,6 +313,16 @@ func (s *RPC) Done(c context.Context, strWorkflowID string, state rpc.WorkflowSt return err } + agent, err := s.getAgentFromContext(c) + if err != nil { + return err + } + + // check before agent can alter some state + if err := s.checkAgentPermissionByWorkflow(c, agent, strWorkflowID, currentPipeline, repo); err != nil { + return err + } + logger := log.With(). Str("repo_id", fmt.Sprint(repo.ID)). Str("pipeline_id", fmt.Sprint(currentPipeline.ID)). @@ -328,10 +379,6 @@ func (s *RPC) Done(c context.Context, strWorkflowID string, state rpc.WorkflowSt s.pipelineTime.WithLabelValues(repo.FullName, currentPipeline.Branch, string(workflow.State), workflow.Name).Set(float64(workflow.Finished - workflow.Started)) } - agent, err := s.getAgentFromContext(c) - if err != nil { - return err - } return s.updateAgentLastWork(agent) } @@ -348,6 +395,17 @@ func (s *RPC) Log(c context.Context, stepUUID string, rpcLogEntries []*rpc.LogEn return err } + currentPipeline, err := s.store.GetPipeline(step.PipelineID) + if err != nil { + log.Error().Err(err).Msgf("cannot find pipeline with id %d", step.PipelineID) + return err + } + + // check before agent can alter some state + if err := s.checkAgentPermissionByWorkflow(c, agent, "", currentPipeline, nil); err != nil { + return err + } + err = s.updateAgentLastWork(agent) if err != nil { return err @@ -441,6 +499,44 @@ func (s *RPC) ReportHealth(ctx context.Context, status string) error { return s.store.AgentUpdate(agent) } +func (s *RPC) checkAgentPermissionByWorkflow(_ context.Context, agent *model.Agent, strWorkflowID string, pipeline *model.Pipeline, repo *model.Repo) error { + var err error + if repo == nil && pipeline == nil { + workflowID, err := strconv.ParseInt(strWorkflowID, 10, 64) + if err != nil { + return err + } + + workflow, err := s.store.WorkflowLoad(workflowID) + if err != nil { + log.Error().Err(err).Msgf("cannot find workflow with id %d", workflowID) + return err + } + + pipeline, err = s.store.GetPipeline(workflow.PipelineID) + if err != nil { + log.Error().Err(err).Msgf("cannot find pipeline with id %d", workflow.PipelineID) + return err + } + } + + if repo == nil { + repo, err = s.store.GetRepo(pipeline.RepoID) + if err != nil { + log.Error().Err(err).Msgf("cannot find repo with id %d", pipeline.RepoID) + return err + } + } + + if agent.CanAccessRepo(repo) { + return nil + } + + msg := fmt.Sprintf("agent '%d' is not allowed to interact with repo[%d] '%s'", agent.ID, repo.ID, repo.FullName) + log.Error().Int64("repoId", repo.ID).Msg(msg) + return errors.New(msg) +} + func (s *RPC) completeChildrenIfParentCompleted(completedWorkflow *model.Workflow) { for _, c := range completedWorkflow.Children { if c.Running() { diff --git a/server/model/agent.go b/server/model/agent.go index 31655cbb8..9a360cec4 100644 --- a/server/model/agent.go +++ b/server/model/agent.go @@ -14,6 +14,13 @@ package model +import ( + "encoding/base32" + "fmt" + + "github.com/gorilla/securecookie" +) + type Agent struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` Created int64 `json:"created" xorm:"created"` @@ -28,13 +35,51 @@ type Agent struct { Capacity int32 `json:"capacity" xorm:"capacity"` Version string `json:"version" xorm:"'version'"` NoSchedule bool `json:"no_schedule" xorm:"no_schedule"` + // OrgID is counted as unset if set to -1, this is done to ensure a new(Agent) still enforce the OrgID check by default + OrgID int64 `json:"org_id" xorm:"INDEX 'org_id'"` } // @name Agent +const ( + IDNotSet = -1 + agentFilterOrgID = "org-id" +) + // TableName return database table name for xorm. func (Agent) TableName() string { return "agents" } func (a *Agent) IsSystemAgent() bool { - return a.OwnerID == -1 + return a.OwnerID == IDNotSet +} + +func GenerateNewAgentToken() string { + return base32.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) +} + +func (a *Agent) GetServerLabels() (map[string]string, error) { + filters := make(map[string]string) + + // enforce filters for user and organization agents + if a.OrgID != IDNotSet { + filters[agentFilterOrgID] = fmt.Sprintf("%d", a.OrgID) + } else { + filters[agentFilterOrgID] = "*" + } + + return filters, nil +} + +func (a *Agent) CanAccessRepo(repo *Repo) bool { + // global agent + if a.OrgID == IDNotSet { + return true + } + + // agent has access to the organization + if a.OrgID == repo.OrgID { + return true + } + + return false } diff --git a/server/model/agent_test.go b/server/model/agent_test.go new file mode 100644 index 000000000..90356c456 --- /dev/null +++ b/server/model/agent_test.go @@ -0,0 +1,90 @@ +// Copyright 2024 Woodpecker Authors +// +// 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 model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateNewAgentToken(t *testing.T) { + token1 := GenerateNewAgentToken() + token2 := GenerateNewAgentToken() + + assert.NotEmpty(t, token1) + assert.NotEmpty(t, token2) + assert.NotEqual(t, token1, token2) + assert.Len(t, token1, 56) +} + +func TestAgent_GetServerLabels(t *testing.T) { + t.Run("EmptyAgent", func(t *testing.T) { + agent := &Agent{} + filters, err := agent.GetServerLabels() + assert.NoError(t, err) + assert.Equal(t, map[string]string{ + agentFilterOrgID: "0", + }, filters) + }) + + t.Run("GlobalAgent", func(t *testing.T) { + agent := &Agent{ + OrgID: IDNotSet, + } + filters, err := agent.GetServerLabels() + assert.NoError(t, err) + assert.Equal(t, map[string]string{ + agentFilterOrgID: "*", + }, filters) + }) + + t.Run("OrgAgent", func(t *testing.T) { + agent := &Agent{ + OrgID: 123, + } + filters, err := agent.GetServerLabels() + assert.NoError(t, err) + assert.Equal(t, map[string]string{ + agentFilterOrgID: "123", + }, filters) + }) +} + +func TestAgent_CanAccessRepo(t *testing.T) { + repo := &Repo{ID: 123, OrgID: 12} + otherRepo := &Repo{ID: 456, OrgID: 45} + + t.Run("EmptyAgent", func(t *testing.T) { + agent := &Agent{} + assert.False(t, agent.CanAccessRepo(repo)) + }) + + t.Run("GlobalAgent", func(t *testing.T) { + agent := &Agent{ + OrgID: IDNotSet, + } + + assert.True(t, agent.CanAccessRepo(repo)) + }) + + t.Run("OrgAgent", func(t *testing.T) { + agent := &Agent{ + OrgID: 12, + } + assert.True(t, agent.CanAccessRepo(repo)) + assert.False(t, agent.CanAccessRepo(otherRepo)) + }) +} diff --git a/server/model/task.go b/server/model/task.go index 3f73bebed..58cf69c28 100644 --- a/server/model/task.go +++ b/server/model/task.go @@ -41,6 +41,18 @@ func (t *Task) String() string { return sb.String() } +func (t *Task) ApplyLabelsFromRepo(r *Repo) error { + if r == nil { + return fmt.Errorf("repo is nil but needed to get task labels") + } + if t.Labels == nil { + t.Labels = make(map[string]string) + } + t.Labels["repo"] = r.FullName + t.Labels[agentFilterOrgID] = fmt.Sprintf("%d", r.OrgID) + return nil +} + // ShouldRun tells if a task should be run or skipped, based on dependencies. func (t *Task) ShouldRun() bool { if t.runsOnFailure() && t.runsOnSuccess() { diff --git a/server/model/task_test.go b/server/model/task_test.go new file mode 100644 index 000000000..c774934e4 --- /dev/null +++ b/server/model/task_test.go @@ -0,0 +1,87 @@ +// Copyright 2024 Woodpecker Authors +// +// 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 model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTask_GetLabels(t *testing.T) { + t.Run("Nil Repo", func(t *testing.T) { + task := &Task{} + err := task.ApplyLabelsFromRepo(nil) + + assert.Error(t, err) + assert.Nil(t, task.Labels) + assert.EqualError(t, err, "repo is nil but needed to get task labels") + }) + + t.Run("Empty Repo", func(t *testing.T) { + task := &Task{} + repo := &Repo{} + + err := task.ApplyLabelsFromRepo(repo) + + assert.NoError(t, err) + assert.NotNil(t, task.Labels) + assert.Equal(t, map[string]string{ + "repo": "", + agentFilterOrgID: "0", + }, task.Labels) + }) + + t.Run("Empty Labels", func(t *testing.T) { + task := &Task{} + repo := &Repo{ + FullName: "test/repo", + ID: 123, + OrgID: 456, + } + + err := task.ApplyLabelsFromRepo(repo) + + assert.NoError(t, err) + assert.NotNil(t, task.Labels) + assert.Equal(t, map[string]string{ + "repo": "test/repo", + agentFilterOrgID: "456", + }, task.Labels) + }) + + t.Run("Existing Labels", func(t *testing.T) { + task := &Task{ + Labels: map[string]string{ + "existing": "label", + }, + } + repo := &Repo{ + FullName: "test/repo", + ID: 123, + OrgID: 456, + } + + err := task.ApplyLabelsFromRepo(repo) + + assert.NoError(t, err) + assert.NotNil(t, task.Labels) + assert.Equal(t, map[string]string{ + "existing": "label", + "repo": "test/repo", + agentFilterOrgID: "456", + }, task.Labels) + }) +} diff --git a/server/pipeline/queue.go b/server/pipeline/queue.go index 73bf30fd8..a17669a1f 100644 --- a/server/pipeline/queue.go +++ b/server/pipeline/queue.go @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "go.woodpecker-ci.org/woodpecker/v2/pipeline/rpc" "go.woodpecker-ci.org/woodpecker/v2/server" @@ -31,18 +32,19 @@ func queuePipeline(ctx context.Context, repo *model.Repo, pipelineItems []*stepb if item.Workflow.State == model.StatusSkipped { continue } - task := new(model.Task) - task.ID = fmt.Sprint(item.Workflow.ID) - task.Labels = map[string]string{} - for k, v := range item.Labels { - task.Labels[k] = v + task := &model.Task{ + ID: fmt.Sprint(item.Workflow.ID), + Labels: make(map[string]string), + } + maps.Copy(task.Labels, item.Labels) + err := task.ApplyLabelsFromRepo(repo) + if err != nil { + return err } - task.Labels["repo"] = repo.FullName task.Dependencies = taskIDs(item.DependsOn, pipelineItems) task.RunOn = item.RunsOn task.DepStatus = make(map[string]model.StatusValue) - var err error task.Data, err = json.Marshal(rpc.Workflow{ ID: fmt.Sprint(item.Workflow.ID), Config: item.Config, diff --git a/server/queue/fifo.go b/server/queue/fifo.go index 5e5a17edd..8cf6f4773 100644 --- a/server/queue/fifo.go +++ b/server/queue/fifo.go @@ -191,12 +191,16 @@ func (q *fifo) Wait(c context.Context, id string) error { } // Extend extends the task execution deadline. -func (q *fifo) Extend(_ context.Context, id string) error { +func (q *fifo) Extend(_ context.Context, agentID int64, id string) error { q.Lock() defer q.Unlock() state, ok := q.running[id] if ok { + if state.item.AgentID != agentID { + return ErrAgentMissMatch + } + state.deadline = time.Now().Add(q.extension) return nil } diff --git a/server/queue/queue.go b/server/queue/queue.go index 9cb695c24..eaaeb47cf 100644 --- a/server/queue/queue.go +++ b/server/queue/queue.go @@ -28,6 +28,9 @@ var ( // ErrNotFound indicates the task was not found in the queue. ErrNotFound = errors.New("queue: task not found") + + // ErrAgentMissMatch indicates a task is assigned to a different agent. + ErrAgentMissMatch = errors.New("task assigned to different agent") ) // InfoT provides runtime information. @@ -79,7 +82,7 @@ type Queue interface { Poll(c context.Context, agentID int64, f FilterFn) (*model.Task, error) // Extend extends the deadline for a task. - Extend(c context.Context, id string) error + Extend(c context.Context, agentID int64, workflowID string) error // Done signals the task is complete. Done(c context.Context, id string, exitStatus model.StatusValue) error diff --git a/server/router/api.go b/server/router/api.go index 4ee7a9554..8d3ca2bcb 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -71,6 +71,11 @@ func apiRoutes(e *gin.RouterGroup) { org.GET("/registries/:registry", api.GetOrgRegistry) org.PATCH("/registries/:registry", api.PatchOrgRegistry) org.DELETE("/registries/:registry", api.DeleteOrgRegistry) + + org.GET("/agents", api.GetOrgAgents) + org.POST("/agents", api.PostOrgAgent) + org.PATCH("/agents/:agent_id", api.PatchOrgAgent) + org.DELETE("/agents/:agent_id", api.DeleteOrgAgent) } } } @@ -217,10 +222,10 @@ func apiRoutes(e *gin.RouterGroup) { agentBase.Use(session.MustAdmin()) agentBase.GET("", api.GetAgents) agentBase.POST("", api.PostAgent) - agentBase.GET("/:agent", api.GetAgent) - agentBase.GET("/:agent/tasks", api.GetAgentTasks) - agentBase.PATCH("/:agent", api.PatchAgent) - agentBase.DELETE("/:agent", api.DeleteAgent) + agentBase.GET("/:agent_id", api.GetAgent) + agentBase.GET("/:agent_id/tasks", api.GetAgentTasks) + agentBase.PATCH("/:agent_id", api.PatchAgent) + agentBase.DELETE("/:agent_id", api.DeleteAgent) } apiBase.GET("/forges", api.GetForges) diff --git a/server/store/datastore/agent.go b/server/store/datastore/agent.go index 383692289..18e035e76 100644 --- a/server/store/datastore/agent.go +++ b/server/store/datastore/agent.go @@ -22,8 +22,7 @@ import ( var ErrNoTokenProvided = errors.New("please provide a token") -func (s storage) AgentList(p *model.ListOptions) ([]*model.Agent, error) { - var agents []*model.Agent +func (s storage) AgentList(p *model.ListOptions) (agents []*model.Agent, _ error) { return agents, s.paginate(p).OrderBy("id").Find(&agents) } @@ -55,3 +54,7 @@ func (s storage) AgentUpdate(agent *model.Agent) error { func (s storage) AgentDelete(agent *model.Agent) error { return wrapDelete(s.engine.ID(agent.ID).Delete(new(model.Agent))) } + +func (s storage) AgentListForOrg(orgID int64, p *model.ListOptions) (agents []*model.Agent, _ error) { + return agents, s.paginate(p).Where("org_id = ?", orgID).OrderBy("id").Find(&agents) +} diff --git a/server/store/datastore/agent_test.go b/server/store/datastore/agent_test.go index f08a5ee74..c94d46719 100644 --- a/server/store/datastore/agent_test.go +++ b/server/store/datastore/agent_test.go @@ -65,14 +65,12 @@ func TestAgentList(t *testing.T) { defer closer() agent1 := &model.Agent{ - ID: int64(1), - Name: "test-1", - Token: "secret-token-1", + ID: int64(1), + Name: "test-1", } agent2 := &model.Agent{ - ID: int64(2), - Name: "test-2", - Token: "secret-token-2", + ID: int64(2), + Name: "test-2", } err := store.AgentCreate(agent1) assert.NoError(t, err) @@ -106,3 +104,43 @@ func TestAgentUpdate(t *testing.T) { err = store.AgentUpdate(agent) assert.NoError(t, err) } + +func TestAgentListForOrg(t *testing.T) { + store, closer := newTestStore(t, new(model.Agent)) + defer closer() + + agent1 := &model.Agent{ + ID: int64(1), + Name: "test-1", + OrgID: int64(100), + } + agent2 := &model.Agent{ + ID: int64(2), + Name: "test-2", + OrgID: int64(100), + } + agent3 := &model.Agent{ + ID: int64(3), + Name: "test-3", + OrgID: int64(200), + } + assert.NoError(t, store.AgentCreate(agent1)) + assert.NoError(t, store.AgentCreate(agent2)) + assert.NoError(t, store.AgentCreate(agent3)) + + agents, err := store.AgentListForOrg(100, &model.ListOptions{All: true}) + assert.NoError(t, err) + assert.Equal(t, 2, len(agents)) + assert.Equal(t, "test-1", agents[0].Name) + assert.Equal(t, "test-2", agents[1].Name) + + agents, err = store.AgentListForOrg(200, &model.ListOptions{All: true}) + assert.NoError(t, err) + assert.Equal(t, 1, len(agents)) + assert.Equal(t, "test-3", agents[0].Name) + + agents, err = store.AgentListForOrg(100, &model.ListOptions{Page: 1, PerPage: 1}) + assert.NoError(t, err) + assert.Equal(t, 1, len(agents)) + assert.Equal(t, "test-1", agents[0].Name) +} diff --git a/server/store/datastore/migration/015_add_org_agents.go b/server/store/datastore/migration/015_add_org_agents.go new file mode 100644 index 000000000..6eb68f55d --- /dev/null +++ b/server/store/datastore/migration/015_add_org_agents.go @@ -0,0 +1,49 @@ +// Copyright 2024 Woodpecker Authors +// +// 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 migration + +import ( + "fmt" + + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" + + "go.woodpecker-ci.org/woodpecker/v2/server/model" +) + +type agentV015 struct { + ID int64 `xorm:"pk autoincr 'id'"` + OwnerID int64 `xorm:"INDEX 'owner_id'"` + OrgID int64 `xorm:"INDEX 'org_id'"` +} + +func (agentV015) TableName() string { + return "agents" +} + +var addOrgAgents = xormigrate.Migration{ + ID: "add-org-agents", + MigrateSession: func(sess *xorm.Session) (err error) { + if err := sess.Sync(new(agentV015)); err != nil { + return fmt.Errorf("sync models failed: %w", err) + } + + // Update all existing agents to be global agents + _, err = sess.Cols("org_id").Update(&model.Agent{ + OrgID: model.IDNotSet, + }) + return err + }, +} diff --git a/server/store/datastore/migration/migration.go b/server/store/datastore/migration/migration.go index d82a3a336..9a8855617 100644 --- a/server/store/datastore/migration/migration.go +++ b/server/store/datastore/migration/migration.go @@ -43,6 +43,7 @@ var migrationTasks = []*xormigrate.Migration{ &renameStartEndTime, &fixV31Registries, &removeOldMigrationsOfV1, + &addOrgAgents, } var allBeans = []any{ diff --git a/server/store/mocks/store.go b/server/store/mocks/store.go index df88e436d..3b778395c 100644 --- a/server/store/mocks/store.go +++ b/server/store/mocks/store.go @@ -143,6 +143,36 @@ func (_m *Store) AgentList(p *model.ListOptions) ([]*model.Agent, error) { return r0, r1 } +// AgentListForOrg provides a mock function with given fields: orgID, opt +func (_m *Store) AgentListForOrg(orgID int64, opt *model.ListOptions) ([]*model.Agent, error) { + ret := _m.Called(orgID, opt) + + if len(ret) == 0 { + panic("no return value specified for AgentListForOrg") + } + + var r0 []*model.Agent + var r1 error + if rf, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Agent, error)); ok { + return rf(orgID, opt) + } + if rf, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Agent); ok { + r0 = rf(orgID, opt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Agent) + } + } + + if rf, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok { + r1 = rf(orgID, opt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // AgentUpdate provides a mock function with given fields: _a0 func (_m *Store) AgentUpdate(_a0 *model.Agent) error { ret := _m.Called(_a0) diff --git a/server/store/store.go b/server/store/store.go index f1e958994..a3503e386 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -180,6 +180,7 @@ type Store interface { AgentList(p *model.ListOptions) ([]*model.Agent, error) AgentUpdate(*model.Agent) error AgentDelete(*model.Agent) error + AgentListForOrg(orgID int64, opt *model.ListOptions) ([]*model.Agent, error) // Workflow WorkflowGetTree(*model.Pipeline) ([]*model.Workflow, error) diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index c55145f97..26c04e5af 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -266,6 +266,9 @@ }, "registries": { "desc": "Organization registry credentials can be added to use private images for all pipelines of an organization." + }, + "agents": { + "desc": "Agents registered for this organization." } } }, @@ -313,6 +316,9 @@ "desc": "The max amount of parallel pipelines executed by this agent.", "badge": "capacity" }, + "org": { + "badge": "org" + }, "version": "Version", "last_contact": "Last contact", "never": "Never", @@ -415,6 +421,9 @@ "download_cli": "Download CLI", "reset_token": "Reset token", "swagger_ui": "Swagger UI" + }, + "agents": { + "desc": "Agents registered to your account repos." } } }, diff --git a/web/src/components/admin/settings/AdminAgentsTab.vue b/web/src/components/admin/settings/AdminAgentsTab.vue index 87e7954ec..53cd48f17 100644 --- a/web/src/components/admin/settings/AdminAgentsTab.vue +++ b/web/src/components/admin/settings/AdminAgentsTab.vue @@ -1,202 +1,23 @@ diff --git a/web/src/components/admin/settings/AdminQueueTab.vue b/web/src/components/admin/settings/AdminQueueTab.vue index 5a08559cd..b11f8d509 100644 --- a/web/src/components/admin/settings/AdminQueueTab.vue +++ b/web/src/components/admin/settings/AdminQueueTab.vue @@ -110,7 +110,12 @@ const tasks = computed(() => { _tasks.push(...queueInfo.value.waiting_on_deps.map((task) => ({ ...task, status: 'waiting_on_deps' }))); } - return _tasks.sort((a, b) => a.id - b.id); + return _tasks + .map((task) => ({ + ...task, + labels: Object.fromEntries(Object.entries(task.labels).filter(([key]) => key !== 'org-id')), + })) + .toSorted((a, b) => a.id - b.id); }); async function loadQueueInfo() { diff --git a/web/src/components/agent/AgentForm.vue b/web/src/components/agent/AgentForm.vue new file mode 100644 index 000000000..b64a0c43a --- /dev/null +++ b/web/src/components/agent/AgentForm.vue @@ -0,0 +1,104 @@ + + + diff --git a/web/src/components/agent/AgentList.vue b/web/src/components/agent/AgentList.vue new file mode 100644 index 000000000..230e00e7f --- /dev/null +++ b/web/src/components/agent/AgentList.vue @@ -0,0 +1,67 @@ + + + diff --git a/web/src/components/agent/AgentManager.vue b/web/src/components/agent/AgentManager.vue new file mode 100644 index 000000000..db7fa423f --- /dev/null +++ b/web/src/components/agent/AgentManager.vue @@ -0,0 +1,101 @@ + + + diff --git a/web/src/components/org/settings/OrgAgentsTab.vue b/web/src/components/org/settings/OrgAgentsTab.vue new file mode 100644 index 000000000..6eaec1c0b --- /dev/null +++ b/web/src/components/org/settings/OrgAgentsTab.vue @@ -0,0 +1,28 @@ + + + diff --git a/web/src/components/user/UserAgentsTab.vue b/web/src/components/user/UserAgentsTab.vue new file mode 100644 index 000000000..43ca05999 --- /dev/null +++ b/web/src/components/user/UserAgentsTab.vue @@ -0,0 +1,28 @@ + + + diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 83025f75a..a70741901 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -318,14 +318,31 @@ export default class WoodpeckerClient extends ApiClient { return this._post('/api/agents', agent) as Promise; } - async updateAgent(agent: Partial): Promise { - return this._patch(`/api/agents/${agent.id}`, agent); + async updateAgent(agent: Partial): Promise { + return this._patch(`/api/agents/${agent.id}`, agent) as Promise; } async deleteAgent(agent: Agent): Promise { return this._delete(`/api/agents/${agent.id}`); } + async getOrgAgents(orgId: number, opts?: PaginationOptions): Promise { + const query = encodeQueryString(opts); + return this._get(`/api/orgs/${orgId}/agents?${query}`) as Promise; + } + + async createOrgAgent(orgId: number, agent: Partial): Promise { + return this._post(`/api/orgs/${orgId}/agents`, agent) as Promise; + } + + async updateOrgAgent(orgId: number, agentId: number, agent: Partial): Promise { + return this._patch(`/api/orgs/${orgId}/agents/${agentId}`, agent) as Promise; + } + + async deleteOrgAgent(orgId: number, agentId: number): Promise { + return this._delete(`/api/orgs/${orgId}/agents/${agentId}`); + } + async getForges(opts?: PaginationOptions): Promise { const query = encodeQueryString(opts); return this._get(`/api/forges?${query}`) as Promise; diff --git a/web/src/lib/api/types/agent.ts b/web/src/lib/api/types/agent.ts index ee5632e11..62868a609 100644 --- a/web/src/lib/api/types/agent.ts +++ b/web/src/lib/api/types/agent.ts @@ -1,6 +1,8 @@ export interface Agent { id: number; name: string; + owner_id: number; + org_id: number; token: string; created: number; updated: number; diff --git a/web/src/lib/api/types/queue.ts b/web/src/lib/api/types/queue.ts index a6ff4edc7..221bbc0db 100644 --- a/web/src/lib/api/types/queue.ts +++ b/web/src/lib/api/types/queue.ts @@ -1,8 +1,8 @@ export interface Task { id: number; - labels: { [key: string]: string }; + labels: Record; dependencies: string[]; - dep_status: { [key: string]: string }; + dep_status: Record; run_on: string[]; agent_id: number; } diff --git a/web/src/views/User.vue b/web/src/views/User.vue index 1c58d87e5..b19639618 100644 --- a/web/src/views/User.vue +++ b/web/src/views/User.vue @@ -14,6 +14,9 @@ + + + @@ -21,6 +24,7 @@ import Button from '~/components/atomic/Button.vue'; import Scaffold from '~/components/layout/scaffold/Scaffold.vue'; import Tab from '~/components/layout/scaffold/Tab.vue'; +import UserAgentsTab from '~/components/user/UserAgentsTab.vue'; import UserCLIAndAPITab from '~/components/user/UserCLIAndAPITab.vue'; import UserGeneralTab from '~/components/user/UserGeneralTab.vue'; import UserRegistriesTab from '~/components/user/UserRegistriesTab.vue'; diff --git a/web/src/views/org/OrgSettings.vue b/web/src/views/org/OrgSettings.vue index 0b20795f7..25d2e92e0 100644 --- a/web/src/views/org/OrgSettings.vue +++ b/web/src/views/org/OrgSettings.vue @@ -18,6 +18,10 @@ + + + + @@ -28,6 +32,7 @@ import { useRouter } from 'vue-router'; import Scaffold from '~/components/layout/scaffold/Scaffold.vue'; import Tab from '~/components/layout/scaffold/Tab.vue'; +import OrgAgentsTab from '~/components/org/settings/OrgAgentsTab.vue'; import OrgRegistriesTab from '~/components/org/settings/OrgRegistriesTab.vue'; import OrgSecretsTab from '~/components/org/settings/OrgSecretsTab.vue'; import { inject } from '~/compositions/useInjectProvide'; diff --git a/woodpecker-go/woodpecker/types.go b/woodpecker-go/woodpecker/types.go index 158f5e8a1..006424b61 100644 --- a/woodpecker-go/woodpecker/types.go +++ b/woodpecker-go/woodpecker/types.go @@ -233,6 +233,7 @@ type ( Updated int64 `json:"updated"` Name string `json:"name"` OwnerID int64 `json:"owner_id"` + OrgID int64 `json:"org_id"` Token string `json:"token"` LastContact int64 `json:"last_contact"` LastWork int64 `json:"last_work"`