diff --git a/apiserver/controllers/proxies.go b/apiserver/controllers/proxies.go new file mode 100644 index 000000000..5c7746ff3 --- /dev/null +++ b/apiserver/controllers/proxies.go @@ -0,0 +1,201 @@ +// Copyright 2026 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 controllers + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/gorilla/mux" + + gErrors "github.com/cloudbase/garm-provider-common/errors" + runnerParams "github.com/cloudbase/garm/params" +) + +// swagger:route GET /proxies proxies ListProxies +// +// List proxies. +// +// Responses: +// 200: Proxies +// default: APIErrorResponse +func (a *APIController) ListProxiesHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + proxies, err := a.r.ListProxies(ctx) + if err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "listing proxies") + handleError(ctx, w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(proxies); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response") + } +} + +// swagger:route GET /proxies/{proxyID} proxies GetProxy +// +// Get proxy by ID. +// +// Parameters: +// + name: proxyID +// description: ID of the proxy to fetch. +// type: number +// in: path +// required: true +// +// Responses: +// 200: Proxy +// default: APIErrorResponse +func (a *APIController) GetProxyHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + proxyID, err := getValueFromVarsAsUint(vars, "proxyID") + if err != nil { + handleError(ctx, w, err) + return + } + proxy, err := a.r.GetProxy(ctx, proxyID) + if err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "fetching proxy") + handleError(ctx, w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(proxy); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response") + } +} + +// swagger:route POST /proxies proxies CreateProxy +// +// Create proxy with the parameters given. +// +// Parameters: +// + name: Body +// description: Parameters used when creating the proxy. +// type: CreateProxyParams +// in: body +// required: true +// +// Responses: +// 200: Proxy +// default: APIErrorResponse +func (a *APIController) CreateProxyHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var proxyData runnerParams.CreateProxyParams + if err := json.NewDecoder(r.Body).Decode(&proxyData); err != nil { + handleError(ctx, w, gErrors.ErrBadRequest) + return + } + + proxy, err := a.r.CreateProxy(ctx, proxyData) + if err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "error creating proxy") + handleError(ctx, w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(proxy); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response") + } +} + +// swagger:route PUT /proxies/{proxyID} proxies UpdateProxy +// +// Update proxy with the parameters given. +// +// Parameters: +// + name: proxyID +// description: ID of the proxy to update. +// type: number +// in: path +// required: true +// +// + name: Body +// description: Parameters used when updating the proxy. +// type: UpdateProxyParams +// in: body +// required: true +// +// Responses: +// 200: Proxy +// default: APIErrorResponse +func (a *APIController) UpdateProxyHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + proxyID, err := getValueFromVarsAsUint(vars, "proxyID") + if err != nil { + handleError(ctx, w, err) + return + } + + var updatePayload runnerParams.UpdateProxyParams + if err := json.NewDecoder(r.Body).Decode(&updatePayload); err != nil { + handleError(ctx, w, gErrors.ErrBadRequest) + return + } + + proxy, err := a.r.UpdateProxy(ctx, proxyID, updatePayload) + if err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "error updating proxy") + handleError(ctx, w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(proxy); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response") + } +} + +// swagger:route DELETE /proxies/{proxyID} proxies DeleteProxy +// +// Delete proxy by ID. +// +// Parameters: +// + name: proxyID +// description: ID of the proxy to delete. +// type: number +// in: path +// required: true +// +// Responses: +// default: APIErrorResponse +func (a *APIController) DeleteProxyHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + proxyID, err := getValueFromVarsAsUint(vars, "proxyID") + if err != nil { + handleError(ctx, w, err) + return + } + if err := a.r.DeleteProxy(ctx, proxyID); err != nil { + handleError(ctx, 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 e11ae25d2..a3ce6aa88 100644 --- a/apiserver/routers/routers.go +++ b/apiserver/routers/routers.go @@ -654,6 +654,24 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware // Restore templates apiRouter.Handle("/templates/restore/", http.HandlerFunc(han.RestoreTemplatesHandler)).Methods("POST", "OPTIONS") apiRouter.Handle("/templates/restore", http.HandlerFunc(han.RestoreTemplatesHandler)).Methods("POST", "OPTIONS") + + ///////////// + // Proxies // + ///////////// + apiRouter.Handle("/proxies/", http.HandlerFunc(han.ListProxiesHandler)).Methods("GET", "OPTIONS") + apiRouter.Handle("/proxies", http.HandlerFunc(han.ListProxiesHandler)).Methods("GET", "OPTIONS") + // Create proxy + apiRouter.Handle("/proxies/", http.HandlerFunc(han.CreateProxyHandler)).Methods("POST", "OPTIONS") + apiRouter.Handle("/proxies", http.HandlerFunc(han.CreateProxyHandler)).Methods("POST", "OPTIONS") + // Get proxy + apiRouter.Handle("/proxies/{proxyID}/", http.HandlerFunc(han.GetProxyHandler)).Methods("GET", "OPTIONS") + apiRouter.Handle("/proxies/{proxyID}", http.HandlerFunc(han.GetProxyHandler)).Methods("GET", "OPTIONS") + // Delete proxy + apiRouter.Handle("/proxies/{proxyID}/", http.HandlerFunc(han.DeleteProxyHandler)).Methods("DELETE", "OPTIONS") + apiRouter.Handle("/proxies/{proxyID}", http.HandlerFunc(han.DeleteProxyHandler)).Methods("DELETE", "OPTIONS") + // Update proxy + apiRouter.Handle("/proxies/{proxyID}/", http.HandlerFunc(han.UpdateProxyHandler)).Methods("PUT", "OPTIONS") + apiRouter.Handle("/proxies/{proxyID}", http.HandlerFunc(han.UpdateProxyHandler)).Methods("PUT", "OPTIONS") ///////////////////////// // Websocket endpoints // ///////////////////////// diff --git a/apiserver/swagger-models.yaml b/apiserver/swagger-models.yaml index 91aac0da1..0c11b2c1e 100644 --- a/apiserver/swagger-models.yaml +++ b/apiserver/swagger-models.yaml @@ -431,6 +431,36 @@ definitions: import: package: github.com/cloudbase/garm/params alias: garm_params + Proxy: + type: object + x-go-type: + type: Proxy + import: + package: github.com/cloudbase/garm/params + alias: garm_params + Proxies: + type: array + x-go-type: + type: Proxies + import: + package: github.com/cloudbase/garm/params + alias: garm_params + items: + $ref: '#/definitions/Proxy' + CreateProxyParams: + type: object + x-go-type: + type: CreateProxyParams + import: + package: github.com/cloudbase/garm/params + alias: garm_params + UpdateProxyParams: + type: object + x-go-type: + type: UpdateProxyParams + import: + package: github.com/cloudbase/garm/params + alias: garm_params GARMAgentToolsPaginatedResponse: type: object x-go-type: diff --git a/apiserver/swagger.yaml b/apiserver/swagger.yaml index 5de8d0f48..0ffea833e 100644 --- a/apiserver/swagger.yaml +++ b/apiserver/swagger.yaml @@ -86,6 +86,13 @@ definitions: alias: garm_params package: github.com/cloudbase/garm/params type: CreatePoolParams + CreateProxyParams: + type: object + x-go-type: + import: + alias: garm_params + package: github.com/cloudbase/garm/params + type: CreateProxyParams CreateRepoParams: type: object x-go-type: @@ -330,6 +337,22 @@ definitions: alias: garm_params package: github.com/cloudbase/garm/params type: Providers + Proxies: + items: + $ref: '#/definitions/Proxy' + type: array + x-go-type: + import: + alias: garm_params + package: github.com/cloudbase/garm/params + type: Proxies + Proxy: + type: object + x-go-type: + import: + alias: garm_params + package: github.com/cloudbase/garm/params + type: Proxy Repositories: items: $ref: '#/definitions/Repository' @@ -441,6 +464,13 @@ definitions: alias: garm_params package: github.com/cloudbase/garm/params type: UpdatePoolParams + UpdateProxyParams: + type: object + x-go-type: + import: + alias: garm_params + package: github.com/cloudbase/garm/params + type: UpdateProxyParams UpdateScaleSetParams: type: object x-go-type: @@ -2347,6 +2377,109 @@ paths: summary: List all providers. tags: - providers + /proxies: + get: + operationId: ListProxies + responses: + "200": + description: Proxies + schema: + $ref: '#/definitions/Proxies' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: List proxies. + tags: + - proxies + post: + operationId: CreateProxy + parameters: + - description: Parameters used when creating the proxy. + in: body + name: Body + required: true + schema: + $ref: '#/definitions/CreateProxyParams' + description: Parameters used when creating the proxy. + type: object + responses: + "200": + description: Proxy + schema: + $ref: '#/definitions/Proxy' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Create proxy with the parameters given. + tags: + - proxies + /proxies/{proxyID}: + delete: + operationId: DeleteProxy + parameters: + - description: ID of the proxy to delete. + in: path + name: proxyID + required: true + type: number + responses: + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Delete proxy by ID. + tags: + - proxies + get: + operationId: GetProxy + parameters: + - description: ID of the proxy to fetch. + in: path + name: proxyID + required: true + type: number + responses: + "200": + description: Proxy + schema: + $ref: '#/definitions/Proxy' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Get proxy by ID. + tags: + - proxies + put: + operationId: UpdateProxy + parameters: + - description: ID of the proxy to update. + in: path + name: proxyID + required: true + type: number + - description: Parameters used when updating the proxy. + in: body + name: Body + required: true + schema: + $ref: '#/definitions/UpdateProxyParams' + description: Parameters used when updating the proxy. + type: object + responses: + "200": + description: Proxy + schema: + $ref: '#/definitions/Proxy' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Update proxy with the parameters given. + tags: + - proxies /repositories: get: operationId: ListRepos diff --git a/cache/cache_test.go b/cache/cache_test.go index 64d99939b..581cf9ec7 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -49,9 +49,9 @@ func (c *CacheTestSuite) TearDownTest() { githubToolsCache.mux.Lock() defer githubToolsCache.mux.Unlock() githubToolsCache.entities = make(map[string]GithubEntityTools) - giteaCredentialsCache.cache = make(map[uint]params.ForgeCredentials) - credentialsCache.cache = make(map[uint]params.ForgeCredentials) - instanceCache.cache = make(map[string]params.Instance) + giteaCredentialsCache.Clear() + credentialsCache.Clear() + instanceCache.Clear() entityCache = &EntityCache{ entities: make(map[string]EntityItem), pools: make(map[string]params.Pool), diff --git a/cache/credentials_cache.go b/cache/credentials_cache.go index 67eb21433..ee672b5d9 100644 --- a/cache/credentials_cache.go +++ b/cache/credentials_cache.go @@ -14,121 +14,65 @@ package cache import ( - "sync" - "github.com/cloudbase/garm/params" ) var ( - credentialsCache *CredentialCache - giteaCredentialsCache *CredentialCache + credentialsCache = &credentialCache{newKeyedCache[uint, params.ForgeCredentials](0)} + giteaCredentialsCache = &credentialCache{newKeyedCache[uint, params.ForgeCredentials](0)} ) -func init() { - ghCredentialsCache := &CredentialCache{ - cache: make(map[uint]params.ForgeCredentials), - } - gtCredentialsCache := &CredentialCache{ - cache: make(map[uint]params.ForgeCredentials), - } - - credentialsCache = ghCredentialsCache - giteaCredentialsCache = gtCredentialsCache -} - -type CredentialCache struct { - mux sync.Mutex - - cache map[uint]params.ForgeCredentials -} - -func (g *CredentialCache) SetCredentialsRateLimit(credsID uint, rateLimit params.GithubRateLimit) { - g.mux.Lock() - defer g.mux.Unlock() - - if creds, ok := g.cache[credsID]; ok { - creds.RateLimit = &rateLimit - g.cache[credsID] = creds - } +// credentialCache adds the credentials specific compound operations on top +// of the generic cache. Setting credentials also refreshes the credentials +// embedded in cached entities. +type credentialCache struct { + *keyedCache[uint, params.ForgeCredentials] } -func (g *CredentialCache) UpdateCredentialsUsingEndpoint(ep params.ForgeEndpoint) { - g.mux.Lock() - defer g.mux.Unlock() - - for _, creds := range g.cache { - if creds.Endpoint.Name == ep.Name { - creds.Endpoint = ep - g.setCredentialsAndUpdateEntities(creds) +func (g *credentialCache) SetCredentialsRateLimit(credsID uint, rateLimit params.GithubRateLimit) { + g.Update(func(cache map[uint]params.ForgeCredentials) { + if creds, ok := cache[credsID]; ok { + creds.RateLimit = &rateLimit + cache[credsID] = creds } - } -} - -func (g *CredentialCache) setCredentialsAndUpdateEntities(credentials params.ForgeCredentials) { - g.cache[credentials.ID] = credentials - UpdateCredentialsInAffectedEntities(credentials) -} - -func (g *CredentialCache) SetCredentials(credentials params.ForgeCredentials) { - g.mux.Lock() - defer g.mux.Unlock() - - g.setCredentialsAndUpdateEntities(credentials) + }) } -func (g *CredentialCache) GetCredentials(id uint) (params.ForgeCredentials, bool) { - g.mux.Lock() - defer g.mux.Unlock() - - if creds, ok := g.cache[id]; ok { - return creds, true - } - return params.ForgeCredentials{}, false +func (g *credentialCache) UpdateCredentialsUsingEndpoint(ep params.ForgeEndpoint) { + g.Update(func(cache map[uint]params.ForgeCredentials) { + for _, creds := range cache { + if creds.Endpoint.Name == ep.Name { + creds.Endpoint = ep + cache[creds.ID] = creds + UpdateCredentialsInAffectedEntities(creds) + } + } + }) } -func (g *CredentialCache) DeleteCredentials(id uint) { - g.mux.Lock() - defer g.mux.Unlock() - - delete(g.cache, id) +func (g *credentialCache) SetCredentials(credentials params.ForgeCredentials) { + g.Update(func(cache map[uint]params.ForgeCredentials) { + cache[credentials.ID] = credentials + UpdateCredentialsInAffectedEntities(credentials) + }) } -func (g *CredentialCache) GetAllCredentials() []params.ForgeCredentials { - g.mux.Lock() - defer g.mux.Unlock() - - creds := make([]params.ForgeCredentials, 0, len(g.cache)) - for _, cred := range g.cache { - creds = append(creds, cred) - } - - // Sort the credentials by ID +func (g *credentialCache) GetAllCredentials() []params.ForgeCredentials { + creds := g.List() sortByID(creds) return creds } -func (g *CredentialCache) GetAllCredentialsAsMap() map[uint]params.ForgeCredentials { - g.mux.Lock() - defer g.mux.Unlock() - - creds := make(map[uint]params.ForgeCredentials, len(g.cache)) - for id, cred := range g.cache { - creds[id] = cred - } - - return creds -} - func SetGithubCredentials(credentials params.ForgeCredentials) { credentialsCache.SetCredentials(credentials) } func GetGithubCredentials(id uint) (params.ForgeCredentials, bool) { - return credentialsCache.GetCredentials(id) + return credentialsCache.Get(id) } func DeleteGithubCredentials(id uint) { - credentialsCache.DeleteCredentials(id) + credentialsCache.Delete(id) } func GetAllGithubCredentials() []params.ForgeCredentials { @@ -140,7 +84,7 @@ func SetCredentialsRateLimit(credsID uint, rateLimit params.GithubRateLimit) { } func GetAllGithubCredentialsAsMap() map[uint]params.ForgeCredentials { - return credentialsCache.GetAllCredentialsAsMap() + return credentialsCache.AsMap() } func SetGiteaCredentials(credentials params.ForgeCredentials) { @@ -148,11 +92,11 @@ func SetGiteaCredentials(credentials params.ForgeCredentials) { } func GetGiteaCredentials(id uint) (params.ForgeCredentials, bool) { - return giteaCredentialsCache.GetCredentials(id) + return giteaCredentialsCache.Get(id) } func DeleteGiteaCredentials(id uint) { - giteaCredentialsCache.DeleteCredentials(id) + giteaCredentialsCache.Delete(id) } func GetAllGiteaCredentials() []params.ForgeCredentials { @@ -160,7 +104,7 @@ func GetAllGiteaCredentials() []params.ForgeCredentials { } func GetAllGiteaCredentialsAsMap() map[uint]params.ForgeCredentials { - return giteaCredentialsCache.GetAllCredentialsAsMap() + return giteaCredentialsCache.AsMap() } func UpdateCredentialsUsingEndpoint(ep params.ForgeEndpoint) { diff --git a/cache/endpoint_cache.go b/cache/endpoint_cache.go index 61ed868b2..d6517d425 100644 --- a/cache/endpoint_cache.go +++ b/cache/endpoint_cache.go @@ -14,59 +14,22 @@ package cache import ( - "sync" - "github.com/cloudbase/garm/params" ) -var endpointCache *EndpointCache - -func init() { - epCache := &EndpointCache{ - endpoints: make(map[string]params.ForgeEndpoint), - } - endpointCache = epCache -} - -type EndpointCache struct { - endpoints map[string]params.ForgeEndpoint - mux sync.Mutex -} - -func (e *EndpointCache) SetEndpoint(ep params.ForgeEndpoint) { - e.mux.Lock() - defer e.mux.Unlock() - - e.endpoints[ep.Name] = ep - UpdateCredentialsUsingEndpoint(ep) -} - -func (e *EndpointCache) GetEndpoint(epName string) (params.ForgeEndpoint, bool) { - e.mux.Lock() - defer e.mux.Unlock() - - ep, ok := e.endpoints[epName] - if ok { - return ep, true - } - return params.ForgeEndpoint{}, false -} - -func (e *EndpointCache) RemoveEndpoint(epName string) { - e.mux.Lock() - defer e.mux.Unlock() - - delete(e.endpoints, epName) -} +var endpointCache = newKeyedCache[string, params.ForgeEndpoint](0) func SetEndpoint(ep params.ForgeEndpoint) { - endpointCache.SetEndpoint(ep) + endpointCache.Update(func(endpoints map[string]params.ForgeEndpoint) { + endpoints[ep.Name] = ep + UpdateCredentialsUsingEndpoint(ep) + }) } func GetEndpoint(epName string) (params.ForgeEndpoint, bool) { - return endpointCache.GetEndpoint(epName) + return endpointCache.Get(epName) } func RemoveEndpoint(epName string) { - endpointCache.RemoveEndpoint(epName) + endpointCache.Delete(epName) } diff --git a/cache/instance_cache.go b/cache/instance_cache.go index 49eb75801..47b0199aa 100644 --- a/cache/instance_cache.go +++ b/cache/instance_cache.go @@ -14,66 +14,65 @@ package cache import ( - "sync" - "github.com/cloudbase/garm/params" ) -var instanceCache *InstanceCache +var instanceCache = newKeyedCache[string, params.Instance](50000) -func init() { - cache := &InstanceCache{ - cache: make(map[string]params.Instance, 50000), - } - instanceCache = cache +func SetInstanceCache(instance params.Instance) { + instanceCache.Set(instance.Name, instance) } -type InstanceCache struct { - mux sync.Mutex - - cache map[string]params.Instance +func GetInstanceCache(name string) (params.Instance, bool) { + return instanceCache.Get(name) } -func (i *InstanceCache) SetInstance(instance params.Instance) { - i.mux.Lock() - defer i.mux.Unlock() - - i.cache[instance.Name] = instance +func DeleteInstanceCache(name string) { + instanceCache.Delete(name) } -func (i *InstanceCache) GetInstance(name string) (params.Instance, bool) { - i.mux.Lock() - defer i.mux.Unlock() - - if instance, ok := i.cache[name]; ok { - return instance, true - } - return params.Instance{}, false +func GetAllInstancesCache() []params.Instance { + instances := instanceCache.List() + sortByCreationDate(instances) + return instances } -func (i *InstanceCache) DeleteInstance(name string) { - i.mux.Lock() - defer i.mux.Unlock() +func GetInstancesForPool(poolID string) []params.Instance { + instances := instanceCache.List(func(instance params.Instance) bool { + return instance.PoolID == poolID + }) + sortByCreationDate(instances) + return instances +} - delete(i.cache, name) +func GetInstancesForScaleSet(scaleSetID uint) []params.Instance { + instances := instanceCache.List(func(instance params.Instance) bool { + return instance.ScaleSetID == scaleSetID + }) + sortByCreationDate(instances) + return instances } -func (i *InstanceCache) GetAllInstances() []params.Instance { - i.mux.Lock() - defer i.mux.Unlock() +func GetEntityInstances(entityID string) []params.Instance { + pools := GetEntityPools(entityID) + poolsAsMap := map[string]bool{} + for _, pool := range pools { + poolsAsMap[pool.ID] = true + } - instances := make([]params.Instance, 0, len(i.cache)) - for _, instance := range i.cache { - instances = append(instances, instance) + ret := []params.Instance{} + for _, val := range GetAllInstancesCache() { + if _, ok := poolsAsMap[val.PoolID]; ok { + ret = append(ret, val) + } } - sortByCreationDate(instances) - return instances + return ret } -func (i *InstanceCache) GetEntityForInstance(name string) (params.ForgeEntity, bool) { - instance, ok := i.GetInstance(name) +func GetEntityForInstance(name string) (params.ForgeEntity, bool) { + instance, ok := GetInstanceCache(name) if !ok { - return params.ForgeEntity{}, ok + return params.ForgeEntity{}, false } var entityID string @@ -98,7 +97,6 @@ func (i *InstanceCache) GetEntityForInstance(name string) (params.ForgeEntity, b return params.ForgeEntity{}, false } - // Get the full entity if entityID != "" { if entity, ok := GetEntity(entityID); ok { return entity, true @@ -106,79 +104,3 @@ func (i *InstanceCache) GetEntityForInstance(name string) (params.ForgeEntity, b } return params.ForgeEntity{}, false } - -func (i *InstanceCache) GetInstancesForPool(poolID string) []params.Instance { - i.mux.Lock() - defer i.mux.Unlock() - - var filteredInstances []params.Instance - for _, instance := range i.cache { - if instance.PoolID == poolID { - filteredInstances = append(filteredInstances, instance) - } - } - sortByCreationDate(filteredInstances) - return filteredInstances -} - -func (i *InstanceCache) GetInstancesForScaleSet(scaleSetID uint) []params.Instance { - i.mux.Lock() - defer i.mux.Unlock() - - var filteredInstances []params.Instance - for _, instance := range i.cache { - if instance.ScaleSetID == scaleSetID { - filteredInstances = append(filteredInstances, instance) - } - } - sortByCreationDate(filteredInstances) - return filteredInstances -} - -func (i *InstanceCache) GetEntityInstances(entityID string) []params.Instance { - pools := GetEntityPools(entityID) - poolsAsMap := map[string]bool{} - for _, pool := range pools { - poolsAsMap[pool.ID] = true - } - - ret := []params.Instance{} - for _, val := range i.GetAllInstances() { - if _, ok := poolsAsMap[val.PoolID]; ok { - ret = append(ret, val) - } - } - return ret -} - -func SetInstanceCache(instance params.Instance) { - instanceCache.SetInstance(instance) -} - -func GetInstanceCache(name string) (params.Instance, bool) { - return instanceCache.GetInstance(name) -} - -func DeleteInstanceCache(name string) { - instanceCache.DeleteInstance(name) -} - -func GetAllInstancesCache() []params.Instance { - return instanceCache.GetAllInstances() -} - -func GetInstancesForPool(poolID string) []params.Instance { - return instanceCache.GetInstancesForPool(poolID) -} - -func GetInstancesForScaleSet(scaleSetID uint) []params.Instance { - return instanceCache.GetInstancesForScaleSet(scaleSetID) -} - -func GetEntityInstances(entityID string) []params.Instance { - return instanceCache.GetEntityInstances(entityID) -} - -func GetEntityForInstance(name string) (params.ForgeEntity, bool) { - return instanceCache.GetEntityForInstance(name) -} diff --git a/cache/keyed_cache.go b/cache/keyed_cache.go new file mode 100644 index 000000000..b4ffd9624 --- /dev/null +++ b/cache/keyed_cache.go @@ -0,0 +1,108 @@ +// Copyright 2026 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 cache + +import ( + "sync" +) + +// keyedCache is the common core of the per-entity caches: a mutex guarded +// map with copy-out reads. Entity specific behavior (sort order, filtered +// lists, cross cache updates) lives in the per-entity wrappers, either as +// predicates passed to List or as compound operations run under the lock +// via Update. +type keyedCache[K comparable, T any] struct { + mux sync.Mutex + + cache map[K]T + sizeHint int +} + +func newKeyedCache[K comparable, T any](sizeHint int) *keyedCache[K, T] { + return &keyedCache[K, T]{ + cache: make(map[K]T, sizeHint), + sizeHint: sizeHint, + } +} + +func (c *keyedCache[K, T]) Set(key K, value T) { + c.mux.Lock() + defer c.mux.Unlock() + + c.cache[key] = value +} + +func (c *keyedCache[K, T]) Get(key K) (T, bool) { + c.mux.Lock() + defer c.mux.Unlock() + + if value, ok := c.cache[key]; ok { + return value, true + } + var zero T + return zero, false +} + +func (c *keyedCache[K, T]) Delete(key K) { + c.mux.Lock() + defer c.mux.Unlock() + + delete(c.cache, key) +} + +// List returns the values that match all supplied filters. With no filters, +// all values are returned. Order is unspecified; callers sort as needed. +func (c *keyedCache[K, T]) List(filters ...func(T) bool) []T { + c.mux.Lock() + defer c.mux.Unlock() + + ret := make([]T, 0, len(c.cache)) +values: + for _, value := range c.cache { + for _, filter := range filters { + if !filter(value) { + continue values + } + } + ret = append(ret, value) + } + return ret +} + +func (c *keyedCache[K, T]) AsMap() map[K]T { + c.mux.Lock() + defer c.mux.Unlock() + + ret := make(map[K]T, len(c.cache)) + for key, value := range c.cache { + ret[key] = value + } + return ret +} + +// Update runs a compound operation against the raw map while holding the +// cache lock. The callback must not call back into this cache. +func (c *keyedCache[K, T]) Update(fn func(map[K]T)) { + c.mux.Lock() + defer c.mux.Unlock() + + fn(c.cache) +} + +func (c *keyedCache[K, T]) Clear() { + c.mux.Lock() + defer c.mux.Unlock() + + c.cache = make(map[K]T, c.sizeHint) +} diff --git a/cache/proxy_cache.go b/cache/proxy_cache.go new file mode 100644 index 000000000..9bd8fbe36 --- /dev/null +++ b/cache/proxy_cache.go @@ -0,0 +1,39 @@ +// Copyright 2026 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 cache + +import ( + "github.com/cloudbase/garm/params" +) + +var proxyCache = newKeyedCache[uint, params.Proxy](0) + +func SetProxyCache(proxy params.Proxy) { + proxyCache.Set(proxy.ID, proxy) +} + +func GetProxy(id uint) (params.Proxy, bool) { + return proxyCache.Get(id) +} + +func ListProxies() []params.Proxy { + ret := proxyCache.List() + sortByID(ret) + return ret +} + +func DeleteProxy(id uint) { + proxyCache.Delete(id) +} diff --git a/cache/template_cache.go b/cache/template_cache.go index 9ea67819c..e32b35bef 100644 --- a/cache/template_cache.go +++ b/cache/template_cache.go @@ -1,79 +1,32 @@ package cache import ( - "sync" - commonParams "github.com/cloudbase/garm-provider-common/params" "github.com/cloudbase/garm/params" ) -var templateCache *TemplateCache - -func init() { - tplCache := &TemplateCache{ - cache: make(map[uint]params.Template), - } - templateCache = tplCache -} - -type TemplateCache struct { - mux sync.Mutex - - cache map[uint]params.Template -} - -func (t *TemplateCache) SetTemplateCache(tpl params.Template) { - t.mux.Lock() - defer t.mux.Unlock() - - t.cache[tpl.ID] = tpl -} - -func (t *TemplateCache) GetTemplate(id uint) (params.Template, bool) { - t.mux.Lock() - defer t.mux.Unlock() - - tpl, ok := t.cache[id] - if !ok { - return params.Template{}, false - } - - return tpl, true -} - -func (t *TemplateCache) ListTemplates(osType *commonParams.OSType, forgeType *params.EndpointType) []params.Template { - ret := []params.Template{} - for _, val := range t.cache { - if osType != nil && val.OSType != *osType { - continue - } - if forgeType != nil && val.ForgeType != *forgeType { - continue - } - ret = append(ret, val) - } - return ret -} - -func (t *TemplateCache) DeleteTemplate(id uint) { - t.mux.Lock() - defer t.mux.Unlock() - - delete(t.cache, id) -} +var templateCache = newKeyedCache[uint, params.Template](0) func SetTemplateCache(tpl params.Template) { - templateCache.SetTemplateCache(tpl) + templateCache.Set(tpl.ID, tpl) } func GetTemplate(id uint) (params.Template, bool) { - return templateCache.GetTemplate(id) + return templateCache.Get(id) } func ListTemplates(osType *commonParams.OSType, forgeType *params.EndpointType) []params.Template { - return templateCache.ListTemplates(osType, forgeType) + return templateCache.List(func(tpl params.Template) bool { + if osType != nil && tpl.OSType != *osType { + return false + } + if forgeType != nil && tpl.ForgeType != *forgeType { + return false + } + return true + }) } func DeleteTemplate(id uint) { - templateCache.DeleteTemplate(id) + templateCache.Delete(id) } diff --git a/client/garm_api_client.go b/client/garm_api_client.go index fb4de8621..2b3300726 100644 --- a/client/garm_api_client.go +++ b/client/garm_api_client.go @@ -25,6 +25,7 @@ import ( "github.com/cloudbase/garm/client/organizations" "github.com/cloudbase/garm/client/pools" "github.com/cloudbase/garm/client/providers" + "github.com/cloudbase/garm/client/proxies" "github.com/cloudbase/garm/client/repositories" "github.com/cloudbase/garm/client/scalesets" "github.com/cloudbase/garm/client/templates" @@ -88,6 +89,7 @@ func New(transport runtime.ClientTransport, formats strfmt.Registry) *GarmAPI { cli.Organizations = organizations.New(transport, formats) cli.Pools = pools.New(transport, formats) cli.Providers = providers.New(transport, formats) + cli.Proxies = proxies.New(transport, formats) cli.Repositories = repositories.New(transport, formats) cli.Scalesets = scalesets.New(transport, formats) cli.Templates = templates.New(transport, formats) @@ -166,6 +168,8 @@ type GarmAPI struct { Providers providers.ClientService + Proxies proxies.ClientService + Repositories repositories.ClientService Scalesets scalesets.ClientService @@ -195,6 +199,7 @@ func (c *GarmAPI) SetTransport(transport runtime.ClientTransport) { c.Organizations.SetTransport(transport) c.Pools.SetTransport(transport) c.Providers.SetTransport(transport) + c.Proxies.SetTransport(transport) c.Repositories.SetTransport(transport) c.Scalesets.SetTransport(transport) c.Templates.SetTransport(transport) diff --git a/client/proxies/create_proxy_parameters.go b/client/proxies/create_proxy_parameters.go new file mode 100644 index 000000000..4add100c5 --- /dev/null +++ b/client/proxies/create_proxy_parameters.go @@ -0,0 +1,151 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + + garm_params "github.com/cloudbase/garm/params" +) + +// NewCreateProxyParams creates a new CreateProxyParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewCreateProxyParams() *CreateProxyParams { + return &CreateProxyParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewCreateProxyParamsWithTimeout creates a new CreateProxyParams object +// with the ability to set a timeout on a request. +func NewCreateProxyParamsWithTimeout(timeout time.Duration) *CreateProxyParams { + return &CreateProxyParams{ + timeout: timeout, + } +} + +// NewCreateProxyParamsWithContext creates a new CreateProxyParams object +// with the ability to set a context for a request. +func NewCreateProxyParamsWithContext(ctx context.Context) *CreateProxyParams { + return &CreateProxyParams{ + Context: ctx, + } +} + +// NewCreateProxyParamsWithHTTPClient creates a new CreateProxyParams object +// with the ability to set a custom HTTPClient for a request. +func NewCreateProxyParamsWithHTTPClient(client *http.Client) *CreateProxyParams { + return &CreateProxyParams{ + HTTPClient: client, + } +} + +/* +CreateProxyParams contains all the parameters to send to the API endpoint + + for the create proxy operation. + + Typically these are written to a http.Request. +*/ +type CreateProxyParams struct { + + /* Body. + + Parameters used when creating the proxy. + */ + Body garm_params.CreateProxyParams + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the create proxy params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *CreateProxyParams) WithDefaults() *CreateProxyParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the create proxy params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *CreateProxyParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the create proxy params +func (o *CreateProxyParams) WithTimeout(timeout time.Duration) *CreateProxyParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the create proxy params +func (o *CreateProxyParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the create proxy params +func (o *CreateProxyParams) WithContext(ctx context.Context) *CreateProxyParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the create proxy params +func (o *CreateProxyParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the create proxy params +func (o *CreateProxyParams) WithHTTPClient(client *http.Client) *CreateProxyParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the create proxy params +func (o *CreateProxyParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithBody adds the body to the create proxy params +func (o *CreateProxyParams) WithBody(body garm_params.CreateProxyParams) *CreateProxyParams { + o.SetBody(body) + return o +} + +// SetBody adds the body to the create proxy params +func (o *CreateProxyParams) SetBody(body garm_params.CreateProxyParams) { + o.Body = body +} + +// WriteToRequest writes these params to a swagger request +func (o *CreateProxyParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + if err := r.SetBodyParam(o.Body); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/client/proxies/create_proxy_responses.go b/client/proxies/create_proxy_responses.go new file mode 100644 index 000000000..7bc86f0ed --- /dev/null +++ b/client/proxies/create_proxy_responses.go @@ -0,0 +1,185 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "encoding/json" + stderrors "errors" + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + apiserver_params "github.com/cloudbase/garm/apiserver/params" + garm_params "github.com/cloudbase/garm/params" +) + +// CreateProxyReader is a Reader for the CreateProxy structure. +type CreateProxyReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *CreateProxyReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewCreateProxyOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewCreateProxyDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewCreateProxyOK creates a CreateProxyOK with default headers values +func NewCreateProxyOK() *CreateProxyOK { + return &CreateProxyOK{} +} + +/* +CreateProxyOK describes a response with status code 200, with default header values. + +Proxy +*/ +type CreateProxyOK struct { + Payload garm_params.Proxy +} + +// IsSuccess returns true when this create proxy o k response has a 2xx status code +func (o *CreateProxyOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this create proxy o k response has a 3xx status code +func (o *CreateProxyOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this create proxy o k response has a 4xx status code +func (o *CreateProxyOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this create proxy o k response has a 5xx status code +func (o *CreateProxyOK) IsServerError() bool { + return false +} + +// IsCode returns true when this create proxy o k response a status code equal to that given +func (o *CreateProxyOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the create proxy o k response +func (o *CreateProxyOK) Code() int { + return 200 +} + +func (o *CreateProxyOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /proxies][%d] createProxyOK %s", 200, payload) +} + +func (o *CreateProxyOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /proxies][%d] createProxyOK %s", 200, payload) +} + +func (o *CreateProxyOK) GetPayload() garm_params.Proxy { + return o.Payload +} + +func (o *CreateProxyOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewCreateProxyDefault creates a CreateProxyDefault with default headers values +func NewCreateProxyDefault(code int) *CreateProxyDefault { + return &CreateProxyDefault{ + _statusCode: code, + } +} + +/* +CreateProxyDefault describes a response with status code -1, with default header values. + +APIErrorResponse +*/ +type CreateProxyDefault struct { + _statusCode int + + Payload apiserver_params.APIErrorResponse +} + +// IsSuccess returns true when this create proxy default response has a 2xx status code +func (o *CreateProxyDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this create proxy default response has a 3xx status code +func (o *CreateProxyDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this create proxy default response has a 4xx status code +func (o *CreateProxyDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this create proxy default response has a 5xx status code +func (o *CreateProxyDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this create proxy default response a status code equal to that given +func (o *CreateProxyDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the create proxy default response +func (o *CreateProxyDefault) Code() int { + return o._statusCode +} + +func (o *CreateProxyDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /proxies][%d] CreateProxy default %s", o._statusCode, payload) +} + +func (o *CreateProxyDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /proxies][%d] CreateProxy default %s", o._statusCode, payload) +} + +func (o *CreateProxyDefault) GetPayload() apiserver_params.APIErrorResponse { + return o.Payload +} + +func (o *CreateProxyDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} diff --git a/client/proxies/delete_proxy_parameters.go b/client/proxies/delete_proxy_parameters.go new file mode 100644 index 000000000..137e666f4 --- /dev/null +++ b/client/proxies/delete_proxy_parameters.go @@ -0,0 +1,152 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// NewDeleteProxyParams creates a new DeleteProxyParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewDeleteProxyParams() *DeleteProxyParams { + return &DeleteProxyParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewDeleteProxyParamsWithTimeout creates a new DeleteProxyParams object +// with the ability to set a timeout on a request. +func NewDeleteProxyParamsWithTimeout(timeout time.Duration) *DeleteProxyParams { + return &DeleteProxyParams{ + timeout: timeout, + } +} + +// NewDeleteProxyParamsWithContext creates a new DeleteProxyParams object +// with the ability to set a context for a request. +func NewDeleteProxyParamsWithContext(ctx context.Context) *DeleteProxyParams { + return &DeleteProxyParams{ + Context: ctx, + } +} + +// NewDeleteProxyParamsWithHTTPClient creates a new DeleteProxyParams object +// with the ability to set a custom HTTPClient for a request. +func NewDeleteProxyParamsWithHTTPClient(client *http.Client) *DeleteProxyParams { + return &DeleteProxyParams{ + HTTPClient: client, + } +} + +/* +DeleteProxyParams contains all the parameters to send to the API endpoint + + for the delete proxy operation. + + Typically these are written to a http.Request. +*/ +type DeleteProxyParams struct { + + /* ProxyID. + + ID of the proxy to delete. + */ + ProxyID float64 + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the delete proxy params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *DeleteProxyParams) WithDefaults() *DeleteProxyParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the delete proxy params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *DeleteProxyParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the delete proxy params +func (o *DeleteProxyParams) WithTimeout(timeout time.Duration) *DeleteProxyParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the delete proxy params +func (o *DeleteProxyParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the delete proxy params +func (o *DeleteProxyParams) WithContext(ctx context.Context) *DeleteProxyParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the delete proxy params +func (o *DeleteProxyParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the delete proxy params +func (o *DeleteProxyParams) WithHTTPClient(client *http.Client) *DeleteProxyParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the delete proxy params +func (o *DeleteProxyParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithProxyID adds the proxyID to the delete proxy params +func (o *DeleteProxyParams) WithProxyID(proxyID float64) *DeleteProxyParams { + o.SetProxyID(proxyID) + return o +} + +// SetProxyID adds the proxyId to the delete proxy params +func (o *DeleteProxyParams) SetProxyID(proxyID float64) { + o.ProxyID = proxyID +} + +// WriteToRequest writes these params to a swagger request +func (o *DeleteProxyParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + // path param proxyID + if err := r.SetPathParam("proxyID", swag.FormatFloat64(o.ProxyID)); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/client/proxies/delete_proxy_responses.go b/client/proxies/delete_proxy_responses.go new file mode 100644 index 000000000..ef9b38dd7 --- /dev/null +++ b/client/proxies/delete_proxy_responses.go @@ -0,0 +1,107 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "encoding/json" + stderrors "errors" + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + apiserver_params "github.com/cloudbase/garm/apiserver/params" +) + +// DeleteProxyReader is a Reader for the DeleteProxy structure. +type DeleteProxyReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *DeleteProxyReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + result := NewDeleteProxyDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result +} + +// NewDeleteProxyDefault creates a DeleteProxyDefault with default headers values +func NewDeleteProxyDefault(code int) *DeleteProxyDefault { + return &DeleteProxyDefault{ + _statusCode: code, + } +} + +/* +DeleteProxyDefault describes a response with status code -1, with default header values. + +APIErrorResponse +*/ +type DeleteProxyDefault struct { + _statusCode int + + Payload apiserver_params.APIErrorResponse +} + +// IsSuccess returns true when this delete proxy default response has a 2xx status code +func (o *DeleteProxyDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this delete proxy default response has a 3xx status code +func (o *DeleteProxyDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this delete proxy default response has a 4xx status code +func (o *DeleteProxyDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this delete proxy default response has a 5xx status code +func (o *DeleteProxyDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this delete proxy default response a status code equal to that given +func (o *DeleteProxyDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the delete proxy default response +func (o *DeleteProxyDefault) Code() int { + return o._statusCode +} + +func (o *DeleteProxyDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[DELETE /proxies/{proxyID}][%d] DeleteProxy default %s", o._statusCode, payload) +} + +func (o *DeleteProxyDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[DELETE /proxies/{proxyID}][%d] DeleteProxy default %s", o._statusCode, payload) +} + +func (o *DeleteProxyDefault) GetPayload() apiserver_params.APIErrorResponse { + return o.Payload +} + +func (o *DeleteProxyDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} diff --git a/client/proxies/get_proxy_parameters.go b/client/proxies/get_proxy_parameters.go new file mode 100644 index 000000000..d97729c86 --- /dev/null +++ b/client/proxies/get_proxy_parameters.go @@ -0,0 +1,152 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// NewGetProxyParams creates a new GetProxyParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewGetProxyParams() *GetProxyParams { + return &GetProxyParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewGetProxyParamsWithTimeout creates a new GetProxyParams object +// with the ability to set a timeout on a request. +func NewGetProxyParamsWithTimeout(timeout time.Duration) *GetProxyParams { + return &GetProxyParams{ + timeout: timeout, + } +} + +// NewGetProxyParamsWithContext creates a new GetProxyParams object +// with the ability to set a context for a request. +func NewGetProxyParamsWithContext(ctx context.Context) *GetProxyParams { + return &GetProxyParams{ + Context: ctx, + } +} + +// NewGetProxyParamsWithHTTPClient creates a new GetProxyParams object +// with the ability to set a custom HTTPClient for a request. +func NewGetProxyParamsWithHTTPClient(client *http.Client) *GetProxyParams { + return &GetProxyParams{ + HTTPClient: client, + } +} + +/* +GetProxyParams contains all the parameters to send to the API endpoint + + for the get proxy operation. + + Typically these are written to a http.Request. +*/ +type GetProxyParams struct { + + /* ProxyID. + + ID of the proxy to fetch. + */ + ProxyID float64 + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the get proxy params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *GetProxyParams) WithDefaults() *GetProxyParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the get proxy params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *GetProxyParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the get proxy params +func (o *GetProxyParams) WithTimeout(timeout time.Duration) *GetProxyParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the get proxy params +func (o *GetProxyParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the get proxy params +func (o *GetProxyParams) WithContext(ctx context.Context) *GetProxyParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the get proxy params +func (o *GetProxyParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the get proxy params +func (o *GetProxyParams) WithHTTPClient(client *http.Client) *GetProxyParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the get proxy params +func (o *GetProxyParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithProxyID adds the proxyID to the get proxy params +func (o *GetProxyParams) WithProxyID(proxyID float64) *GetProxyParams { + o.SetProxyID(proxyID) + return o +} + +// SetProxyID adds the proxyId to the get proxy params +func (o *GetProxyParams) SetProxyID(proxyID float64) { + o.ProxyID = proxyID +} + +// WriteToRequest writes these params to a swagger request +func (o *GetProxyParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + // path param proxyID + if err := r.SetPathParam("proxyID", swag.FormatFloat64(o.ProxyID)); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/client/proxies/get_proxy_responses.go b/client/proxies/get_proxy_responses.go new file mode 100644 index 000000000..63bf434d3 --- /dev/null +++ b/client/proxies/get_proxy_responses.go @@ -0,0 +1,185 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "encoding/json" + stderrors "errors" + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + apiserver_params "github.com/cloudbase/garm/apiserver/params" + garm_params "github.com/cloudbase/garm/params" +) + +// GetProxyReader is a Reader for the GetProxy structure. +type GetProxyReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *GetProxyReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewGetProxyOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewGetProxyDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewGetProxyOK creates a GetProxyOK with default headers values +func NewGetProxyOK() *GetProxyOK { + return &GetProxyOK{} +} + +/* +GetProxyOK describes a response with status code 200, with default header values. + +Proxy +*/ +type GetProxyOK struct { + Payload garm_params.Proxy +} + +// IsSuccess returns true when this get proxy o k response has a 2xx status code +func (o *GetProxyOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this get proxy o k response has a 3xx status code +func (o *GetProxyOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this get proxy o k response has a 4xx status code +func (o *GetProxyOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this get proxy o k response has a 5xx status code +func (o *GetProxyOK) IsServerError() bool { + return false +} + +// IsCode returns true when this get proxy o k response a status code equal to that given +func (o *GetProxyOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the get proxy o k response +func (o *GetProxyOK) Code() int { + return 200 +} + +func (o *GetProxyOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /proxies/{proxyID}][%d] getProxyOK %s", 200, payload) +} + +func (o *GetProxyOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /proxies/{proxyID}][%d] getProxyOK %s", 200, payload) +} + +func (o *GetProxyOK) GetPayload() garm_params.Proxy { + return o.Payload +} + +func (o *GetProxyOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewGetProxyDefault creates a GetProxyDefault with default headers values +func NewGetProxyDefault(code int) *GetProxyDefault { + return &GetProxyDefault{ + _statusCode: code, + } +} + +/* +GetProxyDefault describes a response with status code -1, with default header values. + +APIErrorResponse +*/ +type GetProxyDefault struct { + _statusCode int + + Payload apiserver_params.APIErrorResponse +} + +// IsSuccess returns true when this get proxy default response has a 2xx status code +func (o *GetProxyDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this get proxy default response has a 3xx status code +func (o *GetProxyDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this get proxy default response has a 4xx status code +func (o *GetProxyDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this get proxy default response has a 5xx status code +func (o *GetProxyDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this get proxy default response a status code equal to that given +func (o *GetProxyDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the get proxy default response +func (o *GetProxyDefault) Code() int { + return o._statusCode +} + +func (o *GetProxyDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /proxies/{proxyID}][%d] GetProxy default %s", o._statusCode, payload) +} + +func (o *GetProxyDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /proxies/{proxyID}][%d] GetProxy default %s", o._statusCode, payload) +} + +func (o *GetProxyDefault) GetPayload() apiserver_params.APIErrorResponse { + return o.Payload +} + +func (o *GetProxyDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} diff --git a/client/proxies/list_proxies_parameters.go b/client/proxies/list_proxies_parameters.go new file mode 100644 index 000000000..0e347846d --- /dev/null +++ b/client/proxies/list_proxies_parameters.go @@ -0,0 +1,128 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewListProxiesParams creates a new ListProxiesParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewListProxiesParams() *ListProxiesParams { + return &ListProxiesParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewListProxiesParamsWithTimeout creates a new ListProxiesParams object +// with the ability to set a timeout on a request. +func NewListProxiesParamsWithTimeout(timeout time.Duration) *ListProxiesParams { + return &ListProxiesParams{ + timeout: timeout, + } +} + +// NewListProxiesParamsWithContext creates a new ListProxiesParams object +// with the ability to set a context for a request. +func NewListProxiesParamsWithContext(ctx context.Context) *ListProxiesParams { + return &ListProxiesParams{ + Context: ctx, + } +} + +// NewListProxiesParamsWithHTTPClient creates a new ListProxiesParams object +// with the ability to set a custom HTTPClient for a request. +func NewListProxiesParamsWithHTTPClient(client *http.Client) *ListProxiesParams { + return &ListProxiesParams{ + HTTPClient: client, + } +} + +/* +ListProxiesParams contains all the parameters to send to the API endpoint + + for the list proxies operation. + + Typically these are written to a http.Request. +*/ +type ListProxiesParams struct { + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the list proxies params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ListProxiesParams) WithDefaults() *ListProxiesParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the list proxies params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ListProxiesParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the list proxies params +func (o *ListProxiesParams) WithTimeout(timeout time.Duration) *ListProxiesParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the list proxies params +func (o *ListProxiesParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the list proxies params +func (o *ListProxiesParams) WithContext(ctx context.Context) *ListProxiesParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the list proxies params +func (o *ListProxiesParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the list proxies params +func (o *ListProxiesParams) WithHTTPClient(client *http.Client) *ListProxiesParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the list proxies params +func (o *ListProxiesParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WriteToRequest writes these params to a swagger request +func (o *ListProxiesParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/client/proxies/list_proxies_responses.go b/client/proxies/list_proxies_responses.go new file mode 100644 index 000000000..6e9e4e73c --- /dev/null +++ b/client/proxies/list_proxies_responses.go @@ -0,0 +1,185 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "encoding/json" + stderrors "errors" + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + apiserver_params "github.com/cloudbase/garm/apiserver/params" + garm_params "github.com/cloudbase/garm/params" +) + +// ListProxiesReader is a Reader for the ListProxies structure. +type ListProxiesReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *ListProxiesReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewListProxiesOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewListProxiesDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewListProxiesOK creates a ListProxiesOK with default headers values +func NewListProxiesOK() *ListProxiesOK { + return &ListProxiesOK{} +} + +/* +ListProxiesOK describes a response with status code 200, with default header values. + +Proxies +*/ +type ListProxiesOK struct { + Payload garm_params.Proxies +} + +// IsSuccess returns true when this list proxies o k response has a 2xx status code +func (o *ListProxiesOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this list proxies o k response has a 3xx status code +func (o *ListProxiesOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this list proxies o k response has a 4xx status code +func (o *ListProxiesOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this list proxies o k response has a 5xx status code +func (o *ListProxiesOK) IsServerError() bool { + return false +} + +// IsCode returns true when this list proxies o k response a status code equal to that given +func (o *ListProxiesOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the list proxies o k response +func (o *ListProxiesOK) Code() int { + return 200 +} + +func (o *ListProxiesOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /proxies][%d] listProxiesOK %s", 200, payload) +} + +func (o *ListProxiesOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /proxies][%d] listProxiesOK %s", 200, payload) +} + +func (o *ListProxiesOK) GetPayload() garm_params.Proxies { + return o.Payload +} + +func (o *ListProxiesOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewListProxiesDefault creates a ListProxiesDefault with default headers values +func NewListProxiesDefault(code int) *ListProxiesDefault { + return &ListProxiesDefault{ + _statusCode: code, + } +} + +/* +ListProxiesDefault describes a response with status code -1, with default header values. + +APIErrorResponse +*/ +type ListProxiesDefault struct { + _statusCode int + + Payload apiserver_params.APIErrorResponse +} + +// IsSuccess returns true when this list proxies default response has a 2xx status code +func (o *ListProxiesDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this list proxies default response has a 3xx status code +func (o *ListProxiesDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this list proxies default response has a 4xx status code +func (o *ListProxiesDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this list proxies default response has a 5xx status code +func (o *ListProxiesDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this list proxies default response a status code equal to that given +func (o *ListProxiesDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the list proxies default response +func (o *ListProxiesDefault) Code() int { + return o._statusCode +} + +func (o *ListProxiesDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /proxies][%d] ListProxies default %s", o._statusCode, payload) +} + +func (o *ListProxiesDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /proxies][%d] ListProxies default %s", o._statusCode, payload) +} + +func (o *ListProxiesDefault) GetPayload() apiserver_params.APIErrorResponse { + return o.Payload +} + +func (o *ListProxiesDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} diff --git a/client/proxies/proxies_client.go b/client/proxies/proxies_client.go new file mode 100644 index 000000000..33d3b1d87 --- /dev/null +++ b/client/proxies/proxies_client.go @@ -0,0 +1,278 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "github.com/go-openapi/runtime" + httptransport "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// New creates a new proxies API client. +func New(transport runtime.ClientTransport, formats strfmt.Registry) ClientService { + return &Client{transport: transport, formats: formats} +} + +// New creates a new proxies API client with basic auth credentials. +// It takes the following parameters: +// - host: http host (github.com). +// - basePath: any base path for the API client ("/v1", "/v3"). +// - scheme: http scheme ("http", "https"). +// - user: user for basic authentication header. +// - password: password for basic authentication header. +func NewClientWithBasicAuth(host, basePath, scheme, user, password string) ClientService { + transport := httptransport.New(host, basePath, []string{scheme}) + transport.DefaultAuthentication = httptransport.BasicAuth(user, password) + return &Client{transport: transport, formats: strfmt.Default} +} + +// New creates a new proxies API client with a bearer token for authentication. +// It takes the following parameters: +// - host: http host (github.com). +// - basePath: any base path for the API client ("/v1", "/v3"). +// - scheme: http scheme ("http", "https"). +// - bearerToken: bearer token for Bearer authentication header. +func NewClientWithBearerToken(host, basePath, scheme, bearerToken string) ClientService { + transport := httptransport.New(host, basePath, []string{scheme}) + transport.DefaultAuthentication = httptransport.BearerToken(bearerToken) + return &Client{transport: transport, formats: strfmt.Default} +} + +/* +Client for proxies API +*/ +type Client struct { + transport runtime.ClientTransport + formats strfmt.Registry +} + +// ClientOption may be used to customize the behavior of Client methods. +type ClientOption func(*runtime.ClientOperation) + +// ClientService is the interface for Client methods +type ClientService interface { + CreateProxy(params *CreateProxyParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*CreateProxyOK, error) + + DeleteProxy(params *DeleteProxyParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) error + + GetProxy(params *GetProxyParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetProxyOK, error) + + ListProxies(params *ListProxiesParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*ListProxiesOK, error) + + UpdateProxy(params *UpdateProxyParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*UpdateProxyOK, error) + + SetTransport(transport runtime.ClientTransport) +} + +/* +CreateProxy creates proxy with the parameters given +*/ +func (a *Client) CreateProxy(params *CreateProxyParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*CreateProxyOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewCreateProxyParams() + } + op := &runtime.ClientOperation{ + ID: "CreateProxy", + Method: "POST", + PathPattern: "/proxies", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &CreateProxyReader{formats: a.formats}, + AuthInfo: authInfo, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*CreateProxyOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*CreateProxyDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + +/* +DeleteProxy deletes proxy by ID +*/ +func (a *Client) DeleteProxy(params *DeleteProxyParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) error { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewDeleteProxyParams() + } + op := &runtime.ClientOperation{ + ID: "DeleteProxy", + Method: "DELETE", + PathPattern: "/proxies/{proxyID}", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &DeleteProxyReader{formats: a.formats}, + AuthInfo: authInfo, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + _, err := a.transport.Submit(op) + if err != nil { + return err + } + // no success response is defined: return nil + + return nil +} + +/* +GetProxy gets proxy by ID +*/ +func (a *Client) GetProxy(params *GetProxyParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetProxyOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewGetProxyParams() + } + op := &runtime.ClientOperation{ + ID: "GetProxy", + Method: "GET", + PathPattern: "/proxies/{proxyID}", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &GetProxyReader{formats: a.formats}, + AuthInfo: authInfo, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*GetProxyOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*GetProxyDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + +/* +ListProxies lists proxies +*/ +func (a *Client) ListProxies(params *ListProxiesParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*ListProxiesOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewListProxiesParams() + } + op := &runtime.ClientOperation{ + ID: "ListProxies", + Method: "GET", + PathPattern: "/proxies", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &ListProxiesReader{formats: a.formats}, + AuthInfo: authInfo, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*ListProxiesOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*ListProxiesDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + +/* +UpdateProxy updates proxy with the parameters given +*/ +func (a *Client) UpdateProxy(params *UpdateProxyParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*UpdateProxyOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewUpdateProxyParams() + } + op := &runtime.ClientOperation{ + ID: "UpdateProxy", + Method: "PUT", + PathPattern: "/proxies/{proxyID}", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &UpdateProxyReader{formats: a.formats}, + AuthInfo: authInfo, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*UpdateProxyOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*UpdateProxyDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + +// SetTransport changes the transport on the client +func (a *Client) SetTransport(transport runtime.ClientTransport) { + a.transport = transport +} diff --git a/client/proxies/update_proxy_parameters.go b/client/proxies/update_proxy_parameters.go new file mode 100644 index 000000000..6db270dbb --- /dev/null +++ b/client/proxies/update_proxy_parameters.go @@ -0,0 +1,174 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + + garm_params "github.com/cloudbase/garm/params" +) + +// NewUpdateProxyParams creates a new UpdateProxyParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewUpdateProxyParams() *UpdateProxyParams { + return &UpdateProxyParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewUpdateProxyParamsWithTimeout creates a new UpdateProxyParams object +// with the ability to set a timeout on a request. +func NewUpdateProxyParamsWithTimeout(timeout time.Duration) *UpdateProxyParams { + return &UpdateProxyParams{ + timeout: timeout, + } +} + +// NewUpdateProxyParamsWithContext creates a new UpdateProxyParams object +// with the ability to set a context for a request. +func NewUpdateProxyParamsWithContext(ctx context.Context) *UpdateProxyParams { + return &UpdateProxyParams{ + Context: ctx, + } +} + +// NewUpdateProxyParamsWithHTTPClient creates a new UpdateProxyParams object +// with the ability to set a custom HTTPClient for a request. +func NewUpdateProxyParamsWithHTTPClient(client *http.Client) *UpdateProxyParams { + return &UpdateProxyParams{ + HTTPClient: client, + } +} + +/* +UpdateProxyParams contains all the parameters to send to the API endpoint + + for the update proxy operation. + + Typically these are written to a http.Request. +*/ +type UpdateProxyParams struct { + + /* Body. + + Parameters used when updating the proxy. + */ + Body garm_params.UpdateProxyParams + + /* ProxyID. + + ID of the proxy to update. + */ + ProxyID float64 + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the update proxy params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *UpdateProxyParams) WithDefaults() *UpdateProxyParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the update proxy params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *UpdateProxyParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the update proxy params +func (o *UpdateProxyParams) WithTimeout(timeout time.Duration) *UpdateProxyParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the update proxy params +func (o *UpdateProxyParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the update proxy params +func (o *UpdateProxyParams) WithContext(ctx context.Context) *UpdateProxyParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the update proxy params +func (o *UpdateProxyParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the update proxy params +func (o *UpdateProxyParams) WithHTTPClient(client *http.Client) *UpdateProxyParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the update proxy params +func (o *UpdateProxyParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithBody adds the body to the update proxy params +func (o *UpdateProxyParams) WithBody(body garm_params.UpdateProxyParams) *UpdateProxyParams { + o.SetBody(body) + return o +} + +// SetBody adds the body to the update proxy params +func (o *UpdateProxyParams) SetBody(body garm_params.UpdateProxyParams) { + o.Body = body +} + +// WithProxyID adds the proxyID to the update proxy params +func (o *UpdateProxyParams) WithProxyID(proxyID float64) *UpdateProxyParams { + o.SetProxyID(proxyID) + return o +} + +// SetProxyID adds the proxyId to the update proxy params +func (o *UpdateProxyParams) SetProxyID(proxyID float64) { + o.ProxyID = proxyID +} + +// WriteToRequest writes these params to a swagger request +func (o *UpdateProxyParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + if err := r.SetBodyParam(o.Body); err != nil { + return err + } + + // path param proxyID + if err := r.SetPathParam("proxyID", swag.FormatFloat64(o.ProxyID)); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/client/proxies/update_proxy_responses.go b/client/proxies/update_proxy_responses.go new file mode 100644 index 000000000..768f1ddc3 --- /dev/null +++ b/client/proxies/update_proxy_responses.go @@ -0,0 +1,185 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package proxies + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "encoding/json" + stderrors "errors" + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + apiserver_params "github.com/cloudbase/garm/apiserver/params" + garm_params "github.com/cloudbase/garm/params" +) + +// UpdateProxyReader is a Reader for the UpdateProxy structure. +type UpdateProxyReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *UpdateProxyReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewUpdateProxyOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewUpdateProxyDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewUpdateProxyOK creates a UpdateProxyOK with default headers values +func NewUpdateProxyOK() *UpdateProxyOK { + return &UpdateProxyOK{} +} + +/* +UpdateProxyOK describes a response with status code 200, with default header values. + +Proxy +*/ +type UpdateProxyOK struct { + Payload garm_params.Proxy +} + +// IsSuccess returns true when this update proxy o k response has a 2xx status code +func (o *UpdateProxyOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this update proxy o k response has a 3xx status code +func (o *UpdateProxyOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this update proxy o k response has a 4xx status code +func (o *UpdateProxyOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this update proxy o k response has a 5xx status code +func (o *UpdateProxyOK) IsServerError() bool { + return false +} + +// IsCode returns true when this update proxy o k response a status code equal to that given +func (o *UpdateProxyOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the update proxy o k response +func (o *UpdateProxyOK) Code() int { + return 200 +} + +func (o *UpdateProxyOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /proxies/{proxyID}][%d] updateProxyOK %s", 200, payload) +} + +func (o *UpdateProxyOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /proxies/{proxyID}][%d] updateProxyOK %s", 200, payload) +} + +func (o *UpdateProxyOK) GetPayload() garm_params.Proxy { + return o.Payload +} + +func (o *UpdateProxyOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewUpdateProxyDefault creates a UpdateProxyDefault with default headers values +func NewUpdateProxyDefault(code int) *UpdateProxyDefault { + return &UpdateProxyDefault{ + _statusCode: code, + } +} + +/* +UpdateProxyDefault describes a response with status code -1, with default header values. + +APIErrorResponse +*/ +type UpdateProxyDefault struct { + _statusCode int + + Payload apiserver_params.APIErrorResponse +} + +// IsSuccess returns true when this update proxy default response has a 2xx status code +func (o *UpdateProxyDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this update proxy default response has a 3xx status code +func (o *UpdateProxyDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this update proxy default response has a 4xx status code +func (o *UpdateProxyDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this update proxy default response has a 5xx status code +func (o *UpdateProxyDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this update proxy default response a status code equal to that given +func (o *UpdateProxyDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the update proxy default response +func (o *UpdateProxyDefault) Code() int { + return o._statusCode +} + +func (o *UpdateProxyDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /proxies/{proxyID}][%d] UpdateProxy default %s", o._statusCode, payload) +} + +func (o *UpdateProxyDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /proxies/{proxyID}][%d] UpdateProxy default %s", o._statusCode, payload) +} + +func (o *UpdateProxyDefault) GetPayload() apiserver_params.APIErrorResponse { + return o.Payload +} + +func (o *UpdateProxyDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} diff --git a/cmd/garm-cli/cmd/pool.go b/cmd/garm-cli/cmd/pool.go index 502b1e94c..d6a4a93b4 100644 --- a/cmd/garm-cli/cmd/pool.go +++ b/cmd/garm-cli/cmd/pool.go @@ -57,6 +57,8 @@ var ( poolGitHubRunnerGroup string priority uint poolTemplateNameOrID string + poolProxyNameOrID string + poolClearProxy bool poolEnableShell bool ) @@ -279,6 +281,14 @@ var poolAddCmd = &cobra.Command{ newPoolParams.TemplateID = &tmplID } + if cmd.Flags().Changed("proxy") && poolProxyNameOrID != "" { + proxyID, err := resolveProxyAsUint(poolProxyNameOrID) + if err != nil { + return err + } + newPoolParams.ProxyID = &proxyID + } + var response poolPayloadGetter if cmd.Flags().Changed("repo") { poolRepository, err = resolveRepository(poolRepository, endpointName) @@ -364,6 +374,17 @@ explicitly remove them using the runner delete command. poolUpdateParams.TemplateID = &tmplID } + if cmd.Flags().Changed("proxy") && poolProxyNameOrID != "" { + proxyID, err := resolveProxyAsUint(poolProxyNameOrID) + if err != nil { + return err + } + poolUpdateParams.ProxyID = &proxyID + } else if poolClearProxy { + var noProxy uint + poolUpdateParams.ProxyID = &noProxy + } + if cmd.Flags().Changed("image") { poolUpdateParams.Image = poolImage } @@ -596,6 +617,9 @@ func init() { poolUpdateCmd.Flags().BoolVar(&poolEnableShell, "enable-shell", false, "Enable shell access for runners in this pool.") poolUpdateCmd.MarkFlagsMutuallyExclusive("extra-specs-file", "extra-specs") poolUpdateCmd.Flags().StringVar(&poolTemplateNameOrID, "runner-install-template", "", "The runner install template name or ID to use for this pool.") + poolUpdateCmd.Flags().StringVar(&poolProxyNameOrID, "proxy", "", "The proxy name or ID runners in this pool will use.") + poolUpdateCmd.Flags().BoolVar(&poolClearProxy, "clear-proxy", false, "Remove the proxy from this pool.") + poolUpdateCmd.MarkFlagsMutuallyExclusive("proxy", "clear-proxy") poolAddCmd.Flags().StringVar(&poolProvider, "provider-name", "", "The name of the provider where runners will be created.") poolAddCmd.Flags().UintVar(&priority, "priority", 0, "When multiple pools match the same labels, priority dictates the order by which they are returned, in descending order.") @@ -614,6 +638,7 @@ func init() { poolAddCmd.Flags().BoolVar(&poolEnabled, "enabled", false, "Enable this pool.") poolAddCmd.Flags().BoolVar(&poolEnableShell, "enable-shell", false, "Enable shell access for runners in this pool.") poolAddCmd.Flags().StringVar(&poolTemplateNameOrID, "runner-install-template", "", "The runner install template name or ID to use for this pool.") + poolAddCmd.Flags().StringVar(&poolProxyNameOrID, "proxy", "", "The proxy name or ID runners in this pool will use.") poolAddCmd.Flags().StringVar(&endpointName, "endpoint", "", "When using the name of an entity, the endpoint must be specified when multiple entities with the same name exist.") poolAddCmd.MarkFlagRequired("provider-name") //nolint @@ -775,6 +800,9 @@ func formatOnePool(pool params.Pool) { t.AppendRow(table.Row{"Belongs to", belongsTo}) t.AppendRow(table.Row{"Level", level}) t.AppendRow(table.Row{"Template", fmt.Sprintf("%s (ID: %d)", pool.TemplateName, pool.TemplateID)}) + if pool.ProxyID != 0 { + t.AppendRow(table.Row{"Proxy", fmt.Sprintf("%s (ID: %d)", pool.ProxyName, pool.ProxyID)}) + } t.AppendRow(table.Row{"Enabled", pool.Enabled}) t.AppendRow(table.Row{"Runner Prefix", pool.GetRunnerPrefix()}) t.AppendRow(table.Row{"Extra specs", string(pool.ExtraSpecs)}) diff --git a/cmd/garm-cli/cmd/proxies.go b/cmd/garm-cli/cmd/proxies.go new file mode 100644 index 000000000..52cb6aadd --- /dev/null +++ b/cmd/garm-cli/cmd/proxies.go @@ -0,0 +1,319 @@ +// Copyright 2026 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/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + + apiClientProxies "github.com/cloudbase/garm/client/proxies" + "github.com/cloudbase/garm/cmd/garm-cli/common" + "github.com/cloudbase/garm/params" +) + +var ( + proxyName string + proxyDescription string + proxyHTTPProxy string + proxyHTTPSProxy string + proxyNoProxy string + proxyUsername string + proxyPassword string +) + +var proxiesCmd = &cobra.Command{ + Use: "proxy", + SilenceUsage: true, + Short: "Manage proxies", + Long: `Manage proxy definitions. + +Proxy definitions hold the proxy settings that runners will use to reach +back to GARM, the forge and any other resources they need during setup. +They are useful when runners are spun up in environments without direct +outbound connectivity, such as air-gapped clouds. + +Pools and scale sets may reference a proxy definition, in which case the +proxy settings will be injected into the runners they create. +`, + Run: nil, +} + +var proxyCreateCmd = &cobra.Command{ + Use: "create", + Aliases: []string{"add"}, + SilenceUsage: true, + Short: "Create a new proxy", + Long: `Create a new proxy definition.`, + RunE: func(_ *cobra.Command, _ []string) error { + if needsInit { + return errNeedsInitError + } + + createProxyReq := apiClientProxies.NewCreateProxyParams() + createProxyReq.Body = params.CreateProxyParams{ + Name: proxyName, + Description: proxyDescription, + HTTPProxy: proxyHTTPProxy, + HTTPSProxy: proxyHTTPSProxy, + NoProxy: proxyNoProxy, + Username: proxyUsername, + Password: proxyPassword, + } + + response, err := apiCli.Proxies.CreateProxy(createProxyReq, authToken) + if err != nil { + return err + } + formatOneProxy(response.Payload) + return nil + }, +} + +var proxyListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + SilenceUsage: true, + Short: "List proxies", + Long: `List all proxy definitions.`, + RunE: func(_ *cobra.Command, _ []string) error { + if needsInit { + return errNeedsInitError + } + + listProxiesReq := apiClientProxies.NewListProxiesParams() + response, err := apiCli.Proxies.ListProxies(listProxiesReq, authToken) + if err != nil { + return err + } + formatProxyList(response.Payload) + return nil + }, +} + +var proxyShowCmd = &cobra.Command{ + Use: "show [flags] proxy_name_or_id", + SilenceUsage: true, + Short: "Show proxy", + Long: `Show details of one proxy definition.`, + RunE: func(_ *cobra.Command, args []string) error { + if needsInit { + return errNeedsInitError + } + + if len(args) == 0 { + return fmt.Errorf("command requires a proxy name or ID") + } + + if len(args) > 1 { + return fmt.Errorf("too many arguments") + } + + proxyID, err := resolveProxyAsUint(args[0]) + if err != nil { + return err + } + + getProxyReq := apiClientProxies.NewGetProxyParams() + getProxyReq.ProxyID = float64(proxyID) + response, err := apiCli.Proxies.GetProxy(getProxyReq, authToken) + if err != nil { + return err + } + formatOneProxy(response.Payload) + return nil + }, +} + +var proxyUpdateCmd = &cobra.Command{ + Use: "update [flags] proxy_name_or_id", + SilenceUsage: true, + Short: "Update proxy", + Long: `Update a proxy definition. + +Runners created after the update will use the new proxy settings. Existing +runners are not affected. + +Setting --username to an empty string clears the proxy credentials. +`, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return errNeedsInitError + } + + if len(args) == 0 { + return fmt.Errorf("command requires a proxy name or ID") + } + + if len(args) > 1 { + return fmt.Errorf("too many arguments") + } + + proxyID, err := resolveProxyAsUint(args[0]) + if err != nil { + return err + } + + updateParams := params.UpdateProxyParams{} + if cmd.Flags().Changed("name") { + updateParams.Name = &proxyName + } + if cmd.Flags().Changed("description") { + updateParams.Description = &proxyDescription + } + if cmd.Flags().Changed("http-proxy") { + updateParams.HTTPProxy = &proxyHTTPProxy + } + if cmd.Flags().Changed("https-proxy") { + updateParams.HTTPSProxy = &proxyHTTPSProxy + } + if cmd.Flags().Changed("no-proxy") { + updateParams.NoProxy = &proxyNoProxy + } + if cmd.Flags().Changed("username") { + updateParams.Username = &proxyUsername + } + if cmd.Flags().Changed("password") { + updateParams.Password = &proxyPassword + } + + updateProxyReq := apiClientProxies.NewUpdateProxyParams() + updateProxyReq.ProxyID = float64(proxyID) + updateProxyReq.Body = updateParams + + response, err := apiCli.Proxies.UpdateProxy(updateProxyReq, authToken) + if err != nil { + return err + } + formatOneProxy(response.Payload) + return nil + }, +} + +var proxyDeleteCmd = &cobra.Command{ + Use: "delete [flags] proxy_name_or_id", + Aliases: []string{"remove", "rm"}, + SilenceUsage: true, + Short: "Delete proxy", + Long: `Delete a proxy definition. + +Proxies that are still referenced by pools or scale sets cannot be deleted. +`, + RunE: func(_ *cobra.Command, args []string) error { + if needsInit { + return errNeedsInitError + } + + if len(args) == 0 { + return fmt.Errorf("command requires a proxy name or ID") + } + + if len(args) > 1 { + return fmt.Errorf("too many arguments") + } + + proxyID, err := resolveProxyAsUint(args[0]) + if err != nil { + return err + } + + deleteProxyReq := apiClientProxies.NewDeleteProxyParams() + deleteProxyReq.ProxyID = float64(proxyID) + if err := apiCli.Proxies.DeleteProxy(deleteProxyReq, authToken); err != nil { + return err + } + return nil + }, +} + +func init() { + proxyCreateCmd.Flags().StringVar(&proxyName, "name", "", "Name of the proxy.") + proxyCreateCmd.Flags().StringVar(&proxyDescription, "description", "", "Proxy description.") + proxyCreateCmd.Flags().StringVar(&proxyHTTPProxy, "http-proxy", "", "Proxy URL for plain HTTP requests.") + proxyCreateCmd.Flags().StringVar(&proxyHTTPSProxy, "https-proxy", "", "Proxy URL for HTTPS requests.") + proxyCreateCmd.Flags().StringVar(&proxyNoProxy, "no-proxy", "", "Comma separated list of hosts, domains or CIDRs that bypass the proxy.") + proxyCreateCmd.Flags().StringVar(&proxyUsername, "username", "", "Username used to authenticate to the proxy.") + proxyCreateCmd.Flags().StringVar(&proxyPassword, "password", "", "Password used to authenticate to the proxy.") + proxyCreateCmd.MarkFlagRequired("name") //nolint + + proxyUpdateCmd.Flags().StringVar(&proxyName, "name", "", "Name of the proxy.") + proxyUpdateCmd.Flags().StringVar(&proxyDescription, "description", "", "Proxy description.") + proxyUpdateCmd.Flags().StringVar(&proxyHTTPProxy, "http-proxy", "", "Proxy URL for plain HTTP requests.") + proxyUpdateCmd.Flags().StringVar(&proxyHTTPSProxy, "https-proxy", "", "Proxy URL for HTTPS requests.") + proxyUpdateCmd.Flags().StringVar(&proxyNoProxy, "no-proxy", "", "Comma separated list of hosts, domains or CIDRs that bypass the proxy.") + proxyUpdateCmd.Flags().StringVar(&proxyUsername, "username", "", "Username used to authenticate to the proxy. Set to an empty string to clear the credentials.") + proxyUpdateCmd.Flags().StringVar(&proxyPassword, "password", "", "Password used to authenticate to the proxy.") + + proxiesCmd.AddCommand(proxyCreateCmd) + proxiesCmd.AddCommand(proxyListCmd) + proxiesCmd.AddCommand(proxyShowCmd) + proxiesCmd.AddCommand(proxyUpdateCmd) + proxiesCmd.AddCommand(proxyDeleteCmd) + rootCmd.AddCommand(proxiesCmd) +} + +func formatOneProxy(proxy params.Proxy) { + if outputFormat == common.OutputFormatJSON { + printAsJSON(proxy) + return + } + t := table.NewWriter() + header := table.Row{"Field", "Value"} + t.AppendHeader(header) + + t.AppendRow(table.Row{"ID", proxy.ID}) + t.AppendRow(table.Row{"Created At", proxy.CreatedAt}) + t.AppendRow(table.Row{"Updated At", proxy.UpdatedAt}) + t.AppendRow(table.Row{"Name", proxy.Name}) + if proxy.Description != "" { + t.AppendRow(table.Row{"Description", proxy.Description}) + } + if proxy.HTTPProxy != "" { + t.AppendRow(table.Row{"HTTP Proxy", proxy.HTTPProxy}) + } + if proxy.HTTPSProxy != "" { + t.AppendRow(table.Row{"HTTPS Proxy", proxy.HTTPSProxy}) + } + if proxy.NoProxy != "" { + t.AppendRow(table.Row{"No Proxy", proxy.NoProxy}) + } + t.AppendRow(table.Row{"Auth Enabled", proxy.Username != ""}) + if proxy.Username != "" { + t.AppendRow(table.Row{"Username", proxy.Username}) + } + + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: false, WidthMax: 100}, + }) + fmt.Println(t.Render()) +} + +func formatProxyList(proxies params.Proxies) { + if outputFormat == common.OutputFormatJSON { + printAsJSON(proxies) + return + } + t := table.NewWriter() + header := table.Row{"ID", "Name", "Description", "HTTP Proxy", "HTTPS Proxy", "Auth Enabled"} + t.AppendHeader(header) + for _, val := range proxies { + row := table.Row{val.ID, val.Name, val.Description, val.HTTPProxy, val.HTTPSProxy, val.Username != ""} + t.AppendRow(row) + t.AppendSeparator() + } + fmt.Println(t.Render()) +} diff --git a/cmd/garm-cli/cmd/scalesets.go b/cmd/garm-cli/cmd/scalesets.go index dd9158937..f3008d4c2 100644 --- a/cmd/garm-cli/cmd/scalesets.go +++ b/cmd/garm-cli/cmd/scalesets.go @@ -52,6 +52,8 @@ var ( scalesetExtraSpecs string scalesetGitHubRunnerGroup string scaleSetTemplateNameOrID string + scaleSetProxyNameOrID string + scaleSetClearProxy bool scalesetEnableShell bool scalesetLabels string ) @@ -270,6 +272,14 @@ var scaleSetAddCmd = &cobra.Command{ newScaleSetParams.TemplateID = &tmplID } + if cmd.Flags().Changed("proxy") && scaleSetProxyNameOrID != "" { + proxyID, err := resolveProxyAsUint(scaleSetProxyNameOrID) + if err != nil { + return err + } + newScaleSetParams.ProxyID = &proxyID + } + var response scalesetPayloadGetter if cmd.Flags().Changed("repo") { scalesetRepository, err = resolveRepository(scalesetRepository, endpointName) @@ -346,6 +356,17 @@ explicitly remove them using the runner delete command. scaleSetUpdateParams.TemplateID = &tmplID } + if cmd.Flags().Changed("proxy") && scaleSetProxyNameOrID != "" { + proxyID, err := resolveProxyAsUint(scaleSetProxyNameOrID) + if err != nil { + return err + } + scaleSetUpdateParams.ProxyID = &proxyID + } else if scaleSetClearProxy { + var noProxy uint + scaleSetUpdateParams.ProxyID = &noProxy + } + if cmd.Flags().Changed("image") { scaleSetUpdateParams.Image = scalesetImage } @@ -541,6 +562,9 @@ func init() { scaleSetUpdateCmd.Flags().StringVar(&scalesetExtraSpecs, "extra-specs", "", "A valid json which will be passed to the IaaS provider managing the scale set.") scaleSetUpdateCmd.Flags().BoolVar(&scalesetEnableShell, "enable-shell", false, "Enable shell access for runners in this scale set.") scaleSetUpdateCmd.Flags().StringVar(&scaleSetTemplateNameOrID, "runner-install-template", "", "The runner install template name or ID to use for this scale set.") + scaleSetUpdateCmd.Flags().StringVar(&scaleSetProxyNameOrID, "proxy", "", "The proxy name or ID runners in this scale set will use.") + scaleSetUpdateCmd.Flags().BoolVar(&scaleSetClearProxy, "clear-proxy", false, "Remove the proxy from this scale set.") + scaleSetUpdateCmd.MarkFlagsMutuallyExclusive("proxy", "clear-proxy") scaleSetUpdateCmd.MarkFlagsMutuallyExclusive("extra-specs-file", "extra-specs") scaleSetAddCmd.Flags().StringVar(&scalesetProvider, "provider-name", "", "The name of the provider where runners will be created.") @@ -560,6 +584,7 @@ func init() { scaleSetAddCmd.Flags().BoolVar(&scalesetEnableShell, "enable-shell", false, "Enable shell access for runners in this scale set.") scaleSetAddCmd.Flags().StringVar(&endpointName, "endpoint", "", "When using the name of an entity, the endpoint must be specified when multiple entities with the same name exist.") scaleSetAddCmd.Flags().StringVar(&scaleSetTemplateNameOrID, "runner-install-template", "", "The runner install template name or ID to use for this scale set.") + scaleSetAddCmd.Flags().StringVar(&scaleSetProxyNameOrID, "proxy", "", "The proxy name or ID runners in this scale set will use.") scaleSetAddCmd.Flags().StringVar(&scalesetLabels, "labels", "", "A comma separated list of labels to assign to this scale set.") scaleSetAddCmd.MarkFlagRequired("provider-name") //nolint scaleSetAddCmd.MarkFlagRequired("name") //nolint @@ -666,6 +691,9 @@ func formatOneScaleSet(scaleSet params.ScaleSet) { t.AppendRow(table.Row{"Runner Bootstrap Timeout", scaleSet.RunnerBootstrapTimeout}) t.AppendRow(table.Row{"Belongs to", belongsTo}) t.AppendRow(table.Row{"Template", fmt.Sprintf("%s (ID: %d)", scaleSet.TemplateName, scaleSet.TemplateID)}) + if scaleSet.ProxyID != 0 { + t.AppendRow(table.Row{"Proxy", fmt.Sprintf("%s (ID: %d)", scaleSet.ProxyName, scaleSet.ProxyID)}) + } t.AppendRow(table.Row{"Level", level}) t.AppendRow(table.Row{"Enabled", scaleSet.Enabled}) t.AppendRow(table.Row{"Runner Prefix", scaleSet.GetRunnerPrefix()}) diff --git a/cmd/garm-cli/cmd/util.go b/cmd/garm-cli/cmd/util.go index 8f5c27dfe..6e3c0dac9 100644 --- a/cmd/garm-cli/cmd/util.go +++ b/cmd/garm-cli/cmd/util.go @@ -13,6 +13,7 @@ import ( apiClientEnterprises "github.com/cloudbase/garm/client/enterprises" apiClientOrgs "github.com/cloudbase/garm/client/organizations" + apiClientProxies "github.com/cloudbase/garm/client/proxies" apiClientRepos "github.com/cloudbase/garm/client/repositories" apiTemplates "github.com/cloudbase/garm/client/templates" "github.com/cloudbase/garm/params" @@ -47,6 +48,27 @@ func resolveTemplateAsUint(nameOrID string) (uint, error) { return exactMatches[0].ID, nil } +func resolveProxyAsUint(nameOrID string) (uint, error) { + if parsed, err := strconv.ParseUint(nameOrID, 10, 64); err == nil { + if parsed > math.MaxUint { + return 0, fmt.Errorf("ID is too large") + } + return uint(parsed), nil + } + + listProxiesReq := apiClientProxies.NewListProxiesParams() + response, err := apiCli.Proxies.ListProxies(listProxiesReq, authToken) + if err != nil { + return 0, fmt.Errorf("failed to list proxies") + } + for _, val := range response.Payload { + if strings.EqualFold(val.Name, nameOrID) { + return val.ID, nil + } + } + return 0, fmt.Errorf("no such proxy: %s", nameOrID) +} + func resolveTemplate(nameOrID string) (float64, error) { id, err := resolveTemplateAsUint(nameOrID) if err != nil { diff --git a/database/common/mocks/Store.go b/database/common/mocks/Store.go index 33993c5d1..9d91d5e5a 100644 --- a/database/common/mocks/Store.go +++ b/database/common/mocks/Store.go @@ -931,6 +931,63 @@ func (_c *Store_CreateOrganization_Call) RunAndReturn(run func(context.Context, return _c } +// CreateProxy provides a mock function with given fields: ctx, param +func (_m *Store) CreateProxy(ctx context.Context, param params.CreateProxyParams) (params.Proxy, error) { + ret := _m.Called(ctx, param) + + if len(ret) == 0 { + panic("no return value specified for CreateProxy") + } + + var r0 params.Proxy + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, params.CreateProxyParams) (params.Proxy, error)); ok { + return rf(ctx, param) + } + if rf, ok := ret.Get(0).(func(context.Context, params.CreateProxyParams) params.Proxy); ok { + r0 = rf(ctx, param) + } else { + r0 = ret.Get(0).(params.Proxy) + } + + if rf, ok := ret.Get(1).(func(context.Context, params.CreateProxyParams) error); ok { + r1 = rf(ctx, param) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_CreateProxy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateProxy' +type Store_CreateProxy_Call struct { + *mock.Call +} + +// CreateProxy is a helper method to define mock.On call +// - ctx context.Context +// - param params.CreateProxyParams +func (_e *Store_Expecter) CreateProxy(ctx interface{}, param interface{}) *Store_CreateProxy_Call { + return &Store_CreateProxy_Call{Call: _e.mock.On("CreateProxy", ctx, param)} +} + +func (_c *Store_CreateProxy_Call) Run(run func(ctx context.Context, param params.CreateProxyParams)) *Store_CreateProxy_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(params.CreateProxyParams)) + }) + return _c +} + +func (_c *Store_CreateProxy_Call) Return(proxy params.Proxy, err error) *Store_CreateProxy_Call { + _c.Call.Return(proxy, err) + return _c +} + +func (_c *Store_CreateProxy_Call) RunAndReturn(run func(context.Context, params.CreateProxyParams) (params.Proxy, error)) *Store_CreateProxy_Call { + _c.Call.Return(run) + return _c +} + // CreateRepository provides a mock function with given fields: ctx, owner, name, credentials, webhookSecret, poolBalancerType, agentMode func (_m *Store) CreateRepository(ctx context.Context, owner string, name string, credentials params.ForgeCredentials, webhookSecret string, poolBalancerType params.PoolBalancerType, agentMode bool) (params.Repository, error) { ret := _m.Called(ctx, owner, name, credentials, webhookSecret, poolBalancerType, agentMode) @@ -1882,6 +1939,53 @@ func (_c *Store_DeletePoolByID_Call) RunAndReturn(run func(context.Context, stri return _c } +// DeleteProxy provides a mock function with given fields: ctx, id +func (_m *Store) DeleteProxy(ctx context.Context, id uint) error { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for DeleteProxy") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uint) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Store_DeleteProxy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteProxy' +type Store_DeleteProxy_Call struct { + *mock.Call +} + +// DeleteProxy is a helper method to define mock.On call +// - ctx context.Context +// - id uint +func (_e *Store_Expecter) DeleteProxy(ctx interface{}, id interface{}) *Store_DeleteProxy_Call { + return &Store_DeleteProxy_Call{Call: _e.mock.On("DeleteProxy", ctx, id)} +} + +func (_c *Store_DeleteProxy_Call) Run(run func(ctx context.Context, id uint)) *Store_DeleteProxy_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint)) + }) + return _c +} + +func (_c *Store_DeleteProxy_Call) Return(err error) *Store_DeleteProxy_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Store_DeleteProxy_Call) RunAndReturn(run func(context.Context, uint) error) *Store_DeleteProxy_Call { + _c.Call.Return(run) + return _c +} + // DeleteRepository provides a mock function with given fields: ctx, repoID func (_m *Store) DeleteRepository(ctx context.Context, repoID string) error { ret := _m.Called(ctx, repoID) @@ -3232,6 +3336,120 @@ func (_c *Store_GetPoolByID_Call) RunAndReturn(run func(context.Context, string) return _c } +// GetProxy provides a mock function with given fields: ctx, id +func (_m *Store) GetProxy(ctx context.Context, id uint) (params.Proxy, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetProxy") + } + + var r0 params.Proxy + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uint) (params.Proxy, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, uint) params.Proxy); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(params.Proxy) + } + + if rf, ok := ret.Get(1).(func(context.Context, uint) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetProxy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProxy' +type Store_GetProxy_Call struct { + *mock.Call +} + +// GetProxy is a helper method to define mock.On call +// - ctx context.Context +// - id uint +func (_e *Store_Expecter) GetProxy(ctx interface{}, id interface{}) *Store_GetProxy_Call { + return &Store_GetProxy_Call{Call: _e.mock.On("GetProxy", ctx, id)} +} + +func (_c *Store_GetProxy_Call) Run(run func(ctx context.Context, id uint)) *Store_GetProxy_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint)) + }) + return _c +} + +func (_c *Store_GetProxy_Call) Return(_a0 params.Proxy, _a1 error) *Store_GetProxy_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetProxy_Call) RunAndReturn(run func(context.Context, uint) (params.Proxy, error)) *Store_GetProxy_Call { + _c.Call.Return(run) + return _c +} + +// GetProxyByName provides a mock function with given fields: ctx, name +func (_m *Store) GetProxyByName(ctx context.Context, name string) (params.Proxy, error) { + ret := _m.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for GetProxyByName") + } + + var r0 params.Proxy + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (params.Proxy, error)); ok { + return rf(ctx, name) + } + if rf, ok := ret.Get(0).(func(context.Context, string) params.Proxy); ok { + r0 = rf(ctx, name) + } else { + r0 = ret.Get(0).(params.Proxy) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetProxyByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProxyByName' +type Store_GetProxyByName_Call struct { + *mock.Call +} + +// GetProxyByName is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *Store_Expecter) GetProxyByName(ctx interface{}, name interface{}) *Store_GetProxyByName_Call { + return &Store_GetProxyByName_Call{Call: _e.mock.On("GetProxyByName", ctx, name)} +} + +func (_c *Store_GetProxyByName_Call) Run(run func(ctx context.Context, name string)) *Store_GetProxyByName_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Store_GetProxyByName_Call) Return(_a0 params.Proxy, _a1 error) *Store_GetProxyByName_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetProxyByName_Call) RunAndReturn(run func(context.Context, string) (params.Proxy, error)) *Store_GetProxyByName_Call { + _c.Call.Return(run) + return _c +} + // GetRepository provides a mock function with given fields: ctx, owner, name, endpointName func (_m *Store) GetRepository(ctx context.Context, owner string, name string, endpointName string) (params.Repository, error) { ret := _m.Called(ctx, owner, name, endpointName) @@ -4845,6 +5063,64 @@ func (_c *Store_ListPoolInstances_Call) RunAndReturn(run func(context.Context, s return _c } +// ListProxies provides a mock function with given fields: ctx +func (_m *Store) ListProxies(ctx context.Context) ([]params.Proxy, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ListProxies") + } + + var r0 []params.Proxy + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) ([]params.Proxy, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) []params.Proxy); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]params.Proxy) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_ListProxies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListProxies' +type Store_ListProxies_Call struct { + *mock.Call +} + +// ListProxies is a helper method to define mock.On call +// - ctx context.Context +func (_e *Store_Expecter) ListProxies(ctx interface{}) *Store_ListProxies_Call { + return &Store_ListProxies_Call{Call: _e.mock.On("ListProxies", ctx)} +} + +func (_c *Store_ListProxies_Call) Run(run func(ctx context.Context)) *Store_ListProxies_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *Store_ListProxies_Call) Return(_a0 []params.Proxy, _a1 error) *Store_ListProxies_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_ListProxies_Call) RunAndReturn(run func(context.Context) ([]params.Proxy, error)) *Store_ListProxies_Call { + _c.Call.Return(run) + return _c +} + // ListRepositories provides a mock function with given fields: ctx, filter func (_m *Store) ListRepositories(ctx context.Context, filter params.RepositoryFilter) ([]params.Repository, error) { ret := _m.Called(ctx, filter) @@ -6184,6 +6460,64 @@ func (_c *Store_UpdateOrganization_Call) RunAndReturn(run func(context.Context, return _c } +// UpdateProxy provides a mock function with given fields: ctx, id, param +func (_m *Store) UpdateProxy(ctx context.Context, id uint, param params.UpdateProxyParams) (params.Proxy, error) { + ret := _m.Called(ctx, id, param) + + if len(ret) == 0 { + panic("no return value specified for UpdateProxy") + } + + var r0 params.Proxy + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uint, params.UpdateProxyParams) (params.Proxy, error)); ok { + return rf(ctx, id, param) + } + if rf, ok := ret.Get(0).(func(context.Context, uint, params.UpdateProxyParams) params.Proxy); ok { + r0 = rf(ctx, id, param) + } else { + r0 = ret.Get(0).(params.Proxy) + } + + if rf, ok := ret.Get(1).(func(context.Context, uint, params.UpdateProxyParams) error); ok { + r1 = rf(ctx, id, param) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_UpdateProxy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateProxy' +type Store_UpdateProxy_Call struct { + *mock.Call +} + +// UpdateProxy is a helper method to define mock.On call +// - ctx context.Context +// - id uint +// - param params.UpdateProxyParams +func (_e *Store_Expecter) UpdateProxy(ctx interface{}, id interface{}, param interface{}) *Store_UpdateProxy_Call { + return &Store_UpdateProxy_Call{Call: _e.mock.On("UpdateProxy", ctx, id, param)} +} + +func (_c *Store_UpdateProxy_Call) Run(run func(ctx context.Context, id uint, param params.UpdateProxyParams)) *Store_UpdateProxy_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint), args[2].(params.UpdateProxyParams)) + }) + return _c +} + +func (_c *Store_UpdateProxy_Call) Return(proxy params.Proxy, err error) *Store_UpdateProxy_Call { + _c.Call.Return(proxy, err) + return _c +} + +func (_c *Store_UpdateProxy_Call) RunAndReturn(run func(context.Context, uint, params.UpdateProxyParams) (params.Proxy, error)) *Store_UpdateProxy_Call { + _c.Call.Return(run) + return _c +} + // UpdateRepository provides a mock function with given fields: ctx, repoID, param func (_m *Store) UpdateRepository(ctx context.Context, repoID string, param params.UpdateEntityParams) (params.Repository, error) { ret := _m.Called(ctx, repoID, param) diff --git a/database/common/store.go b/database/common/store.go index 97497f4ee..49e17873a 100644 --- a/database/common/store.go +++ b/database/common/store.go @@ -200,6 +200,15 @@ type TemplateStore interface { DeleteTemplate(ctx context.Context, id uint) (err error) } +type ProxyStore interface { + ListProxies(ctx context.Context) ([]params.Proxy, error) + CreateProxy(ctx context.Context, param params.CreateProxyParams) (proxy params.Proxy, err error) + GetProxy(ctx context.Context, id uint) (params.Proxy, error) + GetProxyByName(ctx context.Context, name string) (params.Proxy, error) + UpdateProxy(ctx context.Context, id uint, param params.UpdateProxyParams) (proxy params.Proxy, err error) + DeleteProxy(ctx context.Context, id uint) (err error) +} + type FileObjectStore interface { ListFileObjects(ctx context.Context, page, pageSize uint64) (params.FileObjectPaginatedResponse, error) SearchFileObjectByTags(ctx context.Context, tags []string, page, pageSize uint64) (params.FileObjectPaginatedResponse, error) @@ -230,6 +239,7 @@ type Store interface { GiteaEndpointStore GiteaCredentialsStore TemplateStore + ProxyStore FileObjectStore ControllerInfo() (params.ControllerInfo, error) diff --git a/database/common/watcher.go b/database/common/watcher.go index d5fe15ef5..001daa331 100644 --- a/database/common/watcher.go +++ b/database/common/watcher.go @@ -38,6 +38,7 @@ const ( TemplateEntityType DatabaseEntityType = "template" FileObjectEntityType DatabaseEntityType = "file_object" ForgeInstanceEntityType DatabaseEntityType = "forge_instance" + ProxyEntityType DatabaseEntityType = "proxy" ) const ( diff --git a/database/sql/migrations/0006_proxies.go b/database/sql/migrations/0006_proxies.go new file mode 100644 index 000000000..0dba0cdac --- /dev/null +++ b/database/sql/migrations/0006_proxies.go @@ -0,0 +1,66 @@ +// Copyright 2026 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 migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// Minimal model copies for the migration. These are intentionally decoupled +// from the main models so that future model changes don't break this migration. + +type proxy0006 struct { + gorm.Model + + Name string `gorm:"index:idx_proxy_name,unique,expression:LOWER(name);type:varchar(64)"` + Description string `gorm:"type:text"` + + HTTPProxy string `gorm:"column:http_proxy;type:text"` + HTTPSProxy string `gorm:"column:https_proxy;type:text"` + NoProxy string `gorm:"column:no_proxy;type:text"` + + Credentials []byte +} + +func (proxy0006) TableName() string { return "proxies" } + +// pool0006 and scaleSet0006 are minimal stubs that only declare the new +// column. AutoMigrate will add the column without touching existing ones. + +type pool0006 struct { + ProxyID *uint `gorm:"index"` +} + +func (pool0006) TableName() string { return "pools" } + +type scaleSet0006 struct { + ProxyID *uint `gorm:"index"` +} + +func (scaleSet0006) TableName() string { return "scale_sets" } + +func init() { + Register(&gormigrate.Migration{ + ID: "0006_proxies", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + &proxy0006{}, + &pool0006{}, + &scaleSet0006{}, + ) + }, + }) +} diff --git a/database/sql/migrations/smoke0006_test.go b/database/sql/migrations/smoke0006_test.go new file mode 100644 index 000000000..33800d814 --- /dev/null +++ b/database/sql/migrations/smoke0006_test.go @@ -0,0 +1,65 @@ +package migrations + +import ( + "testing" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// Verify 0006 applies cleanly on an existing database that already has +// pools and scale_sets tables without the proxy_id column. Fresh databases +// take the initSchema path, so this is the only coverage for the +// gormigrate upgrade path. +func TestProxyMigrationOnExistingDB(t *testing.T) { + db, err := gorm.Open(sqlite.Open(t.TempDir()+"/test.db"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + + if err := db.Exec("CREATE TABLE pools (id text PRIMARY KEY, provider_name text)").Error; err != nil { + t.Fatalf("failed to create pools: %v", err) + } + if err := db.Exec("CREATE TABLE scale_sets (id integer PRIMARY KEY, name text)").Error; err != nil { + t.Fatalf("failed to create scale_sets: %v", err) + } + + var target []*gormigrate.Migration + for _, m := range All() { + if m.ID == "0006_proxies" { + target = append(target, m) + } + } + if len(target) != 1 { + t.Fatalf("expected to find 0006_proxies migration, got %d", len(target)) + } + + m := gormigrate.New(db, gormigrate.DefaultOptions, target) + if err := m.Migrate(); err != nil { + t.Fatalf("migration failed: %v", err) + } + + if !db.Migrator().HasTable("proxies") { + t.Fatal("proxies table was not created") + } + for _, col := range []string{"name", "http_proxy", "https_proxy", "no_proxy", "credentials"} { + if !db.Migrator().HasColumn(&proxy0006{}, col) { + t.Fatalf("proxies table is missing column %q", col) + } + } + if !db.Migrator().HasColumn(&pool0006{}, "proxy_id") { + t.Fatal("pools table is missing proxy_id") + } + if !db.Migrator().HasColumn(&scaleSet0006{}, "proxy_id") { + t.Fatal("scale_sets table is missing proxy_id") + } + + // unique name index must reject duplicate names case-insensitively + if err := db.Exec("INSERT INTO proxies (name, http_proxy) VALUES ('airgap', 'http://proxy:3128')").Error; err != nil { + t.Fatalf("failed to insert proxy: %v", err) + } + if err := db.Exec("INSERT INTO proxies (name, http_proxy) VALUES ('AIRGAP', 'http://proxy:3128')").Error; err == nil { + t.Fatal("duplicate proxy name was not rejected") + } +} diff --git a/database/sql/models.go b/database/sql/models.go index c496144a7..2dec823e5 100644 --- a/database/sql/models.go +++ b/database/sql/models.go @@ -115,6 +115,27 @@ type Template struct { Pools []Pool `gorm:"foreignKey:TemplateID"` } +// Proxy holds a named proxy definition. Pools and scale sets may reference +// a proxy, in which case runners created for them will be configured to use +// the proxy settings defined here. +type Proxy struct { + gorm.Model + + Name string `gorm:"index:idx_proxy_name,unique,expression:LOWER(name);type:varchar(64)"` + Description string `gorm:"type:text"` + + HTTPProxy string `gorm:"column:http_proxy;type:text"` + HTTPSProxy string `gorm:"column:https_proxy;type:text"` + NoProxy string `gorm:"column:no_proxy;type:text"` + + // Credentials holds the sealed proxy credentials (username/password), + // if any were set. + Credentials []byte + + Pools []Pool `gorm:"foreignKey:ProxyID"` + ScaleSets []ScaleSet `gorm:"foreignKey:ProxyID"` +} + type Pool struct { Base @@ -159,6 +180,9 @@ type Pool struct { TemplateID *uint `gorm:"index"` Template Template `gorm:"foreignKey:TemplateID"` + ProxyID *uint `gorm:"index"` + Proxy Proxy `gorm:"foreignKey:ProxyID"` + Instances []Instance `gorm:"foreignKey:PoolID"` Priority uint `gorm:"index:idx_pool_priority"` } @@ -220,6 +244,9 @@ type ScaleSet struct { TemplateID *uint `gorm:"index"` Template Template `gorm:"foreignKey:TemplateID"` + ProxyID *uint `gorm:"index"` + Proxy Proxy `gorm:"foreignKey:ProxyID"` + Tags []*Tag `gorm:"many2many:scaleset_tags;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"` Instances []Instance `gorm:"foreignKey:ScaleSetFkID"` } diff --git a/database/sql/pools.go b/database/sql/pools.go index f99ab4f6b..32b0bb3ab 100644 --- a/database/sql/pools.go +++ b/database/sql/pools.go @@ -80,6 +80,7 @@ func (s *sqlDatabase) GetPoolByID(_ context.Context, poolID string) (params.Pool "ForgeInstance", "ForgeInstance.Endpoint", "Template", + "Proxy", } pool, err := s.getPoolByID(s.conn, poolID, preloadList...) if err != nil { @@ -299,6 +300,10 @@ func (s *sqlDatabase) CreateEntityPool(ctx context.Context, entity params.ForgeE } }() + if param.ProxyID != nil && *param.ProxyID == 0 { + param.ProxyID = nil + } + newPool := Pool{ ProviderName: param.ProviderName, MaxRunners: param.MaxRunners, @@ -313,6 +318,7 @@ func (s *sqlDatabase) CreateEntityPool(ctx context.Context, entity params.ForgeE GitHubRunnerGroup: param.GitHubRunnerGroup, Priority: param.Priority, TemplateID: param.TemplateID, + ProxyID: param.ProxyID, EnableShell: param.EnableShell, } if len(param.ExtraSpecs) > 0 { @@ -339,6 +345,12 @@ func (s *sqlDatabase) CreateEntityPool(ctx context.Context, entity params.ForgeE return fmt.Errorf("error checking entity existence: %w", err) } + if param.ProxyID != nil && *param.ProxyID != 0 { + if err := s.hasProxy(tx, *param.ProxyID); err != nil { + return fmt.Errorf("error checking pool proxy: %w", err) + } + } + var tags []*Tag for _, val := range param.Tags { t, err := s.getOrCreateTag(tx, val) @@ -381,6 +393,7 @@ func (s *sqlDatabase) GetEntityPool(_ context.Context, entity params.ForgeEntity "ForgeInstance", "ForgeInstance.Endpoint", "Template", + "Proxy", } pool, err := s.getEntityPool(s.conn, entity.EntityType, entity.ID, poolID, preloadList...) if err != nil { diff --git a/database/sql/pools_test.go b/database/sql/pools_test.go index 2fb77f210..c120645ac 100644 --- a/database/sql/pools_test.go +++ b/database/sql/pools_test.go @@ -144,7 +144,7 @@ func (s *PoolsTestSuite) TestListAllPools() { func (s *PoolsTestSuite) TestListAllPoolsDBFetchErr() { s.Fixtures.SQLMock. - ExpectQuery(regexp.QuoteMeta("SELECT `pools`.`id`,`pools`.`created_at`,`pools`.`updated_at`,`pools`.`deleted_at`,`pools`.`provider_name`,`pools`.`runner_prefix`,`pools`.`max_runners`,`pools`.`min_idle_runners`,`pools`.`runner_bootstrap_timeout`,`pools`.`image`,`pools`.`flavor`,`pools`.`os_type`,`pools`.`os_arch`,`pools`.`enabled`,`pools`.`git_hub_runner_group`,`pools`.`enable_shell`,`pools`.`generation`,`pools`.`repo_id`,`pools`.`org_id`,`pools`.`enterprise_id`,`pools`.`forge_instance_id`,`pools`.`template_id`,`pools`.`priority` FROM `pools` WHERE `pools`.`deleted_at` IS NULL")). + ExpectQuery(regexp.QuoteMeta("SELECT `pools`.`id`,`pools`.`created_at`,`pools`.`updated_at`,`pools`.`deleted_at`,`pools`.`provider_name`,`pools`.`runner_prefix`,`pools`.`max_runners`,`pools`.`min_idle_runners`,`pools`.`runner_bootstrap_timeout`,`pools`.`image`,`pools`.`flavor`,`pools`.`os_type`,`pools`.`os_arch`,`pools`.`enabled`,`pools`.`git_hub_runner_group`,`pools`.`enable_shell`,`pools`.`generation`,`pools`.`repo_id`,`pools`.`org_id`,`pools`.`enterprise_id`,`pools`.`forge_instance_id`,`pools`.`template_id`,`pools`.`proxy_id`,`pools`.`priority` FROM `pools` WHERE `pools`.`deleted_at` IS NULL")). WillReturnError(fmt.Errorf("mocked fetching all pools error")) _, err := s.StoreSQLMocked.ListAllPools(s.adminCtx) diff --git a/database/sql/proxies.go b/database/sql/proxies.go new file mode 100644 index 000000000..80ec79b2a --- /dev/null +++ b/database/sql/proxies.go @@ -0,0 +1,340 @@ +// Copyright 2026 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 sql + +import ( + "context" + "errors" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + runnerErrors "github.com/cloudbase/garm-provider-common/errors" + "github.com/cloudbase/garm/database/common" + "github.com/cloudbase/garm/params" +) + +// proxyCredentials is the shape of the sealed credentials blob stored in +// the proxies table. +type proxyCredentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (s *sqlDatabase) sqlToParamProxy(proxy Proxy) (params.Proxy, error) { + ret := params.Proxy{ + ID: proxy.ID, + CreatedAt: proxy.CreatedAt, + UpdatedAt: proxy.UpdatedAt, + Name: proxy.Name, + Description: proxy.Description, + HTTPProxy: proxy.HTTPProxy, + HTTPSProxy: proxy.HTTPSProxy, + NoProxy: proxy.NoProxy, + } + + if len(proxy.Credentials) > 0 { + var creds proxyCredentials + if err := s.unsealAndUnmarshal(proxy.Credentials, &creds); err != nil { + return params.Proxy{}, fmt.Errorf("failed to unseal proxy credentials: %w", err) + } + ret.Username = creds.Username + ret.Password = creds.Password + } + + return ret, nil +} + +// sealProxyCredentials seals the given username and password. If the +// username is empty, credentials are considered unset and nil is returned. +func (s *sqlDatabase) sealProxyCredentials(username, password string) ([]byte, error) { + if username == "" { + return nil, nil + } + sealed, err := s.marshalAndSeal(proxyCredentials{ + Username: username, + Password: password, + }) + if err != nil { + return nil, fmt.Errorf("failed to seal proxy credentials: %w", err) + } + return sealed, nil +} + +// hasProxy checks that a proxy with the given ID exists. It is used when +// pools or scale sets are created or updated with a proxy reference. +func (s *sqlDatabase) hasProxy(tx *gorm.DB, id uint) error { + var count int64 + if err := tx.Model(&Proxy{}).Where("id = ?", id).Count(&count).Error; err != nil { + return fmt.Errorf("error checking proxy existence: %w", err) + } + if count == 0 { + return runnerErrors.NewBadRequestError("proxy with ID %d does not exist", id) + } + return nil +} + +func (s *sqlDatabase) ListProxies(_ context.Context) ([]params.Proxy, error) { + var proxies []Proxy + q := s.conn.Model(&Proxy{}).Find(&proxies) + if q.Error != nil { + return nil, fmt.Errorf("failed to list proxies: %w", q.Error) + } + + ret := make([]params.Proxy, len(proxies)) + for idx, proxy := range proxies { + converted, err := s.sqlToParamProxy(proxy) + if err != nil { + return nil, fmt.Errorf("failed to convert proxy: %w", err) + } + ret[idx] = converted + } + return ret, nil +} + +func (s *sqlDatabase) getProxy(tx *gorm.DB, id uint, preload ...string) (Proxy, error) { + var proxy Proxy + q := tx.Model(&Proxy{}).Where("id = ?", id) + for _, item := range preload { + q = q.Preload(item) + } + + q = q.First(&proxy) + if q.Error != nil { + if errors.Is(q.Error, gorm.ErrRecordNotFound) { + return Proxy{}, runnerErrors.ErrNotFound + } + return Proxy{}, fmt.Errorf("failed to get proxy: %w", q.Error) + } + return proxy, nil +} + +func (s *sqlDatabase) GetProxy(_ context.Context, id uint) (params.Proxy, error) { + proxy, err := s.getProxy(s.conn, id) + if err != nil { + return params.Proxy{}, fmt.Errorf("failed to get proxy: %w", err) + } + + ret, err := s.sqlToParamProxy(proxy) + if err != nil { + return params.Proxy{}, fmt.Errorf("failed to convert proxy: %w", err) + } + return ret, nil +} + +func (s *sqlDatabase) GetProxyByName(_ context.Context, name string) (params.Proxy, error) { + var proxy Proxy + q := s.conn.Model(&Proxy{}).Where("LOWER(name) = LOWER(?)", name).First(&proxy) + if q.Error != nil { + if errors.Is(q.Error, gorm.ErrRecordNotFound) { + return params.Proxy{}, runnerErrors.ErrNotFound + } + return params.Proxy{}, fmt.Errorf("failed to get proxy: %w", q.Error) + } + + ret, err := s.sqlToParamProxy(proxy) + if err != nil { + return params.Proxy{}, fmt.Errorf("failed to convert proxy: %w", err) + } + return ret, nil +} + +func (s *sqlDatabase) CreateProxy(_ context.Context, param params.CreateProxyParams) (proxy params.Proxy, err error) { + defer func() { + if err == nil { + s.sendNotify(common.ProxyEntityType, common.CreateOperation, proxy) + } + }() + if err := param.Validate(); err != nil { + return params.Proxy{}, fmt.Errorf("failed to validate create params: %w", err) + } + + sealed, err := s.sealProxyCredentials(param.Username, param.Password) + if err != nil { + return params.Proxy{}, fmt.Errorf("error sealing proxy credentials: %w", err) + } + + newProxy := Proxy{ + Name: param.Name, + Description: param.Description, + HTTPProxy: param.HTTPProxy, + HTTPSProxy: param.HTTPSProxy, + NoProxy: param.NoProxy, + Credentials: sealed, + } + + if err := s.conn.Create(&newProxy).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return params.Proxy{}, runnerErrors.NewConflictError("a proxy already exists with the specified name") + } + return params.Proxy{}, fmt.Errorf("error creating proxy: %w", err) + } + + proxy, err = s.sqlToParamProxy(newProxy) + if err != nil { + return params.Proxy{}, fmt.Errorf("failed to convert proxy: %w", err) + } + return proxy, nil +} + +// updateProxyCredentials applies the username/password changes from the +// update params to the sealed credentials blob of the given proxy. Clearing +// the username clears the credentials entirely. +func (s *sqlDatabase) updateProxyCredentials(dbProxy *Proxy, param params.UpdateProxyParams) (bool, error) { + if param.Username == nil && param.Password == nil { + return false, nil + } + + var creds proxyCredentials + if len(dbProxy.Credentials) > 0 { + if err := s.unsealAndUnmarshal(dbProxy.Credentials, &creds); err != nil { + return false, fmt.Errorf("failed to unseal proxy credentials: %w", err) + } + } + + if param.Username != nil { + creds.Username = *param.Username + } + if param.Password != nil { + creds.Password = *param.Password + } + + if creds.Username == "" && creds.Password != "" { + return false, runnerErrors.NewBadRequestError("password cannot be set without a username") + } + + sealed, err := s.sealProxyCredentials(creds.Username, creds.Password) + if err != nil { + return false, fmt.Errorf("error sealing proxy credentials: %w", err) + } + dbProxy.Credentials = sealed + return true, nil +} + +func (s *sqlDatabase) UpdateProxy(_ context.Context, id uint, param params.UpdateProxyParams) (proxy params.Proxy, err error) { + var hasChange bool + defer func() { + if err == nil && hasChange { + s.sendNotify(common.ProxyEntityType, common.UpdateOperation, proxy) + } + }() + if err := param.Validate(); err != nil { + return params.Proxy{}, fmt.Errorf("failed to validate update params: %w", err) + } + + err = s.conn.Transaction(func(tx *gorm.DB) error { + dbProxy, err := s.getProxy(tx.Clauses(clause.Locking{Strength: "UPDATE"}), id) + if err != nil { + return fmt.Errorf("failed to get proxy: %w", err) + } + + if param.Name != nil && *param.Name != dbProxy.Name { + hasChange = true + dbProxy.Name = *param.Name + } + + if param.Description != nil && *param.Description != dbProxy.Description { + hasChange = true + dbProxy.Description = *param.Description + } + + if param.HTTPProxy != nil && *param.HTTPProxy != dbProxy.HTTPProxy { + hasChange = true + dbProxy.HTTPProxy = *param.HTTPProxy + } + + if param.HTTPSProxy != nil && *param.HTTPSProxy != dbProxy.HTTPSProxy { + hasChange = true + dbProxy.HTTPSProxy = *param.HTTPSProxy + } + + if dbProxy.HTTPProxy == "" && dbProxy.HTTPSProxy == "" { + return runnerErrors.NewBadRequestError("at least one of http_proxy or https_proxy must be set") + } + + if param.NoProxy != nil && *param.NoProxy != dbProxy.NoProxy { + hasChange = true + dbProxy.NoProxy = *param.NoProxy + } + + credsChanged, err := s.updateProxyCredentials(&dbProxy, param) + if err != nil { + return fmt.Errorf("error updating proxy credentials: %w", err) + } + if credsChanged { + hasChange = true + } + + if !hasChange { + proxy, err = s.sqlToParamProxy(dbProxy) + if err != nil { + return fmt.Errorf("failed to convert proxy: %w", err) + } + return nil + } + + if q := tx.Save(&dbProxy); q.Error != nil { + if errors.Is(q.Error, gorm.ErrDuplicatedKey) { + return runnerErrors.NewConflictError("a proxy already exists with the specified name") + } + return fmt.Errorf("failed to save proxy: %w", q.Error) + } + + proxy, err = s.sqlToParamProxy(dbProxy) + if err != nil { + return fmt.Errorf("failed to convert proxy: %w", err) + } + return nil + }) + if err != nil { + return params.Proxy{}, fmt.Errorf("failed to update proxy: %w", err) + } + return proxy, nil +} + +func (s *sqlDatabase) DeleteProxy(_ context.Context, id uint) (err error) { + var proxy params.Proxy + + defer func() { + if err == nil { + s.sendNotify(common.ProxyEntityType, common.DeleteOperation, proxy) + } + }() + err = s.conn.Transaction(func(tx *gorm.DB) error { + dbProxy, err := s.getProxy(tx.Clauses(clause.Locking{Strength: "UPDATE"}), id, "Pools", "ScaleSets") + if err != nil { + return fmt.Errorf("failed to get proxy: %w", err) + } + + if len(dbProxy.Pools) > 0 || len(dbProxy.ScaleSets) > 0 { + return runnerErrors.NewBadRequestError("cannot delete proxy while in use by pools or scale sets") + } + + proxy = params.Proxy{ + ID: dbProxy.ID, + Name: dbProxy.Name, + } + + if q := tx.Unscoped().Delete(&dbProxy); q.Error != nil { + return fmt.Errorf("failed to delete proxy: %w", q.Error) + } + return nil + }) + if err != nil { + return fmt.Errorf("failed to delete proxy: %w", err) + } + return nil +} diff --git a/database/sql/scalesets.go b/database/sql/scalesets.go index d1c6f964c..0115bc6b0 100644 --- a/database/sql/scalesets.go +++ b/database/sql/scalesets.go @@ -69,6 +69,10 @@ func (s *sqlDatabase) CreateEntityScaleSet(ctx context.Context, entity params.Fo } }() + if param.ProxyID != nil && *param.ProxyID == 0 { + param.ProxyID = nil + } + newScaleSet := ScaleSet{ Name: param.Name, ScaleSetID: param.ScaleSetID, @@ -86,6 +90,7 @@ func (s *sqlDatabase) CreateEntityScaleSet(ctx context.Context, entity params.Fo GitHubRunnerGroup: param.GitHubRunnerGroup, State: params.ScaleSetPendingCreate, TemplateID: param.TemplateID, + ProxyID: param.ProxyID, EnableShell: param.EnableShell, } @@ -111,6 +116,12 @@ func (s *sqlDatabase) CreateEntityScaleSet(ctx context.Context, entity params.Fo return fmt.Errorf("error checking entity existence: %w", err) } + if param.ProxyID != nil && *param.ProxyID != 0 { + if err := s.hasProxy(tx, *param.ProxyID); err != nil { + return fmt.Errorf("error checking scale set proxy: %w", err) + } + } + q := tx.Create(&newScaleSet) if q.Error != nil { return fmt.Errorf("error creating scale set: %w", q.Error) @@ -325,6 +336,21 @@ func (s *sqlDatabase) updateScaleSet(tx *gorm.DB, scaleSet ScaleSet, param param updates["template_id"] = param.TemplateID } + if param.ProxyID != nil && (scaleSet.ProxyID == nil || *param.ProxyID != *scaleSet.ProxyID) { + if *param.ProxyID == 0 { + if scaleSet.ProxyID != nil { + updates["proxy_id"] = nil + incrementGeneration = true + } + } else { + if err := s.hasProxy(tx, *param.ProxyID); err != nil { + return params.ScaleSet{}, 0, fmt.Errorf("error checking scale set proxy: %w", err) + } + updates["proxy_id"] = *param.ProxyID + incrementGeneration = true + } + } + if param.EnableShell != nil && *param.EnableShell != scaleSet.EnableShell { updates["enable_shell"] = *param.EnableShell incrementGeneration = true @@ -386,7 +412,7 @@ func (s *sqlDatabase) updateScaleSet(tx *gorm.DB, scaleSet ScaleSet, param param var rowsAffected int64 if len(updates) > 0 { - q := tx.Model(&scaleSet).Omit("Repository", "Organization", "Enterprise", "Instances", "Template", "Tags").Updates(updates) + q := tx.Model(&scaleSet).Omit("Repository", "Organization", "Enterprise", "Instances", "Template", "Proxy", "Tags").Updates(updates) if q.Error != nil { return params.ScaleSet{}, 0, fmt.Errorf("error saving database entry: %w", q.Error) } @@ -417,6 +443,7 @@ func (s *sqlDatabase) GetScaleSetByID(_ context.Context, scaleSet uint) (params. "Repository", "Repository.Endpoint", "Template", + "Proxy", "Tags", ) if err != nil { diff --git a/database/sql/sql.go b/database/sql/sql.go index 347a80b5b..e3e5d8fc1 100644 --- a/database/sql/sql.go +++ b/database/sql/sql.go @@ -373,6 +373,7 @@ func (s *sqlDatabase) initSchema(tx *gorm.DB) error { &GiteaCredentials{}, &Tag{}, &Template{}, + &Proxy{}, &Pool{}, &Repository{}, &Organization{}, diff --git a/database/sql/util.go b/database/sql/util.go index 6d5572cb7..0432519c8 100644 --- a/database/sql/util.go +++ b/database/sql/util.go @@ -395,6 +395,11 @@ func (s *sqlDatabase) sqlToCommonPool(pool Pool) (params.Pool, error) { ret.TemplateName = pool.Template.Name } + if pool.ProxyID != nil && *pool.ProxyID != 0 { + ret.ProxyID = *pool.ProxyID + ret.ProxyName = pool.Proxy.Name + } + var ep GithubEndpoint if pool.RepoID != nil { ret.RepoID = pool.RepoID.String() @@ -479,6 +484,11 @@ func (s *sqlDatabase) sqlToCommonScaleSet(scaleSet ScaleSet) (params.ScaleSet, e ret.TemplateName = scaleSet.Template.Name } + if scaleSet.ProxyID != nil && *scaleSet.ProxyID != 0 { + ret.ProxyID = *scaleSet.ProxyID + ret.ProxyName = scaleSet.Proxy.Name + } + var ep GithubEndpoint if scaleSet.RepoID != nil { ret.RepoID = scaleSet.RepoID.String() @@ -687,6 +697,21 @@ func (s *sqlDatabase) updatePool(tx *gorm.DB, pool Pool, param params.UpdatePool updates["template_id"] = param.TemplateID } + if param.ProxyID != nil && (pool.ProxyID == nil || *param.ProxyID != *pool.ProxyID) { + if *param.ProxyID == 0 { + if pool.ProxyID != nil { + updates["proxy_id"] = nil + incrementGeneration = true + } + } else { + if err := s.hasProxy(tx, *param.ProxyID); err != nil { + return params.Pool{}, 0, fmt.Errorf("error checking pool proxy: %w", err) + } + updates["proxy_id"] = *param.ProxyID + incrementGeneration = true + } + } + if param.MinIdleRunners != nil && *param.MinIdleRunners != pool.MinIdleRunners { updates["min_idle_runners"] = *param.MinIdleRunners } diff --git a/internal/templates/templates.go b/internal/templates/templates.go index da59dad5d..9535f58d0 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -30,6 +30,16 @@ type WrapperContext struct { CallbackURL string MetadataURL string CACertBundle string + + // HTTPProxy, HTTPSProxy and NoProxy hold the final proxy URLs (with + // credentials embedded, if any) and the proxy bypass list. The wrappers + // export these as environment variables. The install scripts and the + // runner itself inherit them. On Windows, where PowerShell 5.1 ignores + // proxy environment variables, the wrapper also sets the .NET default + // web proxy from these values. + HTTPProxy string + HTTPSProxy string + NoProxy string } // InstallContext wraps the vendored InstallRunnerParams with agent-specific @@ -45,6 +55,15 @@ type InstallContext struct { // ForceInsecureGARMAgent allows the agent to connect back to GARM even when // TLS is not enabled in GARM itself. ForceInsecureGARMAgent bool + + // HTTPProxy, HTTPSProxy and NoProxy hold the final proxy URLs (with + // credentials embedded, if any) and the proxy bypass list. Templates + // render these into the garm-agent config. Note that garm-agent + // versions older than v0.1.1 do not know about the proxy section, so + // templates must only emit it when a proxy is actually set. + HTTPProxy string + HTTPSProxy string + NoProxy string } func GetTemplateContent(osType commonParams.OSType, forge params.EndpointType) ([]byte, error) { @@ -144,7 +163,7 @@ func RenderRunnerInstallScript(tpl string, context any) ([]byte, error) { return []byte(data), nil } -func RenderRunnerInstallWrapper(ctx context.Context, osType commonParams.OSType, metadataURL, callbackURL, token string) ([]byte, error) { +func RenderRunnerInstallWrapper(ctx context.Context, osType commonParams.OSType, metadataURL, callbackURL, token string, proxyConfig commonParams.ProxyConfig) ([]byte, error) { tmpl, err := template.ParseFS(Userdata, "userdata/*_wrapper.tmpl") if err != nil { return nil, fmt.Errorf("failed to parse templates: %w", err) @@ -154,6 +173,9 @@ func RenderRunnerInstallWrapper(ctx context.Context, osType commonParams.OSType, MetadataURL: metadataURL, CallbackURL: callbackURL, CallbackToken: token, + HTTPProxy: proxyConfig.HTTPProxy, + HTTPSProxy: proxyConfig.HTTPSProxy, + NoProxy: proxyConfig.NoProxy, } controllerInfo := cache.ControllerInfo() diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go index 1a125e8dd..e8fad8dfa 100644 --- a/internal/templates/templates_test.go +++ b/internal/templates/templates_test.go @@ -15,6 +15,7 @@ package templates import ( + "context" "fmt" "strings" "testing" @@ -27,6 +28,121 @@ import ( // template and checks that the agent config only carries force_insecure when // the controller allows insecure agent connections. Agents older than v0.1.1 // do not know the setting, so it must be absent unless explicitly enabled. +// TestWrapperRendersProxySettings renders the install wrappers with and +// without a proxy config and checks that proxy environment variables only +// show up when a proxy is set. +func TestWrapperRendersProxySettings(t *testing.T) { + ctx := context.Background() + proxyCfg := commonParams.ProxyConfig{ + HTTPProxy: "http://user:pass@proxy.example.com:3128", + HTTPSProxy: "http://user:pass@proxy.example.com:3128", + NoProxy: "localhost,.internal.example.com,10.0.0.0/8", + } + + for _, osType := range []commonParams.OSType{commonParams.Linux, commonParams.Windows} { + t.Run(string(osType), func(t *testing.T) { + rendered, err := RenderRunnerInstallWrapper(ctx, osType, "https://garm.example.com/api/v1/metadata", "https://garm.example.com/api/v1/callbacks", "token", commonParams.ProxyConfig{}) + if err != nil { + t.Fatalf("failed to render wrapper: %v", err) + } + for _, needle := range []string{"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "DefaultWebProxy"} { + if strings.Contains(string(rendered), needle) { + t.Errorf("expected %q to be absent when no proxy is set", needle) + } + } + + rendered, err = RenderRunnerInstallWrapper(ctx, osType, "https://garm.example.com/api/v1/metadata", "https://garm.example.com/api/v1/callbacks", "token", proxyCfg) + if err != nil { + t.Fatalf("failed to render wrapper: %v", err) + } + for _, needle := range []string{proxyCfg.HTTPProxy, proxyCfg.NoProxy} { + if !strings.Contains(string(rendered), needle) { + t.Errorf("expected %q in the rendered wrapper", needle) + } + } + if osType == commonParams.Windows { + for _, needle := range []string{"DefaultWebProxy", `[Environment]::SetEnvironmentVariable("HTTPS_PROXY", $env:HTTPS_PROXY, "Machine")`} { + if !strings.Contains(string(rendered), needle) { + t.Errorf("expected %q in the rendered windows wrapper", needle) + } + } + } else if !strings.Contains(string(rendered), `export https_proxy='`+proxyCfg.HTTPSProxy+`'`) { + t.Error("expected lowercase https_proxy export in the linux wrapper") + } + }) + } +} + +// TestWindowsInstallTemplatesHaveProxyPreamble checks that the windows +// install scripts configure the .NET default web proxy from the proxy +// environment variables inherited from the wrapper. PowerShell 5.1 does not +// honor proxy environment variables on its own. +func TestWindowsInstallTemplatesHaveProxyPreamble(t *testing.T) { + for _, forge := range []params.EndpointType{params.GithubEndpointType, params.GiteaEndpointType} { + content, err := GetTemplateContent(commonParams.Windows, forge) + if err != nil { + t.Fatalf("failed to get template content: %v", err) + } + if !strings.Contains(string(content), "[System.Net.WebRequest]::DefaultWebProxy = $defaultProxy") { + t.Errorf("expected windows %s install template to set the default web proxy", forge) + } + } +} + +// TestInstallTemplatesRenderAgentProxy renders every default install +// template and checks that the garm-agent proxy section is only emitted when +// a proxy is set. Agents older than v0.1.1 do not know the proxy section, so +// it must be absent otherwise. +func TestInstallTemplatesRenderAgentProxy(t *testing.T) { + for _, forge := range []params.EndpointType{params.GithubEndpointType, params.GiteaEndpointType} { + for _, osType := range []commonParams.OSType{commonParams.Linux, commonParams.Windows} { + t.Run(fmt.Sprintf("%s_%s", forge, osType), func(t *testing.T) { + content, err := GetTemplateContent(osType, forge) + if err != nil { + t.Fatalf("failed to get template content: %v", err) + } + + tplCtx := InstallContext{ + AgentMode: true, + AgentURL: "wss://garm.example.com/agent", + AgentToken: "secret", + AgentShell: "false", + } + + rendered, err := RenderRunnerInstallScript(string(content), tplCtx) + if err != nil { + t.Fatalf("failed to render template: %v", err) + } + if strings.Contains(string(rendered), "[proxy]") { + t.Error("expected proxy section to be absent when no proxy is set") + } + + tplCtx.HTTPSProxy = "http://user:pass@proxy.example.com:3128" + tplCtx.NoProxy = "localhost,.internal.example.com" + rendered, err = RenderRunnerInstallScript(string(content), tplCtx) + if err != nil { + t.Fatalf("failed to render template: %v", err) + } + needles := []string{ + "[proxy]", + `https_proxy = "http://user:pass@proxy.example.com:3128"`, + `no_proxy = "localhost,.internal.example.com"`, + } + if forge == params.GithubEndpointType { + // The github runner reads proxy settings from the + // .env file in the runner install dir. + needles = append(needles, "https_proxy=http://user:pass@proxy.example.com:3128") + } + for _, needle := range needles { + if !strings.Contains(string(rendered), needle) { + t.Errorf("expected %q in the rendered template", needle) + } + } + }) + } + } +} + func TestInstallTemplatesRenderForceInsecure(t *testing.T) { for _, forge := range []params.EndpointType{params.GithubEndpointType, params.GiteaEndpointType} { for _, osType := range []commonParams.OSType{commonParams.Linux, commonParams.Windows} { diff --git a/internal/templates/userdata/gitea_linux_userdata.tmpl b/internal/templates/userdata/gitea_linux_userdata.tmpl index 27ac49ff9..69b43cb0b 100644 --- a/internal/templates/userdata/gitea_linux_userdata.tmpl +++ b/internal/templates/userdata/gitea_linux_userdata.tmpl @@ -93,6 +93,15 @@ token = "$AGENT_TOKEN" runner_cmdline = ["$RUN_HOME/gitea-runner", "daemon", "--once"] state_db_path = "/etc/garm-agent/agent-state.db" EOF +{{- if or .HTTPProxy .HTTPSProxy }} + cat >> /etc/garm-agent/garm-agent.toml << 'PROXY_EOF' + +[proxy] +http_proxy = "{{ .HTTPProxy }}" +https_proxy = "{{ .HTTPSProxy }}" +no_proxy = "{{ .NoProxy }}" +PROXY_EOF +{{- end }} cat > /tmp/garm-agent.service << EOF [Unit] diff --git a/internal/templates/userdata/gitea_windows_userdata.tmpl b/internal/templates/userdata/gitea_windows_userdata.tmpl index 9285417d6..50afbe0f1 100644 --- a/internal/templates/userdata/gitea_windows_userdata.tmpl +++ b/internal/templates/userdata/gitea_windows_userdata.tmpl @@ -6,6 +6,29 @@ Param( $ErrorActionPreference="Stop" +if ($env:HTTPS_PROXY -or $env:HTTP_PROXY) { + $proxyAddress = $env:HTTPS_PROXY + if (!$proxyAddress) { $proxyAddress = $env:HTTP_PROXY } + $proxyUri = [System.Uri]$proxyAddress + $defaultProxy = New-Object System.Net.WebProxy("$($proxyUri.Scheme)://$($proxyUri.Host):$($proxyUri.Port)") + if ($proxyUri.UserInfo) { + $proxyUserInfo = $proxyUri.UserInfo.Split(":", 2) + $proxyUser = [System.Uri]::UnescapeDataString($proxyUserInfo[0]) + $proxyPass = "" + if ($proxyUserInfo.Length -gt 1) { $proxyPass = [System.Uri]::UnescapeDataString($proxyUserInfo[1]) } + $defaultProxy.Credentials = New-Object System.Net.NetworkCredential($proxyUser, $proxyPass) + } + if ($env:NO_PROXY) { + $proxyBypass = @() + foreach ($bypassEntry in $env:NO_PROXY -split ",") { + $trimmedEntry = $bypassEntry.Trim().TrimStart(".") + if ($trimmedEntry) { $proxyBypass += ([regex]::Escape($trimmedEntry) + "$") } + } + if ($proxyBypass.Count -gt 0) { $defaultProxy.BypassList = $proxyBypass } + } + [System.Net.WebRequest]::DefaultWebProxy = $defaultProxy +} + function Start-ExecuteWithRetry { [CmdletBinding()] param( @@ -438,6 +461,15 @@ token = "$agentToken" runner_cmdline = ["$runnerExecutable", "daemon", "--once"] state_db_path = "C:/garm-agent/agent-state.db" "@ +{{- if or .HTTPProxy .HTTPSProxy }} + Add-Content "$agentDir\garm-agent.toml" @' + +[proxy] +http_proxy = "{{ .HTTPProxy }}" +https_proxy = "{{ .HTTPSProxy }}" +no_proxy = "{{ .NoProxy }}" +'@ +{{- end }} Update-GarmStatus -Message "Downloading agent from: $agentDownloadURL" -CallbackURL $CallbackURL | Out-Null Start-ExecuteWithRetry -ScriptBlock { @@ -479,11 +511,14 @@ function Install-NSSM { & $nssm set GiteaRunner AppStopMethodConsole 1000 & $nssm set GiteaRunner AppThrottle 5000 & $nssm set GiteaRunner ObjectName $username $password + $proxyEnv = @() + if ($env:HTTP_PROXY) { $proxyEnv += "HTTP_PROXY=$env:HTTP_PROXY" } + if ($env:HTTPS_PROXY) { $proxyEnv += "HTTPS_PROXY=$env:HTTPS_PROXY" } + if ($env:NO_PROXY) { $proxyEnv += "NO_PROXY=$env:NO_PROXY" } + if ($proxyEnv.Count -gt 0) { & $nssm set GiteaRunner AppEnvironmentExtra $proxyEnv } & $nssm start GiteaRunner } - - function Install-Runner() { $CallbackURL="{{.CallbackURL}}" if (!($CallbackURL -match "^(.*)/status(/)?$")) { diff --git a/internal/templates/userdata/github_linux_userdata.tmpl b/internal/templates/userdata/github_linux_userdata.tmpl index d8c1489c5..c3d832319 100644 --- a/internal/templates/userdata/github_linux_userdata.tmpl +++ b/internal/templates/userdata/github_linux_userdata.tmpl @@ -102,6 +102,15 @@ token = "$AGENT_TOKEN" runner_cmdline = ["/bin/bash", "-C", "/home/runner/actions-runner/run.sh"] state_db_path = "/etc/garm-agent/agent-state.db" EOF +{{- if or .HTTPProxy .HTTPSProxy }} + cat >> /etc/garm-agent/garm-agent.toml << 'PROXY_EOF' + +[proxy] +http_proxy = "{{ .HTTPProxy }}" +https_proxy = "{{ .HTTPSProxy }}" +no_proxy = "{{ .NoProxy }}" +PROXY_EOF +{{- end }} cat > /tmp/garm-agent.service << EOF [Unit] @@ -234,6 +243,22 @@ set -e sendStatus "installing runner service" sudo ./svc.sh install {{ .RunnerUsername }} || fail "failed to install service" {{- end}} +{{- if or .HTTPProxy .HTTPSProxy }} + +sendStatus "setting runner proxy config" +cat >> "$RUN_HOME/.env" << 'PROXY_EOF' +{{- if .HTTPProxy }} +http_proxy={{ .HTTPProxy }} +{{- end }} +{{- if .HTTPSProxy }} +https_proxy={{ .HTTPSProxy }} +{{- end }} +{{- if .NoProxy }} +no_proxy={{ .NoProxy }} +{{- end }} +PROXY_EOF +chown {{ .RunnerUsername }}:{{ .RunnerGroup }} "$RUN_HOME/.env" || fail "failed to change .env owner" +{{- end }} if [ -e "/sys/fs/selinux" ];then sudo chcon -R -h user_u:object_r:bin_t:s0 /home/runner/ || fail "failed to change selinux context" diff --git a/internal/templates/userdata/github_windows_userdata.tmpl b/internal/templates/userdata/github_windows_userdata.tmpl index 4a218b213..f987b91a7 100644 --- a/internal/templates/userdata/github_windows_userdata.tmpl +++ b/internal/templates/userdata/github_windows_userdata.tmpl @@ -6,6 +6,29 @@ Param( $ErrorActionPreference="Stop" +if ($env:HTTPS_PROXY -or $env:HTTP_PROXY) { + $proxyAddress = $env:HTTPS_PROXY + if (!$proxyAddress) { $proxyAddress = $env:HTTP_PROXY } + $proxyUri = [System.Uri]$proxyAddress + $defaultProxy = New-Object System.Net.WebProxy("$($proxyUri.Scheme)://$($proxyUri.Host):$($proxyUri.Port)") + if ($proxyUri.UserInfo) { + $proxyUserInfo = $proxyUri.UserInfo.Split(":", 2) + $proxyUser = [System.Uri]::UnescapeDataString($proxyUserInfo[0]) + $proxyPass = "" + if ($proxyUserInfo.Length -gt 1) { $proxyPass = [System.Uri]::UnescapeDataString($proxyUserInfo[1]) } + $defaultProxy.Credentials = New-Object System.Net.NetworkCredential($proxyUser, $proxyPass) + } + if ($env:NO_PROXY) { + $proxyBypass = @() + foreach ($bypassEntry in $env:NO_PROXY -split ",") { + $trimmedEntry = $bypassEntry.Trim().TrimStart(".") + if ($trimmedEntry) { $proxyBypass += ([regex]::Escape($trimmedEntry) + "$") } + } + if ($proxyBypass.Count -gt 0) { $defaultProxy.BypassList = $proxyBypass } + } + [System.Net.WebRequest]::DefaultWebProxy = $defaultProxy +} + function Start-ExecuteWithRetry { [CmdletBinding()] param( @@ -434,6 +457,15 @@ token = "$agentToken" runner_cmdline = ["C:\\Windows\\system32\\cmd.exe", "/C", "C:\\actions-runner\\run.cmd"] state_db_path = "C:/garm-agent/agent-state.db" "@ +{{- if or .HTTPProxy .HTTPSProxy }} + Add-Content "$agentDir\garm-agent.toml" @' + +[proxy] +http_proxy = "{{ .HTTPProxy }}" +https_proxy = "{{ .HTTPSProxy }}" +no_proxy = "{{ .NoProxy }}" +'@ +{{- end }} Update-GarmStatus -Message "Downloading agent from: $agentDownloadURL" -CallbackURL $CallbackURL | Out-Null Start-ExecuteWithRetry -ScriptBlock { @@ -527,6 +559,21 @@ function Install-Runner() { Update-GarmStatus -CallbackURL $CallbackURL -Message "configuring and starting runner" cd $runnerDir + {{- if or .HTTPProxy .HTTPSProxy }} + + Update-GarmStatus -CallbackURL $CallbackURL -Message "setting runner proxy config" + Set-Content -Path (Join-Path $runnerDir ".env") -Value @' +{{- if .HTTPProxy }} +http_proxy={{ .HTTPProxy }} +{{- end }} +{{- if .HTTPSProxy }} +https_proxy={{ .HTTPSProxy }} +{{- end }} +{{- if .NoProxy }} +no_proxy={{ .NoProxy }} +{{- end }} +'@ + {{- end }} {{- if .UseJITConfig }} Update-GarmStatus -CallbackURL $CallbackURL -Message "downloading JIT credentials" diff --git a/internal/templates/userdata/linux_wrapper.tmpl b/internal/templates/userdata/linux_wrapper.tmpl index 9d4d1e1ab..d0400e391 100644 --- a/internal/templates/userdata/linux_wrapper.tmpl +++ b/internal/templates/userdata/linux_wrapper.tmpl @@ -6,6 +6,18 @@ set -o pipefail CALLBACK_URL="{{ .CallbackURL }}" METADATA_URL="{{ .MetadataURL }}" BEARER_TOKEN="{{ .CallbackToken }}" +{{- if .HTTPProxy }} +export HTTP_PROXY='{{ .HTTPProxy }}' +export http_proxy='{{ .HTTPProxy }}' +{{- end }} +{{- if .HTTPSProxy }} +export HTTPS_PROXY='{{ .HTTPSProxy }}' +export https_proxy='{{ .HTTPSProxy }}' +{{- end }} +{{- if .NoProxy }} +export NO_PROXY='{{ .NoProxy }}' +export no_proxy='{{ .NoProxy }}' +{{- end }} function call() { PAYLOAD="$1" diff --git a/internal/templates/userdata/windows_wrapper.tmpl b/internal/templates/userdata/windows_wrapper.tmpl index d5b54f856..9a622cc45 100644 --- a/internal/templates/userdata/windows_wrapper.tmpl +++ b/internal/templates/userdata/windows_wrapper.tmpl @@ -6,6 +6,41 @@ $ErrorActionPreference="Stop" $MetadataUrl = "{{ .MetadataURL }}" $CallbackUrl = "{{ .CallbackURL }}" $CallbackToken = "{{ .CallbackToken }}" +{{- if .HTTPProxy }} +$env:HTTP_PROXY = '{{ .HTTPProxy }}' +[Environment]::SetEnvironmentVariable("HTTP_PROXY", $env:HTTP_PROXY, "Machine") +{{- end }} +{{- if .HTTPSProxy }} +$env:HTTPS_PROXY = '{{ .HTTPSProxy }}' +[Environment]::SetEnvironmentVariable("HTTPS_PROXY", $env:HTTPS_PROXY, "Machine") +{{- end }} +{{- if .NoProxy }} +$env:NO_PROXY = '{{ .NoProxy }}' +[Environment]::SetEnvironmentVariable("NO_PROXY", $env:NO_PROXY, "Machine") +{{- end }} +{{- if or .HTTPProxy .HTTPSProxy }} + +$proxyAddress = $env:HTTPS_PROXY +if (!$proxyAddress) { $proxyAddress = $env:HTTP_PROXY } +$proxyUri = [System.Uri]$proxyAddress +$defaultProxy = New-Object System.Net.WebProxy("$($proxyUri.Scheme)://$($proxyUri.Host):$($proxyUri.Port)") +if ($proxyUri.UserInfo) { + $proxyUserInfo = $proxyUri.UserInfo.Split(":", 2) + $proxyUser = [System.Uri]::UnescapeDataString($proxyUserInfo[0]) + $proxyPass = "" + if ($proxyUserInfo.Length -gt 1) { $proxyPass = [System.Uri]::UnescapeDataString($proxyUserInfo[1]) } + $defaultProxy.Credentials = New-Object System.Net.NetworkCredential($proxyUser, $proxyPass) +} +if ($env:NO_PROXY) { + $proxyBypass = @() + foreach ($bypassEntry in $env:NO_PROXY -split ",") { + $trimmedEntry = $bypassEntry.Trim().TrimStart(".") + if ($trimmedEntry) { $proxyBypass += ([regex]::Escape($trimmedEntry) + "$") } + } + if ($proxyBypass.Count -gt 0) { $defaultProxy.BypassList = $proxyBypass } +} +[System.Net.WebRequest]::DefaultWebProxy = $defaultProxy +{{- end }} function Import-Certificate() { [CmdletBinding()] diff --git a/params/params.go b/params/params.go index 204f10ed6..b7a80aa3e 100644 --- a/params/params.go +++ b/params/params.go @@ -23,6 +23,7 @@ import ( "math" "net" "net/http" + "net/url" "regexp" "strings" "time" @@ -580,6 +581,12 @@ type Pool struct { TemplateID uint `json:"template_id,omitempty"` TemplateName string `json:"template_name,omitempty"` + + // ProxyID is the ID of the proxy definition that will be used by runners + // spawned in this pool. Runners will use the proxy settings to reach back + // to GARM, the forge and any other resources they need during setup. + ProxyID uint `json:"proxy_id,omitempty"` + ProxyName string `json:"proxy_name,omitempty"` } func (p Pool) BelongsTo(entity ForgeEntity) bool { @@ -749,6 +756,12 @@ type ScaleSet struct { TemplateID uint `json:"template_id,omitempty"` TemplateName string `json:"template_name,omitempty"` + // ProxyID is the ID of the proxy definition that will be used by runners + // spawned in this scale set. Runners will use the proxy settings to reach + // back to GARM, the forge and any other resources they need during setup. + ProxyID uint `json:"proxy_id,omitempty"` + ProxyName string `json:"proxy_name,omitempty"` + LastMessageID int64 `json:"-"` } @@ -1658,6 +1671,73 @@ type Template struct { // swagger:model Templates type Templates []Template +// swagger:model Proxy +type Proxy struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Name string `json:"name"` + Description string `json:"description,omitempty"` + + // HTTPProxy is the proxy URL used for plain HTTP requests. + HTTPProxy string `json:"http_proxy,omitempty"` + // HTTPSProxy is the proxy URL used for HTTPS requests. + HTTPSProxy string `json:"https_proxy,omitempty"` + // NoProxy is a comma separated list of hosts, domains or CIDRs for + // which the proxy should be bypassed. + NoProxy string `json:"no_proxy,omitempty"` + + // Username is the username used to authenticate to the proxy. If set, + // it will be composed into the final proxy URLs handed to runners. + Username string `json:"username,omitempty"` + // Password is the password used to authenticate to the proxy. This + // field is never serialized. Secrets are not echoed back via the API. + Password string `json:"-"` +} + +func (p Proxy) GetID() uint { + return p.ID +} + +// HasCredentials returns true if the proxy definition has credentials set. +func (p Proxy) HasCredentials() bool { + return p.Username != "" +} + +// composeProxyURL embeds the proxy credentials (if any) into the given proxy +// URL. The URL is assumed to have been validated at create/update time. +func (p Proxy) composeProxyURL(proxyURL string) string { + if proxyURL == "" || !p.HasCredentials() { + return proxyURL + } + parsed, err := url.Parse(proxyURL) + if err != nil { + return proxyURL + } + if p.Password != "" { + parsed.User = url.UserPassword(p.Username, p.Password) + } else { + parsed.User = url.User(p.Username) + } + return parsed.String() +} + +// ProxyConfig composes the final proxy configuration that gets handed to +// providers as part of the bootstrap params. Credentials, if set, are +// embedded into the proxy URLs. +func (p Proxy) ProxyConfig() commonParams.ProxyConfig { + return commonParams.ProxyConfig{ + HTTPProxy: p.composeProxyURL(p.HTTPProxy), + HTTPSProxy: p.composeProxyURL(p.HTTPSProxy), + NoProxy: p.NoProxy, + } +} + +// used by swagger client generated code +// swagger:model Proxies +type Proxies []Proxy + // swagger:model FileObject type FileObject struct { ID uint `json:"id"` diff --git a/params/requests.go b/params/requests.go index fea3dff93..be17a7b78 100644 --- a/params/requests.go +++ b/params/requests.go @@ -32,6 +32,7 @@ const ( DefaultRunnerPrefix string = "garm" httpsScheme string = "https" httpScheme string = "http" + socks5Scheme string = "socks5" ) type InstanceRequest struct { @@ -214,6 +215,9 @@ type UpdatePoolParams struct { GitHubRunnerGroup *string `json:"github-runner-group,omitempty"` Priority *uint `json:"priority,omitempty"` TemplateID *uint `json:"template_id,omitempty"` + // ProxyID is the ID of the proxy definition runners in this pool will + // use. Setting it to 0 removes the proxy from the pool. + ProxyID *uint `json:"proxy_id,omitempty"` } type CreateInstanceParams struct { @@ -256,6 +260,8 @@ type CreatePoolParams struct { GitHubRunnerGroup string `json:"github-runner-group,omitempty"` Priority uint `json:"priority,omitempty"` TemplateID *uint `json:"template_id,omitempty"` + // ProxyID is the ID of the proxy definition runners in this pool will use. + ProxyID *uint `json:"proxy_id,omitempty"` } func (p *CreatePoolParams) Validate() error { @@ -677,6 +683,9 @@ type CreateScaleSetParams struct { GitHubRunnerGroup string `json:"github-runner-group,omitempty"` TemplateID *uint `json:"template_id,omitempty"` Labels []string `json:"labels,omitempty"` + // ProxyID is the ID of the proxy definition runners in this scale set + // will use. + ProxyID *uint `json:"proxy_id,omitempty"` } func (s *CreateScaleSetParams) Validate() error { @@ -747,6 +756,9 @@ type UpdateScaleSetParams struct { ExtendedState *string `json:"extended_state"` TemplateID *uint `json:"template_id,omitempty"` ScaleSetID int `json:"-"` + // ProxyID is the ID of the proxy definition runners in this scale set + // will use. Setting it to 0 removes the proxy from the scale set. + ProxyID *uint `json:"proxy_id,omitempty"` } // swagger:model CreateGiteaEndpointParams @@ -978,6 +990,121 @@ func (u *UpdateTemplateParams) Validate() error { return nil } +// validateProxyURL validates a proxy URL. Credentials must not be embedded +// in the URL itself. They are set separately and composed into the final +// proxy URL when needed. +func validateProxyURL(proxyURL string) error { + parsed, err := url.Parse(proxyURL) + if err != nil { + return runnerErrors.NewBadRequestError("invalid proxy URL: %q", proxyURL) + } + + switch parsed.Scheme { + case httpScheme, httpsScheme, socks5Scheme: + default: + return runnerErrors.NewBadRequestError("invalid proxy URL scheme %q; supported schemes are http, https and socks5", parsed.Scheme) + } + + if parsed.Host == "" { + return runnerErrors.NewBadRequestError("invalid proxy URL: %q; missing host", proxyURL) + } + + if parsed.User != nil { + return runnerErrors.NewBadRequestError("proxy URLs must not embed credentials; use the username and password fields instead") + } + + return nil +} + +// swagger:model CreateProxyParams +type CreateProxyParams struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + + // HTTPProxy is the proxy URL used for plain HTTP requests. + HTTPProxy string `json:"http_proxy,omitempty"` + // HTTPSProxy is the proxy URL used for HTTPS requests. + HTTPSProxy string `json:"https_proxy,omitempty"` + // NoProxy is a comma separated list of hosts, domains or CIDRs for + // which the proxy should be bypassed. + NoProxy string `json:"no_proxy,omitempty"` + + // Username is the username used to authenticate to the proxy. + Username string `json:"username,omitempty"` + // Password is the password used to authenticate to the proxy. + Password string `json:"password,omitempty"` +} + +func (c *CreateProxyParams) Validate() error { + if c.Name == "" { + return runnerErrors.NewBadRequestError("name cannot be empty") + } + + if c.HTTPProxy == "" && c.HTTPSProxy == "" { + return runnerErrors.NewBadRequestError("at least one of http_proxy or https_proxy must be set") + } + + if c.HTTPProxy != "" { + if err := validateProxyURL(c.HTTPProxy); err != nil { + return err + } + } + + if c.HTTPSProxy != "" { + if err := validateProxyURL(c.HTTPSProxy); err != nil { + return err + } + } + + if c.Password != "" && c.Username == "" { + return runnerErrors.NewBadRequestError("password cannot be set without a username") + } + + return nil +} + +// swagger:model UpdateProxyParams +type UpdateProxyParams struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + + // HTTPProxy is the proxy URL used for plain HTTP requests. + HTTPProxy *string `json:"http_proxy,omitempty"` + // HTTPSProxy is the proxy URL used for HTTPS requests. + HTTPSProxy *string `json:"https_proxy,omitempty"` + // NoProxy is a comma separated list of hosts, domains or CIDRs for + // which the proxy should be bypassed. Setting it to an empty string + // clears the value. + NoProxy *string `json:"no_proxy,omitempty"` + + // Username is the username used to authenticate to the proxy. Setting + // it to an empty string clears the proxy credentials. + Username *string `json:"username,omitempty"` + // Password is the password used to authenticate to the proxy. Setting + // it to an empty string clears the password. + Password *string `json:"password,omitempty"` +} + +func (u *UpdateProxyParams) Validate() error { + if u.Name != nil && *u.Name == "" { + return runnerErrors.NewBadRequestError("name cannot be empty") + } + + if u.HTTPProxy != nil && *u.HTTPProxy != "" { + if err := validateProxyURL(*u.HTTPProxy); err != nil { + return err + } + } + + if u.HTTPSProxy != nil && *u.HTTPSProxy != "" { + if err := validateProxyURL(*u.HTTPSProxy); err != nil { + return err + } + } + + return nil +} + // swagger:model UpdateFileObjectParams type UpdateFileObjectParams struct { Name *string `json:"name"` diff --git a/runner/metadata.go b/runner/metadata.go index afefc2f80..84565a42d 100644 --- a/runner/metadata.go +++ b/runner/metadata.go @@ -21,11 +21,11 @@ import ( "encoding/json" "errors" "fmt" - "html/template" "io" "log/slog" "net/url" "strings" + "text/template" "time" "github.com/cloudbase/garm-provider-common/cloudconfig" @@ -53,6 +53,18 @@ After=network.target ExecStart=/home/{{.RunAsUser}}/actions-runner/runsvc.sh User={{.RunAsUser}} WorkingDirectory=/home/{{.RunAsUser}}/actions-runner +{{- if .HTTPProxy }} +Environment="HTTP_PROXY={{.HTTPProxy}}" +Environment="http_proxy={{.HTTPProxy}}" +{{- end }} +{{- if .HTTPSProxy }} +Environment="HTTPS_PROXY={{.HTTPSProxy}}" +Environment="https_proxy={{.HTTPSProxy}}" +{{- end }} +{{- if .NoProxy }} +Environment="NO_PROXY={{.NoProxy}}" +Environment="no_proxy={{.NoProxy}}" +{{- end }} KillMode=process KillSignal=SIGTERM TimeoutStopSec=5min @@ -69,6 +81,18 @@ After=network.target ExecStart=/home/{{.RunAsUser}}/gitea-runner/gitea-runner daemon --once User={{.RunAsUser}} WorkingDirectory=/home/{{.RunAsUser}}/gitea-runner +{{- if .HTTPProxy }} +Environment="HTTP_PROXY={{.HTTPProxy}}" +Environment="http_proxy={{.HTTPProxy}}" +{{- end }} +{{- if .HTTPSProxy }} +Environment="HTTPS_PROXY={{.HTTPSProxy}}" +Environment="https_proxy={{.HTTPSProxy}}" +{{- end }} +{{- if .NoProxy }} +Environment="NO_PROXY={{.NoProxy}}" +Environment="no_proxy={{.NoProxy}}" +{{- end }} KillMode=process KillSignal=SIGTERM TimeoutStopSec=5min @@ -430,10 +454,18 @@ func (r *Runner) GetRunnerInstallScript(ctx context.Context) ([]byte, error) { } } + proxyConfig, err := getInstanceProxyConfig(instance) + if err != nil { + return nil, fmt.Errorf("error resolving instance proxy: %w", err) + } + // Build agent context for the template so templates don't need // to fetch metadata and parse it with jq at runtime. tplCtx := templates.InstallContext{ InstallRunnerParams: installCtx, + HTTPProxy: proxyConfig.HTTPProxy, + HTTPSProxy: proxyConfig.HTTPSProxy, + NoProxy: proxyConfig.NoProxy, } if entity.AgentMode { tplCtx.AgentMode = true @@ -479,6 +511,40 @@ func (r *Runner) GetRunnerInstallScript(ctx context.Context) ([]byte, error) { return installScript, nil } +// getInstanceProxyConfig resolves the proxy definition set on the pool or +// scale set the instance belongs to and composes the final proxy config. An +// empty config is returned when no proxy is set. An error is returned when a +// proxy is set but cannot be resolved from the cache. +func getInstanceProxyConfig(instance params.Instance) (commonParams.ProxyConfig, error) { + var proxyID uint + switch { + case instance.PoolID != "": + if pool, ok := cache.GetPoolByID(instance.PoolID); ok { + proxyID = pool.ProxyID + } + case instance.ScaleSetID > 0: + if scaleSet, ok := cache.GetScaleSetByID(instance.ScaleSetID); ok { + proxyID = scaleSet.ProxyID + } + } + if proxyID == 0 { + return commonParams.ProxyConfig{}, nil + } + + proxy, ok := cache.GetProxy(proxyID) + if !ok { + return commonParams.ProxyConfig{}, fmt.Errorf("proxy %d set on the pool or scale set of instance %s was not found in cache", proxyID, instance.Name) + } + return proxy.ProxyConfig(), nil +} + +// systemdEscape escapes the percent sign in values rendered into systemd +// unit files. systemd expands % as a specifier; proxy URLs carry percent +// encoded credentials. +func systemdEscape(val string) string { + return strings.ReplaceAll(val, "%", "%%") +} + func (r *Runner) GenerateSystemdUnitFile(ctx context.Context, runAsUser string) ([]byte, error) { entity, err := auth.InstanceEntity(ctx) if err != nil { @@ -486,6 +552,18 @@ func (r *Runner) GenerateSystemdUnitFile(ctx context.Context, runAsUser string) return nil, runnerErrors.ErrUnauthorized } + instance, err := auth.InstanceParams(ctx) + if err != nil { + slog.ErrorContext(r.ctx, "failed to get instance params", "error", err) + return nil, runnerErrors.ErrUnauthorized + } + + proxyConfig, err := getInstanceProxyConfig(instance) + if err != nil { + slog.ErrorContext(r.ctx, "failed to resolve instance proxy", "error", err) + return nil, fmt.Errorf("error resolving instance proxy: %w", err) + } + serviceName, err := r.getServiceNameForEntity(entity) if err != nil { slog.ErrorContext(r.ctx, "failed to get service name", "error", err) @@ -514,9 +592,15 @@ func (r *Runner) GenerateSystemdUnitFile(ctx context.Context, runAsUser string) data := struct { ServiceName string RunAsUser string + HTTPProxy string + HTTPSProxy string + NoProxy string }{ ServiceName: serviceName, RunAsUser: runAsUser, + HTTPProxy: systemdEscape(proxyConfig.HTTPProxy), + HTTPSProxy: systemdEscape(proxyConfig.HTTPSProxy), + NoProxy: systemdEscape(proxyConfig.NoProxy), } var unitFile bytes.Buffer diff --git a/runner/metadata_test.go b/runner/metadata_test.go index 58a3432ec..e5df5295f 100644 --- a/runner/metadata_test.go +++ b/runner/metadata_test.go @@ -462,7 +462,8 @@ func (s *MetadataTestSuite) TestGenerateSystemdUnitFile() { // Set up entity with specific forge type entity := s.Fixtures.TestEntity entity.Credentials.ForgeType = tt.forgeType - ctx := auth.SetInstanceEntity(context.Background(), entity) + ctx := auth.SetInstanceParams(context.Background(), s.Fixtures.TestInstance) + ctx = auth.SetInstanceEntity(ctx, entity) unitFile, err := s.Runner.GenerateSystemdUnitFile(ctx, tt.runAsUser) @@ -483,7 +484,8 @@ func (s *MetadataTestSuite) TestGenerateSystemdUnitFile() { func (s *MetadataTestSuite) TestGenerateSystemdUnitFileUnknownForgeType() { entity := s.Fixtures.TestEntity entity.Credentials.ForgeType = "unknown" - ctx := auth.SetInstanceEntity(context.Background(), entity) + ctx := auth.SetInstanceParams(context.Background(), s.Fixtures.TestInstance) + ctx = auth.SetInstanceEntity(ctx, entity) _, err := s.Runner.GenerateSystemdUnitFile(ctx, "test-user") @@ -1276,7 +1278,8 @@ func (s *MetadataTestSuite) TestGetJITConfigFileMultipleFiles() { func (s *MetadataTestSuite) TestGenerateSystemdUnitFileGiteaWithDefaultUser() { entity := s.Fixtures.TestEntity entity.Credentials.ForgeType = params.GiteaEndpointType - ctx := auth.SetInstanceEntity(context.Background(), entity) + ctx := auth.SetInstanceParams(context.Background(), s.Fixtures.TestInstance) + ctx = auth.SetInstanceEntity(ctx, entity) unitFile, err := s.Runner.GenerateSystemdUnitFile(ctx, "") @@ -1287,6 +1290,60 @@ func (s *MetadataTestSuite) TestGenerateSystemdUnitFileGiteaWithDefaultUser() { s.Require().Contains(string(unitFile), "Restart=always") } +func (s *MetadataTestSuite) TestGenerateSystemdUnitFileWithProxy() { + cache.SetProxyCache(params.Proxy{ + ID: 42, + Name: "airgap", + HTTPSProxy: "http://proxy.example.com:3128", + NoProxy: "localhost,10.0.0.0/8", + Username: "user", + Password: "p%ss", + }) + defer cache.DeleteProxy(42) + + entity := s.Fixtures.TestEntity + cache.SetEntity(entity) + defer cache.DeleteEntity(entity.ID) + pool := params.Pool{ID: "proxied-pool-id", OrgID: entity.ID, ProxyID: 42} + cache.SetEntityPool(entity.ID, pool) + + instance := s.Fixtures.TestInstance + instance.PoolID = pool.ID + + ctx := auth.SetInstanceParams(context.Background(), instance) + ctx = auth.SetInstanceEntity(ctx, entity) + + unitFile, err := s.Runner.GenerateSystemdUnitFile(ctx, "") + + s.Require().Nil(err) + // The password contains a percent sign, which url.UserPassword encodes + // as %25 and the unit file renderer escapes as %%25. systemd expands % + // as a specifier. + s.Require().Contains(string(unitFile), `Environment="HTTPS_PROXY=http://user:p%%25ss@proxy.example.com:3128"`) + s.Require().Contains(string(unitFile), `Environment="https_proxy=http://user:p%%25ss@proxy.example.com:3128"`) + s.Require().Contains(string(unitFile), `Environment="NO_PROXY=localhost,10.0.0.0/8"`) + s.Require().NotContains(string(unitFile), `Environment="HTTP_PROXY=`) +} + +func (s *MetadataTestSuite) TestGenerateSystemdUnitFileMissingProxy() { + entity := s.Fixtures.TestEntity + cache.SetEntity(entity) + defer cache.DeleteEntity(entity.ID) + pool := params.Pool{ID: "missing-proxy-pool-id", OrgID: entity.ID, ProxyID: 4242} + cache.SetEntityPool(entity.ID, pool) + + instance := s.Fixtures.TestInstance + instance.PoolID = pool.ID + + ctx := auth.SetInstanceParams(context.Background(), instance) + ctx = auth.SetInstanceEntity(ctx, entity) + + _, err := s.Runner.GenerateSystemdUnitFile(ctx, "") + + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "was not found in cache") +} + func (s *MetadataTestSuite) TestGetLabelsForInstanceWithCache() { // This test would require setting up the cache properly // For now, we test that it doesn't panic with empty cache diff --git a/runner/pool/pool.go b/runner/pool/pool.go index a3756a46d..f60ca02e2 100644 --- a/runner/pool/pool.go +++ b/runner/pool/pool.go @@ -1101,6 +1101,15 @@ func (r *basePoolManager) addInstanceToProvider(instance params.Instance) error GitHubRunnerGroup: instance.GitHubRunnerGroup, JitConfigEnabled: hasJITConfig, } + + if pool.ProxyID != 0 { + proxy, ok := cache.GetProxy(pool.ProxyID) + if !ok { + return fmt.Errorf("proxy %d (%s) set on pool %s was not found in cache", pool.ProxyID, pool.ProxyName, pool.ID) + } + bootstrapArgs.ProxyConfig = proxy.ProxyConfig() + } + // We use the template management system unless: // * there is no template associated with the pool/scale set // * user explicitly overwrites the install template via extra specs diff --git a/runner/proxies.go b/runner/proxies.go new file mode 100644 index 000000000..a4b7ca2c4 --- /dev/null +++ b/runner/proxies.go @@ -0,0 +1,105 @@ +// Copyright 2026 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 runner + +import ( + "context" + "errors" + "fmt" + + runnerErrors "github.com/cloudbase/garm-provider-common/errors" + "github.com/cloudbase/garm/auth" + "github.com/cloudbase/garm/params" +) + +func (r *Runner) CreateProxy(ctx context.Context, param params.CreateProxyParams) (params.Proxy, error) { + if !auth.IsAdmin(ctx) { + return params.Proxy{}, runnerErrors.ErrUnauthorized + } + + if err := param.Validate(); err != nil { + return params.Proxy{}, fmt.Errorf("failed to validate create params: %w", err) + } + + proxy, err := r.store.CreateProxy(ctx, param) + if err != nil { + return params.Proxy{}, fmt.Errorf("failed to create proxy: %w", err) + } + return proxy, nil +} + +func (r *Runner) GetProxy(ctx context.Context, id uint) (params.Proxy, error) { + if !auth.IsAdmin(ctx) { + return params.Proxy{}, runnerErrors.ErrUnauthorized + } + + proxy, err := r.store.GetProxy(ctx, id) + if err != nil { + return params.Proxy{}, fmt.Errorf("failed to get proxy: %w", err) + } + return proxy, nil +} + +func (r *Runner) GetProxyByName(ctx context.Context, name string) (params.Proxy, error) { + if !auth.IsAdmin(ctx) { + return params.Proxy{}, runnerErrors.ErrUnauthorized + } + + proxy, err := r.store.GetProxyByName(ctx, name) + if err != nil { + return params.Proxy{}, fmt.Errorf("failed to get proxy: %w", err) + } + return proxy, nil +} + +func (r *Runner) ListProxies(ctx context.Context) ([]params.Proxy, error) { + if !auth.IsAdmin(ctx) { + return nil, runnerErrors.ErrUnauthorized + } + + proxies, err := r.store.ListProxies(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list proxies: %w", err) + } + return proxies, nil +} + +func (r *Runner) UpdateProxy(ctx context.Context, id uint, param params.UpdateProxyParams) (params.Proxy, error) { + if !auth.IsAdmin(ctx) { + return params.Proxy{}, runnerErrors.ErrUnauthorized + } + + if err := param.Validate(); err != nil { + return params.Proxy{}, fmt.Errorf("failed to validate update params: %w", err) + } + + proxy, err := r.store.UpdateProxy(ctx, id, param) + if err != nil { + return params.Proxy{}, fmt.Errorf("failed to update proxy: %w", err) + } + return proxy, nil +} + +func (r *Runner) DeleteProxy(ctx context.Context, id uint) error { + if !auth.IsAdmin(ctx) { + return runnerErrors.ErrUnauthorized + } + + if err := r.store.DeleteProxy(ctx, id); err != nil { + if !errors.Is(err, runnerErrors.ErrNotFound) { + return fmt.Errorf("failed to delete proxy: %w", err) + } + } + return nil +} diff --git a/util/util.go b/util/util.go index f286c2935..170b4ae99 100644 --- a/util/util.go +++ b/util/util.go @@ -151,7 +151,7 @@ func MaybeAddWrapperToExtraSpecs(ctx context.Context, param commonParams.Bootstr return param } - wrapper, err := templates.RenderRunnerInstallWrapper(ctx, param.OSType, param.MetadataURL, param.CallbackURL, param.InstanceToken) + wrapper, err := templates.RenderRunnerInstallWrapper(ctx, param.OSType, param.MetadataURL, param.CallbackURL, param.InstanceToken, param.ProxyConfig) if err != nil { slog.WarnContext(ctx, "failed to get runner install wrapper", "os_type", param.OSType, "error", err) return param diff --git a/webapp/src/lib/api/client.ts b/webapp/src/lib/api/client.ts index 0dea23702..73f53fcde 100644 --- a/webapp/src/lib/api/client.ts +++ b/webapp/src/lib/api/client.ts @@ -17,6 +17,9 @@ import { type CreateTemplateParams, type UpdateTemplateParams, type RestoreTemplateRequest, + type Proxy, + type CreateProxyParams, + type UpdateProxyParams, type CreateRepoParams, type CreateOrgParams, type CreateEnterpriseParams, @@ -56,6 +59,9 @@ export type { Template, CreateTemplateParams, UpdateTemplateParams, + Proxy, + CreateProxyParams, + UpdateProxyParams, CreateRepoParams, CreateOrgParams, CreateEnterpriseParams, diff --git a/webapp/src/lib/api/generated-client.ts b/webapp/src/lib/api/generated-client.ts index ebaa5724a..3c050843e 100644 --- a/webapp/src/lib/api/generated-client.ts +++ b/webapp/src/lib/api/generated-client.ts @@ -18,6 +18,7 @@ import { FirstRunApi, HooksApi, TemplatesApi, + ProxiesApi, ObjectsApi, ToolsApi, type GARMAgentRelease, @@ -36,6 +37,9 @@ import { type Template, type CreateTemplateParams, type UpdateTemplateParams, + type Proxy, + type CreateProxyParams, + type UpdateProxyParams, type CreateRepoParams, type CreateOrgParams, type CreateEnterpriseParams, @@ -81,6 +85,9 @@ export type { Template, CreateTemplateParams, UpdateTemplateParams, + Proxy, + CreateProxyParams, + UpdateProxyParams, CreateRepoParams, CreateOrgParams, CreateEnterpriseParams, @@ -142,6 +149,7 @@ export class GeneratedGarmApiClient { private firstRunApi: FirstRunApi; private hooksApi: HooksApi; private templatesApi: TemplatesApi; + private proxiesApi: ProxiesApi; private objectsApi: ObjectsApi; private toolsApi: ToolsApi; @@ -177,6 +185,7 @@ export class GeneratedGarmApiClient { this.firstRunApi = new FirstRunApi(this.config); this.hooksApi = new HooksApi(this.config); this.templatesApi = new TemplatesApi(this.config); + this.proxiesApi = new ProxiesApi(this.config); this.objectsApi = new ObjectsApi(this.config); this.toolsApi = new ToolsApi(this.config); } @@ -716,6 +725,31 @@ export class GeneratedGarmApiClient { await this.templatesApi.restoreTemplates(params); } + // Proxies + async listProxies(): Promise { + const response = await this.proxiesApi.listProxies(); + return response.data || []; + } + + async getProxy(id: number): Promise { + const response = await this.proxiesApi.getProxy(id); + return response.data; + } + + async createProxy(params: CreateProxyParams): Promise { + const response = await this.proxiesApi.createProxy(params); + return response.data; + } + + async updateProxy(id: number, params: UpdateProxyParams): Promise { + const response = await this.proxiesApi.updateProxy(id, params); + return response.data; + } + + async deleteProxy(id: number): Promise { + await this.proxiesApi.deleteProxy(id); + } + // File Object methods async listFileObjects(tags?: string, page?: number, pageSize?: number): Promise { const response = await this.objectsApi.listFileObjects(tags, page, pageSize); diff --git a/webapp/src/lib/api/generated/.openapi-generator/FILES b/webapp/src/lib/api/generated/.openapi-generator/FILES index 9a4baf212..9f0230bd7 100644 --- a/webapp/src/lib/api/generated/.openapi-generator/FILES +++ b/webapp/src/lib/api/generated/.openapi-generator/FILES @@ -21,6 +21,7 @@ docs/CreateGithubCredentialsParams.md docs/CreateGithubEndpointParams.md docs/CreateOrgParams.md docs/CreatePoolParams.md +docs/CreateProxyParams.md docs/CreateRepoParams.md docs/CreateScaleSetParams.md docs/CreateTemplateParams.md @@ -68,6 +69,8 @@ docs/PoolManagerStatus.md docs/PoolsApi.md docs/Provider.md docs/ProvidersApi.md +docs/ProxiesApi.md +docs/Proxy.md docs/RepositoriesApi.md docs/Repository.md docs/RestoreTemplateRequest.md @@ -88,6 +91,7 @@ docs/UpdateGiteaEndpointParams.md docs/UpdateGithubCredentialsParams.md docs/UpdateGithubEndpointParams.md docs/UpdatePoolParams.md +docs/UpdateProxyParams.md docs/UpdateScaleSetParams.md docs/UpdateTemplateParams.md docs/User.md diff --git a/webapp/src/lib/api/generated/api.ts b/webapp/src/lib/api/generated/api.ts index da6212582..5f7760990 100644 --- a/webapp/src/lib/api/generated/api.ts +++ b/webapp/src/lib/api/generated/api.ts @@ -630,6 +630,12 @@ export interface CreatePoolParams { * @memberof CreatePoolParams */ 'provider_name'?: string; + /** + * ProxyID is the ID of the proxy definition runners in this pool will use. + * @type {number} + * @memberof CreatePoolParams + */ + 'proxy_id'?: number; /** * * @type {number} @@ -655,6 +661,55 @@ export interface CreatePoolParams { */ 'template_id'?: number; } +/** + * + * @export + * @interface CreateProxyParams + */ +export interface CreateProxyParams { + /** + * + * @type {string} + * @memberof CreateProxyParams + */ + 'description'?: string; + /** + * HTTPProxy is the proxy URL used for plain HTTP requests. + * @type {string} + * @memberof CreateProxyParams + */ + 'http_proxy'?: string; + /** + * HTTPSProxy is the proxy URL used for HTTPS requests. + * @type {string} + * @memberof CreateProxyParams + */ + 'https_proxy'?: string; + /** + * + * @type {string} + * @memberof CreateProxyParams + */ + 'name'?: string; + /** + * NoProxy is a comma separated list of hosts, domains or CIDRs for which the proxy should be bypassed. + * @type {string} + * @memberof CreateProxyParams + */ + 'no_proxy'?: string; + /** + * Password is the password used to authenticate to the proxy. + * @type {string} + * @memberof CreateProxyParams + */ + 'password'?: string; + /** + * Username is the username used to authenticate to the proxy. + * @type {string} + * @memberof CreateProxyParams + */ + 'username'?: string; +} /** * * @export @@ -794,6 +849,12 @@ export interface CreateScaleSetParams { * @memberof CreateScaleSetParams */ 'provider_name'?: string; + /** + * ProxyID is the ID of the proxy definition runners in this scale set will use. + * @type {number} + * @memberof CreateScaleSetParams + */ + 'proxy_id'?: number; /** * * @type {number} @@ -2599,6 +2660,18 @@ export interface Pool { * @memberof Pool */ 'provider_name'?: string; + /** + * ProxyID is the ID of the proxy definition that will be used by runners spawned in this pool. Runners will use the proxy settings to reach back to GARM, the forge and any other resources they need during setup. + * @type {number} + * @memberof Pool + */ + 'proxy_id'?: number; + /** + * + * @type {string} + * @memberof Pool + */ + 'proxy_name'?: string; /** * * @type {string} @@ -2692,6 +2765,67 @@ export interface Provider { */ 'type'?: string; } +/** + * + * @export + * @interface Proxy + */ +export interface Proxy { + /** + * + * @type {string} + * @memberof Proxy + */ + 'created_at'?: string; + /** + * + * @type {string} + * @memberof Proxy + */ + 'description'?: string; + /** + * HTTPProxy is the proxy URL used for plain HTTP requests. + * @type {string} + * @memberof Proxy + */ + 'http_proxy'?: string; + /** + * HTTPSProxy is the proxy URL used for HTTPS requests. + * @type {string} + * @memberof Proxy + */ + 'https_proxy'?: string; + /** + * + * @type {number} + * @memberof Proxy + */ + 'id'?: number; + /** + * + * @type {string} + * @memberof Proxy + */ + 'name'?: string; + /** + * NoProxy is a comma separated list of hosts, domains or CIDRs for which the proxy should be bypassed. + * @type {string} + * @memberof Proxy + */ + 'no_proxy'?: string; + /** + * + * @type {string} + * @memberof Proxy + */ + 'updated_at'?: string; + /** + * Username is the username used to authenticate to the proxy. If set, it will be composed into the final proxy URLs handed to runners. + * @type {string} + * @memberof Proxy + */ + 'username'?: string; +} /** * * @export @@ -3014,6 +3148,18 @@ export interface ScaleSet { * @memberof ScaleSet */ 'provider_name'?: string; + /** + * ProxyID is the ID of the proxy definition that will be used by runners spawned in this scale set. Runners will use the proxy settings to reach back to GARM, the forge and any other resources they need during setup. + * @type {number} + * @memberof ScaleSet + */ + 'proxy_id'?: number; + /** + * + * @type {string} + * @memberof ScaleSet + */ + 'proxy_name'?: string; /** * * @type {string} @@ -3529,6 +3675,12 @@ export interface UpdatePoolParams { * @memberof UpdatePoolParams */ 'priority'?: number; + /** + * ProxyID is the ID of the proxy definition runners in this pool will use. Setting it to 0 removes the proxy from the pool. + * @type {number} + * @memberof UpdatePoolParams + */ + 'proxy_id'?: number; /** * * @type {number} @@ -3554,6 +3706,55 @@ export interface UpdatePoolParams { */ 'template_id'?: number; } +/** + * + * @export + * @interface UpdateProxyParams + */ +export interface UpdateProxyParams { + /** + * + * @type {string} + * @memberof UpdateProxyParams + */ + 'description'?: string; + /** + * HTTPProxy is the proxy URL used for plain HTTP requests. + * @type {string} + * @memberof UpdateProxyParams + */ + 'http_proxy'?: string; + /** + * HTTPSProxy is the proxy URL used for HTTPS requests. + * @type {string} + * @memberof UpdateProxyParams + */ + 'https_proxy'?: string; + /** + * + * @type {string} + * @memberof UpdateProxyParams + */ + 'name'?: string; + /** + * NoProxy is a comma separated list of hosts, domains or CIDRs for which the proxy should be bypassed. Setting it to an empty string clears the value. + * @type {string} + * @memberof UpdateProxyParams + */ + 'no_proxy'?: string; + /** + * Password is the password used to authenticate to the proxy. Setting it to an empty string clears the password. + * @type {string} + * @memberof UpdateProxyParams + */ + 'password'?: string; + /** + * Username is the username used to authenticate to the proxy. Setting it to an empty string clears the proxy credentials. + * @type {string} + * @memberof UpdateProxyParams + */ + 'username'?: string; +} /** * * @export @@ -3626,6 +3827,12 @@ export interface UpdateScaleSetParams { * @memberof UpdateScaleSetParams */ 'os_type'?: string; + /** + * ProxyID is the ID of the proxy definition runners in this scale set will use. Setting it to 0 removes the proxy from the scale set. + * @type {number} + * @memberof UpdateScaleSetParams + */ + 'proxy_id'?: number; /** * * @type {number} @@ -13138,6 +13345,409 @@ export class ProvidersApi extends BaseAPI { +/** + * ProxiesApi - axios parameter creator + * @export + */ +export const ProxiesApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Create proxy with the parameters given. + * @param {CreateProxyParams} body Parameters used when creating the proxy. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createProxy: async (body: CreateProxyParams, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'body' is not null or undefined + assertParamExists('createProxy', 'body', body) + const localVarPath = `/proxies`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Delete proxy by ID. + * @param {number} proxyID ID of the proxy to delete. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteProxy: async (proxyID: number, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'proxyID' is not null or undefined + assertParamExists('deleteProxy', 'proxyID', proxyID) + const localVarPath = `/proxies/{proxyID}` + .replace(`{${"proxyID"}}`, encodeURIComponent(String(proxyID))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get proxy by ID. + * @param {number} proxyID ID of the proxy to fetch. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getProxy: async (proxyID: number, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'proxyID' is not null or undefined + assertParamExists('getProxy', 'proxyID', proxyID) + const localVarPath = `/proxies/{proxyID}` + .replace(`{${"proxyID"}}`, encodeURIComponent(String(proxyID))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary List proxies. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listProxies: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/proxies`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Update proxy with the parameters given. + * @param {number} proxyID ID of the proxy to update. + * @param {UpdateProxyParams} body Parameters used when updating the proxy. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateProxy: async (proxyID: number, body: UpdateProxyParams, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'proxyID' is not null or undefined + assertParamExists('updateProxy', 'proxyID', proxyID) + // verify required parameter 'body' is not null or undefined + assertParamExists('updateProxy', 'body', body) + const localVarPath = `/proxies/{proxyID}` + .replace(`{${"proxyID"}}`, encodeURIComponent(String(proxyID))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ProxiesApi - functional programming interface + * @export + */ +export const ProxiesApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ProxiesApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Create proxy with the parameters given. + * @param {CreateProxyParams} body Parameters used when creating the proxy. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createProxy(body: CreateProxyParams, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createProxy(body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ProxiesApi.createProxy']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Delete proxy by ID. + * @param {number} proxyID ID of the proxy to delete. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteProxy(proxyID: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteProxy(proxyID, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ProxiesApi.deleteProxy']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Get proxy by ID. + * @param {number} proxyID ID of the proxy to fetch. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getProxy(proxyID: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getProxy(proxyID, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ProxiesApi.getProxy']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary List proxies. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listProxies(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listProxies(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ProxiesApi.listProxies']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Update proxy with the parameters given. + * @param {number} proxyID ID of the proxy to update. + * @param {UpdateProxyParams} body Parameters used when updating the proxy. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateProxy(proxyID: number, body: UpdateProxyParams, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateProxy(proxyID, body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ProxiesApi.updateProxy']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * ProxiesApi - factory interface + * @export + */ +export const ProxiesApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ProxiesApiFp(configuration) + return { + /** + * + * @summary Create proxy with the parameters given. + * @param {CreateProxyParams} body Parameters used when creating the proxy. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createProxy(body: CreateProxyParams, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.createProxy(body, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Delete proxy by ID. + * @param {number} proxyID ID of the proxy to delete. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteProxy(proxyID: number, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.deleteProxy(proxyID, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Get proxy by ID. + * @param {number} proxyID ID of the proxy to fetch. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getProxy(proxyID: number, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getProxy(proxyID, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary List proxies. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listProxies(options?: RawAxiosRequestConfig): AxiosPromise> { + return localVarFp.listProxies(options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Update proxy with the parameters given. + * @param {number} proxyID ID of the proxy to update. + * @param {UpdateProxyParams} body Parameters used when updating the proxy. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateProxy(proxyID: number, body: UpdateProxyParams, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.updateProxy(proxyID, body, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * ProxiesApi - object-oriented interface + * @export + * @class ProxiesApi + * @extends {BaseAPI} + */ +export class ProxiesApi extends BaseAPI { + /** + * + * @summary Create proxy with the parameters given. + * @param {CreateProxyParams} body Parameters used when creating the proxy. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProxiesApi + */ + public createProxy(body: CreateProxyParams, options?: RawAxiosRequestConfig) { + return ProxiesApiFp(this.configuration).createProxy(body, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Delete proxy by ID. + * @param {number} proxyID ID of the proxy to delete. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProxiesApi + */ + public deleteProxy(proxyID: number, options?: RawAxiosRequestConfig) { + return ProxiesApiFp(this.configuration).deleteProxy(proxyID, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Get proxy by ID. + * @param {number} proxyID ID of the proxy to fetch. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProxiesApi + */ + public getProxy(proxyID: number, options?: RawAxiosRequestConfig) { + return ProxiesApiFp(this.configuration).getProxy(proxyID, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary List proxies. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProxiesApi + */ + public listProxies(options?: RawAxiosRequestConfig) { + return ProxiesApiFp(this.configuration).listProxies(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Update proxy with the parameters given. + * @param {number} proxyID ID of the proxy to update. + * @param {UpdateProxyParams} body Parameters used when updating the proxy. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProxiesApi + */ + public updateProxy(proxyID: number, body: UpdateProxyParams, options?: RawAxiosRequestConfig) { + return ProxiesApiFp(this.configuration).updateProxy(proxyID, body, options).then((request) => request(this.axios, this.basePath)); + } +} + + + /** * RepositoriesApi - axios parameter creator * @export diff --git a/webapp/src/lib/components/CreatePoolModal.svelte b/webapp/src/lib/components/CreatePoolModal.svelte index 5fbda716d..923f6bdcb 100644 --- a/webapp/src/lib/components/CreatePoolModal.svelte +++ b/webapp/src/lib/components/CreatePoolModal.svelte @@ -11,6 +11,7 @@ Enterprise, Provider, Template, + Proxy, UpdateEntityParams } from '$lib/api/generated/api.js'; import WizardModal from './WizardModal.svelte'; @@ -41,9 +42,11 @@ let entities: (Repository | Organization | Enterprise)[] = []; let providers: Provider[] = []; let templates: Template[] = []; + let proxies: Proxy[] = []; let loadingEntities = false; let loadingProviders = false; let loadingTemplates = false; + let loadingProxies = false; let showEntityUpdateModal = false; let unsubscribeWebsocket: (() => void) | null = null; @@ -66,6 +69,7 @@ let tags: string[] = []; let extraSpecs = '{}'; let selectedTemplate: number | undefined = undefined; + let selectedProxy: number | undefined = undefined; $: wizardTitle = poolType === 'pool' ? 'Create New Pool' : 'Create New Scale Set'; $: wizardSubmitLabel = poolType === 'pool' ? 'Create Pool' : 'Create Scale Set'; @@ -110,6 +114,18 @@ } } + async function loadProxies() { + try { + loadingProxies = true; + const allProxies = await garmApi.listProxies(); + proxies = allProxies.sort((a, b) => (a.name || '').localeCompare(b.name || '')); + } catch (err) { + error = extractAPIError(err); + } finally { + loadingProxies = false; + } + } + async function loadTemplates() { try { loadingTemplates = true; @@ -288,7 +304,8 @@ enable_shell: enableShell, labels: tags, extra_specs: extraSpecs.trim() ? parsedExtraSpecs : undefined, - template_id: selectedTemplate + template_id: selectedTemplate, + proxy_id: selectedProxy }; if (initialEntityType && initialEntityId) { @@ -326,7 +343,8 @@ enable_shell: enableShell, tags, extra_specs: extraSpecs.trim() ? parsedExtraSpecs : undefined, - template_id: selectedTemplate + template_id: selectedTemplate, + proxy_id: selectedProxy }; if (initialEntityType && initialEntityId) { @@ -360,6 +378,7 @@ onMount(() => { loadProviders(); + loadProxies(); if (initialEntityType) { loadEntities(); } @@ -553,6 +572,9 @@ bind:selectedTemplate {templates} {loadingTemplates} + bind:selectedProxy + {proxies} + {loadingProxies} idPrefix="create-pool" />
diff --git a/webapp/src/lib/components/MobileCard.svelte b/webapp/src/lib/components/MobileCard.svelte index 3502f6c62..385dc3923 100644 --- a/webapp/src/lib/components/MobileCard.svelte +++ b/webapp/src/lib/components/MobileCard.svelte @@ -43,11 +43,16 @@ }>; }; + function resolveField(obj: any, path: string): any { + if (!obj || !path) return undefined; + return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); + } + function getPrimaryText(): string { if (!item) return 'Unknown'; - + const { field, useId, showOwner } = config.primaryText; - const value = item[field]; + const value = resolveField(item, field); if (useId && value) { // For pools - show truncated ID @@ -75,7 +80,7 @@ return computedValue; } - return item?.[field] || ''; + return resolveField(item, field) || ''; } function getEntityHref(): string { diff --git a/webapp/src/lib/components/Navigation.svelte b/webapp/src/lib/components/Navigation.svelte index bc824781c..61cd5496c 100644 --- a/webapp/src/lib/components/Navigation.svelte +++ b/webapp/src/lib/components/Navigation.svelte @@ -100,6 +100,11 @@ label: 'Runner Install Templates', icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4' // Code/script icon }, + { + href: resolve('/proxies'), + label: 'Proxies', + icon: 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4' // Switch-horizontal icon + }, { href: resolve('/objects'), label: 'Object Storage', diff --git a/webapp/src/lib/components/UpdatePoolModal.svelte b/webapp/src/lib/components/UpdatePoolModal.svelte index 23a8e1810..1e82ca97d 100644 --- a/webapp/src/lib/components/UpdatePoolModal.svelte +++ b/webapp/src/lib/components/UpdatePoolModal.svelte @@ -1,6 +1,6 @@ + + + Proxies - GARM + + + + + { searchTerm = e.detail.term; currentPage = 1; }} + on:pageChange={(e) => currentPage = e.detail.page} + on:perPageChange={(e) => { perPage = e.detail.perPage; currentPage = 1; }} + on:edit={(e) => openEditModal(e.detail.item)} + on:delete={(e) => openDeleteModal(e.detail.item)} + emptyMessage="No proxies found" +> + + {#if column.key === 'auth'} + {#if item.username} + + {:else} + + {/if} + {/if} + + + + +{#if showCreateModal || (showEditModal && selectedProxy)} + +
+
+

+ {showCreateModal ? 'Create Proxy' : `Edit Proxy ${selectedProxy?.name}`} +

+
+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

+ Comma separated list of hosts, domains or CIDRs for which the proxy should be bypassed. +

+
+ +
+
+ + +
+
+ + +
+
+ {#if showEditModal} +

+ Leaving the password blank keeps the current password. Clearing the username removes the credentials. +

+ {/if} + +
+ + +
+
+
+
+{/if} + + +{#if showDeleteModal && selectedProxy} + { showDeleteModal = false; selectedProxy = null; }} + on:confirm={handleDeleteProxy} + /> +{/if} diff --git a/webapp/src/routes/proxies/page.integration.test.ts b/webapp/src/routes/proxies/page.integration.test.ts new file mode 100644 index 000000000..165ab7791 --- /dev/null +++ b/webapp/src/routes/proxies/page.integration.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/svelte'; + +// Unmock real components that setup.ts might mock +vi.unmock('$lib/components/PageHeader.svelte'); +vi.unmock('$lib/components/DataTable.svelte'); +vi.unmock('$lib/components/DeleteModal.svelte'); +vi.unmock('$lib/components/Modal.svelte'); +vi.unmock('$lib/components/Badge.svelte'); +vi.unmock('$lib/components/cells'); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$app/stores', () => ({})); + +vi.mock('$lib/api/client.js', () => ({ + garmApi: { + createProxy: vi.fn(), + updateProxy: vi.fn(), + deleteProxy: vi.fn() + } +})); + +vi.mock('$lib/stores/eager-cache.js', () => ({ + eagerCache: { + subscribe: vi.fn() + }, + eagerCacheManager: { + getProxies: vi.fn(), + retryResource: vi.fn() + } +})); + +vi.mock('$lib/stores/toast.js', () => ({ + toastStore: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + add: vi.fn() + } +})); + +vi.mock('$lib/stores/websocket.js', () => ({ + websocketStore: { + subscribeToEntity: vi.fn(() => vi.fn()) + } +})); + +import ProxiesPage from './+page.svelte'; + +// Proxy mock data +const mockProxies = [ + { + id: 1, + name: 'corporate-proxy', + description: 'Main corporate proxy', + http_proxy: 'http://proxy.example.com:3128', + https_proxy: 'http://proxy.example.com:3128', + no_proxy: 'localhost,127.0.0.1', + username: 'proxyuser', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + }, + { + id: 2, + name: 'dmz-proxy', + description: 'DMZ proxy without auth', + http_proxy: 'http://dmz.example.com:8080', + https_proxy: '', + no_proxy: '', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } +]; + +function mockCacheState(proxies: any[]) { + return { + proxies, + loaded: { proxies: true }, + loading: { proxies: false }, + errorMessages: { proxies: '' } + }; +} + +describe('Proxies Page - Integration Tests', () => { + let eagerCacheManager: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + const cacheModule = await import('$lib/stores/eager-cache.js'); + eagerCacheManager = cacheModule.eagerCacheManager; + const eagerCache = cacheModule.eagerCache; + + vi.mocked(eagerCache.subscribe).mockImplementation((callback: any) => { + callback(mockCacheState(mockProxies)); + return () => {}; + }); + + vi.mocked(eagerCacheManager.getProxies).mockResolvedValue(mockProxies); + }); + + it('renders page title and description', async () => { + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Proxies' })).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText(/Manage proxy definitions runners can use/i) + ).toBeInTheDocument(); + }); + }); + + it('shows "Create Proxy" button', async () => { + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Create Proxy/i })).toBeInTheDocument(); + }); + }); + + it('renders proxy data in the table', async () => { + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getAllByText('corporate-proxy').length).toBeGreaterThan(0); + }); + + await waitFor(() => { + expect(screen.getAllByText('dmz-proxy').length).toBeGreaterThan(0); + expect(screen.getAllByText('http://proxy.example.com:3128').length).toBeGreaterThan(0); + }); + }); + + it('shows authentication status for proxies', async () => { + render(ProxiesPage); + + await waitFor(() => { + // corporate-proxy has a username set, dmz-proxy does not + expect(screen.getAllByText('Authenticated').length).toBeGreaterThan(0); + expect(screen.getAllByText('No auth').length).toBeGreaterThan(0); + }); + }); + + it('handles search filtering', async () => { + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getAllByText('corporate-proxy').length).toBeGreaterThan(0); + }); + + const searchInput = screen.getByPlaceholderText(/Search proxies by name, description or URL/i); + await fireEvent.input(searchInput, { target: { value: 'dmz' } }); + + await waitFor(() => { + expect(screen.getAllByText('dmz-proxy').length).toBeGreaterThan(0); + }); + + await waitFor(() => { + expect(screen.queryByText('corporate-proxy')).not.toBeInTheDocument(); + }); + }); + + it('handles empty proxies list', async () => { + const { eagerCache } = await import('$lib/stores/eager-cache.js'); + + vi.mocked(eagerCache.subscribe).mockImplementation((callback: any) => { + callback(mockCacheState([])); + return () => {}; + }); + vi.mocked(eagerCacheManager.getProxies).mockResolvedValue([]); + + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getByText(/No proxies found/i)).toBeInTheDocument(); + }); + }); + + it('opens the create modal with form fields when "Create Proxy" is clicked', async () => { + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Create Proxy/i })).toBeInTheDocument(); + }); + + await fireEvent.click(screen.getByRole('button', { name: /Create Proxy/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/^Name/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Description/i)).toBeInTheDocument(); + expect(screen.getByLabelText('HTTP Proxy')).toBeInTheDocument(); + expect(screen.getByLabelText('HTTPS Proxy')).toBeInTheDocument(); + expect(screen.getByLabelText(/No Proxy/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Password/i)).toBeInTheDocument(); + }); + }); + + it('creates a proxy when the create form is submitted', async () => { + const { garmApi } = await import('$lib/api/client.js'); + vi.mocked(garmApi.createProxy).mockResolvedValue(mockProxies[0] as any); + + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Create Proxy/i })).toBeInTheDocument(); + }); + + await fireEvent.click(screen.getByRole('button', { name: /Create Proxy/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/^Name/)).toBeInTheDocument(); + }); + + await fireEvent.input(screen.getByLabelText(/^Name/), { target: { value: 'new-proxy' } }); + await fireEvent.input(screen.getByLabelText('HTTP Proxy'), { + target: { value: 'http://new.example.com:3128' } + }); + + // The submit button inside the modal form + const submitButtons = screen.getAllByRole('button', { name: /^Create Proxy$/i }); + await fireEvent.click(submitButtons[submitButtons.length - 1]); + + await waitFor(() => { + expect(garmApi.createProxy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'new-proxy', + http_proxy: 'http://new.example.com:3128' + }) + ); + }); + }); + + it('opens the edit modal prefilled and with a blank password', async () => { + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getAllByText('corporate-proxy').length).toBeGreaterThan(0); + }); + + // Both mobile and desktop views render edit buttons; click the first one + const editButtons = screen.getAllByRole('button', { name: /Edit/i }); + await fireEvent.click(editButtons[0]); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Edit Proxy corporate-proxy/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/^Name/)).toHaveValue('corporate-proxy'); + expect(screen.getByLabelText(/Username/i)).toHaveValue('proxyuser'); + expect(screen.getByLabelText(/Password/i)).toHaveValue(''); + expect( + screen.getByText(/Leaving the password blank keeps the current password/i) + ).toBeInTheDocument(); + }); + }); + + it('deletes a proxy after confirmation', async () => { + const { garmApi } = await import('$lib/api/client.js'); + vi.mocked(garmApi.deleteProxy).mockResolvedValue(undefined); + + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getAllByText('corporate-proxy').length).toBeGreaterThan(0); + }); + + const deleteButtons = screen.getAllByRole('button', { name: /Delete/i }); + await fireEvent.click(deleteButtons[0]); + + await waitFor(() => { + expect(screen.getByText(/Are you sure you want to delete this proxy/i)).toBeInTheDocument(); + }); + + const confirmButton = screen.getByRole('button', { name: /^Delete$/i }); + await fireEvent.click(confirmButton); + + await waitFor(() => { + expect(garmApi.deleteProxy).toHaveBeenCalledWith(1); + }); + }); + + it('surfaces the API error when deletion is blocked', async () => { + const { garmApi } = await import('$lib/api/client.js'); + const { toastStore } = await import('$lib/stores/toast.js'); + vi.mocked(garmApi.deleteProxy).mockRejectedValue( + new Error('proxy is in use by one or more pools or scale sets') + ); + + render(ProxiesPage); + + await waitFor(() => { + expect(screen.getAllByText('corporate-proxy').length).toBeGreaterThan(0); + }); + + const deleteButtons = screen.getAllByRole('button', { name: /Delete/i }); + await fireEvent.click(deleteButtons[0]); + + await waitFor(() => { + expect(screen.getByText(/Are you sure you want to delete this proxy/i)).toBeInTheDocument(); + }); + + const confirmButton = screen.getByRole('button', { name: /^Delete$/i }); + await fireEvent.click(confirmButton); + + await waitFor(() => { + expect(garmApi.deleteProxy).toHaveBeenCalledWith(1); + expect(toastStore.add).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + title: 'Failed to delete proxy' + }) + ); + }); + }); +}); diff --git a/webapp/swagger.yaml b/webapp/swagger.yaml index 69ae3d7c7..362abda0c 100644 --- a/webapp/swagger.yaml +++ b/webapp/swagger.yaml @@ -379,6 +379,11 @@ definitions: provider_name: type: string x-go-name: ProviderName + proxy_id: + description: ProxyID is the ID of the proxy definition runners in this pool will use. + format: uint64 + type: integer + x-go-name: ProxyID runner_bootstrap_timeout: format: uint64 type: integer @@ -397,6 +402,38 @@ definitions: x-go-name: TemplateID type: object x-go-package: github.com/cloudbase/garm/params + CreateProxyParams: + properties: + description: + type: string + x-go-name: Description + http_proxy: + description: HTTPProxy is the proxy URL used for plain HTTP requests. + type: string + x-go-name: HTTPProxy + https_proxy: + description: HTTPSProxy is the proxy URL used for HTTPS requests. + type: string + x-go-name: HTTPSProxy + name: + type: string + x-go-name: Name + no_proxy: + description: |- + NoProxy is a comma separated list of hosts, domains or CIDRs for + which the proxy should be bypassed. + type: string + x-go-name: NoProxy + password: + description: Password is the password used to authenticate to the proxy. + type: string + x-go-name: Password + username: + description: Username is the username used to authenticate to the proxy. + type: string + x-go-name: Username + type: object + x-go-package: github.com/cloudbase/garm/params CreateRepoParams: properties: agent_mode: @@ -470,6 +507,13 @@ definitions: provider_name: type: string x-go-name: ProviderName + proxy_id: + description: |- + ProxyID is the ID of the proxy definition runners in this scale set + will use. + format: uint64 + type: integer + x-go-name: ProxyID runner_bootstrap_timeout: format: uint64 type: integer @@ -1699,6 +1743,17 @@ definitions: provider_name: type: string x-go-name: ProviderName + proxy_id: + description: |- + ProxyID is the ID of the proxy definition that will be used by runners + spawned in this pool. Runners will use the proxy settings to reach back + to GARM, the forge and any other resources they need during setup. + format: uint64 + type: integer + x-go-name: ProxyID + proxy_name: + type: string + x-go-name: ProxyName repo_id: type: string x-go-name: RepoID @@ -1770,6 +1825,54 @@ definitions: $ref: '#/definitions/Provider' type: array x-go-package: github.com/cloudbase/garm/params + Proxies: + description: used by swagger client generated code + items: + $ref: '#/definitions/Proxy' + type: array + x-go-package: github.com/cloudbase/garm/params + Proxy: + properties: + created_at: + format: date-time + type: string + x-go-name: CreatedAt + description: + type: string + x-go-name: Description + http_proxy: + description: HTTPProxy is the proxy URL used for plain HTTP requests. + type: string + x-go-name: HTTPProxy + https_proxy: + description: HTTPSProxy is the proxy URL used for HTTPS requests. + type: string + x-go-name: HTTPSProxy + id: + format: uint64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + no_proxy: + description: |- + NoProxy is a comma separated list of hosts, domains or CIDRs for + which the proxy should be bypassed. + type: string + x-go-name: NoProxy + updated_at: + format: date-time + type: string + x-go-name: UpdatedAt + username: + description: |- + Username is the username used to authenticate to the proxy. If set, + it will be composed into the final proxy URLs handed to runners. + type: string + x-go-name: Username + type: object + x-go-package: github.com/cloudbase/garm/params Repositories: description: used by swagger client generated code items: @@ -1975,6 +2078,17 @@ definitions: provider_name: type: string x-go-name: ProviderName + proxy_id: + description: |- + ProxyID is the ID of the proxy definition that will be used by runners + spawned in this scale set. Runners will use the proxy settings to reach + back to GARM, the forge and any other resources they need during setup. + format: uint64 + type: integer + x-go-name: ProxyID + proxy_name: + type: string + x-go-name: ProxyName repo_id: type: string x-go-name: RepoID @@ -2276,6 +2390,13 @@ definitions: format: uint64 type: integer x-go-name: Priority + proxy_id: + description: |- + ProxyID is the ID of the proxy definition runners in this pool will + use. Setting it to 0 removes the proxy from the pool. + format: uint64 + type: integer + x-go-name: ProxyID runner_bootstrap_timeout: format: uint64 type: integer @@ -2294,6 +2415,43 @@ definitions: x-go-name: TemplateID type: object x-go-package: github.com/cloudbase/garm/params + UpdateProxyParams: + properties: + description: + type: string + x-go-name: Description + http_proxy: + description: HTTPProxy is the proxy URL used for plain HTTP requests. + type: string + x-go-name: HTTPProxy + https_proxy: + description: HTTPSProxy is the proxy URL used for HTTPS requests. + type: string + x-go-name: HTTPSProxy + name: + type: string + x-go-name: Name + no_proxy: + description: |- + NoProxy is a comma separated list of hosts, domains or CIDRs for + which the proxy should be bypassed. Setting it to an empty string + clears the value. + type: string + x-go-name: NoProxy + password: + description: |- + Password is the password used to authenticate to the proxy. Setting + it to an empty string clears the password. + type: string + x-go-name: Password + username: + description: |- + Username is the username used to authenticate to the proxy. Setting + it to an empty string clears the proxy credentials. + type: string + x-go-name: Username + type: object + x-go-package: github.com/cloudbase/garm/params UpdateScaleSetParams: properties: enable_shell: @@ -2329,6 +2487,13 @@ definitions: $ref: '#/definitions/OSArch' os_type: $ref: '#/definitions/OSType' + proxy_id: + description: |- + ProxyID is the ID of the proxy definition runners in this scale set + will use. Setting it to 0 removes the proxy from the scale set. + format: uint64 + type: integer + x-go-name: ProxyID runner_bootstrap_timeout: format: uint64 type: integer @@ -4284,6 +4449,109 @@ paths: summary: List all providers. tags: - providers + /proxies: + get: + operationId: ListProxies + responses: + "200": + description: Proxies + schema: + $ref: '#/definitions/Proxies' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: List proxies. + tags: + - proxies + post: + operationId: CreateProxy + parameters: + - description: Parameters used when creating the proxy. + in: body + name: Body + required: true + schema: + $ref: '#/definitions/CreateProxyParams' + description: Parameters used when creating the proxy. + type: object + responses: + "200": + description: Proxy + schema: + $ref: '#/definitions/Proxy' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Create proxy with the parameters given. + tags: + - proxies + /proxies/{proxyID}: + delete: + operationId: DeleteProxy + parameters: + - description: ID of the proxy to delete. + in: path + name: proxyID + required: true + type: number + responses: + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Delete proxy by ID. + tags: + - proxies + get: + operationId: GetProxy + parameters: + - description: ID of the proxy to fetch. + in: path + name: proxyID + required: true + type: number + responses: + "200": + description: Proxy + schema: + $ref: '#/definitions/Proxy' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Get proxy by ID. + tags: + - proxies + put: + operationId: UpdateProxy + parameters: + - description: ID of the proxy to update. + in: path + name: proxyID + required: true + type: number + - description: Parameters used when updating the proxy. + in: body + name: Body + required: true + schema: + $ref: '#/definitions/UpdateProxyParams' + description: Parameters used when updating the proxy. + type: object + responses: + "200": + description: Proxy + schema: + $ref: '#/definitions/Proxy' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Update proxy with the parameters given. + tags: + - proxies /repositories: get: operationId: ListRepos diff --git a/workers/cache/cache.go b/workers/cache/cache.go index 6a6e8f8ff..3f228b2a0 100644 --- a/workers/cache/cache.go +++ b/workers/cache/cache.go @@ -184,6 +184,18 @@ func (w *Worker) loadAllInstances() error { return nil } +func (w *Worker) loadAllProxies() error { + proxies, err := w.store.ListProxies(w.ctx) + if err != nil { + return fmt.Errorf("listing proxies: %w", err) + } + + for _, proxy := range proxies { + cache.SetProxyCache(proxy) + } + return nil +} + func (w *Worker) loadAllGithubCredentials() error { creds, err := w.store.ListGithubCredentials(w.ctx) if err != nil { @@ -278,6 +290,13 @@ func (w *Worker) Start() error { return nil }) + g.Go(func() error { + if err := w.loadAllProxies(); err != nil { + return fmt.Errorf("loading all proxies: %w", err) + } + return nil + }) + if err := w.waitForErrorGroupOrContextCancelled(g); err != nil { return fmt.Errorf("waiting for error group: %w", err) } @@ -470,6 +489,20 @@ func (w *Worker) handleTemplateEvent(event common.ChangePayload) { } } +func (w *Worker) handleProxyEvent(event common.ChangePayload) { + proxy, ok := event.Payload.(params.Proxy) + if !ok { + slog.DebugContext(w.ctx, "invalid payload type for proxy event", "payload", event.Payload) + return + } + switch event.Operation { + case common.CreateOperation, common.UpdateOperation: + cache.SetProxyCache(proxy) + case common.DeleteOperation: + cache.DeleteProxy(proxy.ID) + } +} + func (w *Worker) handleCredentialsEvent(event common.ChangePayload) { credentials, ok := event.Payload.(params.ForgeCredentials) if !ok { @@ -557,6 +590,8 @@ func (w *Worker) handleEvent(event common.ChangePayload) { w.handleControllerInfoEvent(event) case common.TemplateEntityType: w.handleTemplateEvent(event) + case common.ProxyEntityType: + w.handleProxyEvent(event) case common.GithubEndpointEntityType: w.handleEndpointEvent(event) default: diff --git a/workers/provider/instance_manager.go b/workers/provider/instance_manager.go index 95b45f797..f276bd404 100644 --- a/workers/provider/instance_manager.go +++ b/workers/provider/instance_manager.go @@ -203,6 +203,15 @@ func (i *instanceManager) handleCreateInstanceInProvider(instance params.Instanc GitHubRunnerGroup: i.scaleSet.GitHubRunnerGroup, JitConfigEnabled: true, } + + if i.scaleSet.ProxyID != 0 { + proxy, ok := cache.GetProxy(i.scaleSet.ProxyID) + if !ok { + return fmt.Errorf("proxy %d (%s) set on scale set %d was not found in cache", i.scaleSet.ProxyID, i.scaleSet.ProxyName, i.scaleSet.ID) + } + bootstrapArgs.ProxyConfig = proxy.ProxyConfig() + } + // We use the template management system unless: // * there is no template associated with the pool/scale set // * user explicitly overwrites the install template via extra specs diff --git a/workers/websocket/events/params.go b/workers/websocket/events/params.go index dda16224f..efbe242b1 100644 --- a/workers/websocket/events/params.go +++ b/workers/websocket/events/params.go @@ -19,7 +19,7 @@ import ( type Filter struct { Operations []common.OperationType `json:"operations,omitempty" jsonschema:"title=operations,description=A list of operations to filter on,enum=create,enum=update,enum=delete"` - EntityType common.DatabaseEntityType `json:"entity-type,omitempty" jsonschema:"title=entity type,description=The type of entity to filter on,enum=repository,enum=organization,enum=enterprise,enum=pool,enum=user,enum=instance,enum=job,enum=controller,enum=github_credentials,enum=github_endpoint"` + EntityType common.DatabaseEntityType `json:"entity-type,omitempty" jsonschema:"title=entity type,description=The type of entity to filter on,enum=repository,enum=organization,enum=enterprise,enum=pool,enum=user,enum=instance,enum=job,enum=controller,enum=github_credentials,enum=github_endpoint,enum=proxy"` } func (f Filter) Validate() error { @@ -29,7 +29,7 @@ func (f Filter) Validate() error { common.PoolEntityType, common.UserEntityType, common.InstanceEntityType, common.JobEntityType, common.ControllerEntityType, common.GithubCredentialsEntityType, common.GiteaCredentialsEntityType, common.ScaleSetEntityType, common.GithubEndpointEntityType, - common.TemplateEntityType, common.FileObjectEntityType: + common.TemplateEntityType, common.FileObjectEntityType, common.ProxyEntityType: default: return common.ErrInvalidEntityType }