Handle garm-agent tools upload/sync

This change adds the ability to manage garm-agent tools downloads. Users
can:

* Set an upstream releases page (github releases api)
* Enable sync from upstream. In this case, GARM will automatically download
  garm-agent tools from the releases page and save them in the internal
  object store
* Manually upload tools. Manually uploaded tools for an OS/arch combination
  will never be overwritten by auto-sync. Usrs will need to delete manually
  uploaded tools to enable sync for that os/arch release.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This commit is contained in:
Gabriel Adrian Samfira 2026-02-07 14:54:12 +02:00 committed by Gabriel
parent c29e8d4459
commit def4b4aaf1
29 changed files with 2755 additions and 97 deletions

View file

@ -512,3 +512,33 @@ func (a *APIController) UpdateControllerHandler(w http.ResponseWriter, r *http.R
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}
// swagger:route POST /controller/tools/sync controller ForceToolsSync
//
// Force immediate sync of GARM agent tools.
//
// Forces an immediate sync of GARM agent tools by resetting the cached timestamp.
// This will trigger the background worker to fetch the latest tools from the configured
// release URL and sync them to the object store.
//
// Note: This endpoint requires that GARM agent tools sync is enabled. If sync is disabled,
// the request will return an error.
//
// Responses:
// 200: ControllerInfo
// 400: APIErrorResponse
// 401: APIErrorResponse
func (a *APIController) ForceToolsSyncHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
info, err := a.r.ForceToolsSync(ctx)
if err != nil {
handleError(ctx, w, err)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(info); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}

View file

@ -25,7 +25,9 @@ import (
"github.com/gorilla/mux"
gErrors "github.com/cloudbase/garm-provider-common/errors"
commonParams "github.com/cloudbase/garm-provider-common/params"
"github.com/cloudbase/garm/apiserver/params"
runnerParams "github.com/cloudbase/garm/params"
)
func (a *APIController) InstanceMetadataHandler(w http.ResponseWriter, r *http.Request) {
@ -238,6 +240,68 @@ func (a *APIController) RootCertificateBundleHandler(w http.ResponseWriter, r *h
}
}
// swagger:route POST /tools/garm-agent tools UploadGARMAgentTool
//
// Upload a GARM agent tool binary.
//
// Uploads a GARM agent tool for a specific OS and architecture.
// This will automatically replace any existing tool for the same OS/architecture combination.
//
// Uses custom headers for metadata:
//
// - X-Tool-Name: Name of the tool
//
// - X-Tool-Description: Description
//
// - X-Tool-OS-Type: OS type (linux or windows)
//
// - X-Tool-OS-Arch: Architecture (amd64 or arm64)
//
// - X-Tool-Version: Version string
//
// Responses:
// 200: FileObject
// 400: APIErrorResponse
// 401: APIErrorResponse
func (a *APIController) UploadGARMAgentToolHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get metadata from headers
toolName := r.Header.Get("X-Tool-Name")
toolDesc := r.Header.Get("X-Tool-Description")
toolOSType := r.Header.Get("X-Tool-OS-Type")
toolOSArch := r.Header.Get("X-Tool-OS-Arch")
toolVersion := r.Header.Get("X-Tool-Version")
if toolName == "" || toolOSType == "" || toolOSArch == "" || toolVersion == "" {
handleError(ctx, w, gErrors.NewBadRequestError("missing required headers: X-Tool-Name, X-Tool-OS-Type, X-Tool-OS-Arch, X-Tool-Version"))
return
}
// Build params
createParams := runnerParams.CreateGARMToolParams{
Name: toolName,
Description: toolDesc,
Size: r.ContentLength,
OSType: commonParams.OSType(toolOSType),
OSArch: commonParams.OSArch(toolOSArch),
Version: toolVersion,
Origin: "manual",
}
// Create the tool (this will handle cleanup of old versions)
result, err := a.r.CreateGARMTool(ctx, createParams, r.Body)
if err != nil {
handleError(ctx, w, err)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(result); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}
func (a *APIController) RunnerInstallScriptHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View file

@ -219,6 +219,9 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware
// Update controller
controllerRouter.Handle("/", http.HandlerFunc(han.UpdateControllerHandler)).Methods("PUT", "OPTIONS")
controllerRouter.Handle("", http.HandlerFunc(han.UpdateControllerHandler)).Methods("PUT", "OPTIONS")
// Force tools sync
controllerRouter.Handle("/tools/sync/", http.HandlerFunc(han.ForceToolsSyncHandler)).Methods("POST", "OPTIONS")
controllerRouter.Handle("/tools/sync", http.HandlerFunc(han.ForceToolsSyncHandler)).Methods("POST", "OPTIONS")
////////////////////////////////////
// API router for everything else //
@ -266,6 +269,9 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware
///////////////////////////////////////////////////////
apiRouter.Handle("/tools/garm-agent/", http.HandlerFunc(han.InstanceGARMToolsHandler)).Methods("GET", "OPTIONS")
apiRouter.Handle("/tools/garm-agent", http.HandlerFunc(han.InstanceGARMToolsHandler)).Methods("GET", "OPTIONS")
// Upload garm agent tool
apiRouter.Handle("/tools/garm-agent/", http.HandlerFunc(han.UploadGARMAgentToolHandler)).Methods("POST", "OPTIONS")
apiRouter.Handle("/tools/garm-agent", http.HandlerFunc(han.UploadGARMAgentToolHandler)).Methods("POST", "OPTIONS")
// Download garm agent
apiRouter.Handle("/tools/garm-agent/{objectID}/download/", http.HandlerFunc(han.InstanceGARMToolDownloadHandler)).Methods("GET", "OPTIONS")
apiRouter.Handle("/tools/garm-agent/{objectID}/download", http.HandlerFunc(han.InstanceGARMToolDownloadHandler)).Methods("GET", "OPTIONS")

View file

@ -494,6 +494,32 @@ paths:
summary: Get controller info.
tags:
- controllerInfo
/controller/tools/sync:
post:
description: |-
Forces an immediate sync of GARM agent tools by resetting the cached timestamp.
This will trigger the background worker to fetch the latest tools from the configured
release URL and sync them to the object store.
Note: This endpoint requires that GARM agent tools sync is enabled. If sync is disabled,
the request will return an error.
operationId: ForceToolsSync
responses:
"200":
description: ControllerInfo
schema:
$ref: '#/definitions/ControllerInfo'
"400":
description: APIErrorResponse
schema:
$ref: '#/definitions/APIErrorResponse'
"401":
description: APIErrorResponse
schema:
$ref: '#/definitions/APIErrorResponse'
summary: Force immediate sync of GARM agent tools.
tags:
- controller
/enterprises:
get:
operationId: ListEnterprises
@ -2599,6 +2625,39 @@ paths:
summary: List GARM agent tools.
tags:
- tools
post:
description: |-
Uploads a GARM agent tool for a specific OS and architecture.
This will automatically replace any existing tool for the same OS/architecture combination.
Uses custom headers for metadata:
X-Tool-Name: Name of the tool
X-Tool-Description: Description
X-Tool-OS-Type: OS type (linux or windows)
X-Tool-OS-Arch: Architecture (amd64 or arm64)
X-Tool-Version: Version string
operationId: UploadGARMAgentTool
responses:
"200":
description: FileObject
schema:
$ref: '#/definitions/FileObject'
"400":
description: APIErrorResponse
schema:
$ref: '#/definitions/APIErrorResponse'
"401":
description: APIErrorResponse
schema:
$ref: '#/definitions/APIErrorResponse'
summary: Upload a GARM agent tool binary.
tags:
- tools
produces:
- application/json
security:

View file

@ -56,11 +56,60 @@ type ClientOption func(*runtime.ClientOperation)
// ClientService is the interface for Client methods
type ClientService interface {
ForceToolsSync(params *ForceToolsSyncParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*ForceToolsSyncOK, error)
UpdateController(params *UpdateControllerParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*UpdateControllerOK, error)
SetTransport(transport runtime.ClientTransport)
}
/*
ForceToolsSync forces immediate sync of g a r m agent tools
Forces an immediate sync of GARM agent tools by resetting the cached timestamp.
This will trigger the background worker to fetch the latest tools from the configured
release URL and sync them to the object store.
Note: This endpoint requires that GARM agent tools sync is enabled. If sync is disabled,
the request will return an error.
*/
func (a *Client) ForceToolsSync(params *ForceToolsSyncParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*ForceToolsSyncOK, error) {
// TODO: Validate the params before sending
if params == nil {
params = NewForceToolsSyncParams()
}
op := &runtime.ClientOperation{
ID: "ForceToolsSync",
Method: "POST",
PathPattern: "/controller/tools/sync",
ProducesMediaTypes: []string{"application/json"},
ConsumesMediaTypes: []string{"application/json"},
Schemes: []string{"http"},
Params: params,
Reader: &ForceToolsSyncReader{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
}
success, ok := result.(*ForceToolsSyncOK)
if ok {
return success, nil
}
// unexpected success response
// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
msg := fmt.Sprintf("unexpected success response for ForceToolsSync: API contract not enforced by server. Client expected to get an error, but got: %T", result)
panic(msg)
}
/*
UpdateController updates controller
*/

View file

@ -0,0 +1,128 @@
// Code generated by go-swagger; DO NOT EDIT.
package controller
// 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"
)
// NewForceToolsSyncParams creates a new ForceToolsSyncParams 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 NewForceToolsSyncParams() *ForceToolsSyncParams {
return &ForceToolsSyncParams{
timeout: cr.DefaultTimeout,
}
}
// NewForceToolsSyncParamsWithTimeout creates a new ForceToolsSyncParams object
// with the ability to set a timeout on a request.
func NewForceToolsSyncParamsWithTimeout(timeout time.Duration) *ForceToolsSyncParams {
return &ForceToolsSyncParams{
timeout: timeout,
}
}
// NewForceToolsSyncParamsWithContext creates a new ForceToolsSyncParams object
// with the ability to set a context for a request.
func NewForceToolsSyncParamsWithContext(ctx context.Context) *ForceToolsSyncParams {
return &ForceToolsSyncParams{
Context: ctx,
}
}
// NewForceToolsSyncParamsWithHTTPClient creates a new ForceToolsSyncParams object
// with the ability to set a custom HTTPClient for a request.
func NewForceToolsSyncParamsWithHTTPClient(client *http.Client) *ForceToolsSyncParams {
return &ForceToolsSyncParams{
HTTPClient: client,
}
}
/*
ForceToolsSyncParams contains all the parameters to send to the API endpoint
for the force tools sync operation.
Typically these are written to a http.Request.
*/
type ForceToolsSyncParams struct {
timeout time.Duration
Context context.Context
HTTPClient *http.Client
}
// WithDefaults hydrates default values in the force tools sync params (not the query body).
//
// All values with no default are reset to their zero value.
func (o *ForceToolsSyncParams) WithDefaults() *ForceToolsSyncParams {
o.SetDefaults()
return o
}
// SetDefaults hydrates default values in the force tools sync params (not the query body).
//
// All values with no default are reset to their zero value.
func (o *ForceToolsSyncParams) SetDefaults() {
// no default values defined for this parameter
}
// WithTimeout adds the timeout to the force tools sync params
func (o *ForceToolsSyncParams) WithTimeout(timeout time.Duration) *ForceToolsSyncParams {
o.SetTimeout(timeout)
return o
}
// SetTimeout adds the timeout to the force tools sync params
func (o *ForceToolsSyncParams) SetTimeout(timeout time.Duration) {
o.timeout = timeout
}
// WithContext adds the context to the force tools sync params
func (o *ForceToolsSyncParams) WithContext(ctx context.Context) *ForceToolsSyncParams {
o.SetContext(ctx)
return o
}
// SetContext adds the context to the force tools sync params
func (o *ForceToolsSyncParams) SetContext(ctx context.Context) {
o.Context = ctx
}
// WithHTTPClient adds the HTTPClient to the force tools sync params
func (o *ForceToolsSyncParams) WithHTTPClient(client *http.Client) *ForceToolsSyncParams {
o.SetHTTPClient(client)
return o
}
// SetHTTPClient adds the HTTPClient to the force tools sync params
func (o *ForceToolsSyncParams) SetHTTPClient(client *http.Client) {
o.HTTPClient = client
}
// WriteToRequest writes these params to a swagger request
func (o *ForceToolsSyncParams) 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
}

View file

@ -0,0 +1,253 @@
// Code generated by go-swagger; DO NOT EDIT.
package controller
// 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"
"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"
)
// ForceToolsSyncReader is a Reader for the ForceToolsSync structure.
type ForceToolsSyncReader struct {
formats strfmt.Registry
}
// ReadResponse reads a server response into the received o.
func (o *ForceToolsSyncReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
switch response.Code() {
case 200:
result := NewForceToolsSyncOK()
if err := result.readResponse(response, consumer, o.formats); err != nil {
return nil, err
}
return result, nil
case 400:
result := NewForceToolsSyncBadRequest()
if err := result.readResponse(response, consumer, o.formats); err != nil {
return nil, err
}
return nil, result
case 401:
result := NewForceToolsSyncUnauthorized()
if err := result.readResponse(response, consumer, o.formats); err != nil {
return nil, err
}
return nil, result
default:
return nil, runtime.NewAPIError("[POST /controller/tools/sync] ForceToolsSync", response, response.Code())
}
}
// NewForceToolsSyncOK creates a ForceToolsSyncOK with default headers values
func NewForceToolsSyncOK() *ForceToolsSyncOK {
return &ForceToolsSyncOK{}
}
/*
ForceToolsSyncOK describes a response with status code 200, with default header values.
ControllerInfo
*/
type ForceToolsSyncOK struct {
Payload garm_params.ControllerInfo
}
// IsSuccess returns true when this force tools sync o k response has a 2xx status code
func (o *ForceToolsSyncOK) IsSuccess() bool {
return true
}
// IsRedirect returns true when this force tools sync o k response has a 3xx status code
func (o *ForceToolsSyncOK) IsRedirect() bool {
return false
}
// IsClientError returns true when this force tools sync o k response has a 4xx status code
func (o *ForceToolsSyncOK) IsClientError() bool {
return false
}
// IsServerError returns true when this force tools sync o k response has a 5xx status code
func (o *ForceToolsSyncOK) IsServerError() bool {
return false
}
// IsCode returns true when this force tools sync o k response a status code equal to that given
func (o *ForceToolsSyncOK) IsCode(code int) bool {
return code == 200
}
// Code gets the status code for the force tools sync o k response
func (o *ForceToolsSyncOK) Code() int {
return 200
}
func (o *ForceToolsSyncOK) Error() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /controller/tools/sync][%d] forceToolsSyncOK %s", 200, payload)
}
func (o *ForceToolsSyncOK) String() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /controller/tools/sync][%d] forceToolsSyncOK %s", 200, payload)
}
func (o *ForceToolsSyncOK) GetPayload() garm_params.ControllerInfo {
return o.Payload
}
func (o *ForceToolsSyncOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
// response payload
if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF {
return err
}
return nil
}
// NewForceToolsSyncBadRequest creates a ForceToolsSyncBadRequest with default headers values
func NewForceToolsSyncBadRequest() *ForceToolsSyncBadRequest {
return &ForceToolsSyncBadRequest{}
}
/*
ForceToolsSyncBadRequest describes a response with status code 400, with default header values.
APIErrorResponse
*/
type ForceToolsSyncBadRequest struct {
Payload apiserver_params.APIErrorResponse
}
// IsSuccess returns true when this force tools sync bad request response has a 2xx status code
func (o *ForceToolsSyncBadRequest) IsSuccess() bool {
return false
}
// IsRedirect returns true when this force tools sync bad request response has a 3xx status code
func (o *ForceToolsSyncBadRequest) IsRedirect() bool {
return false
}
// IsClientError returns true when this force tools sync bad request response has a 4xx status code
func (o *ForceToolsSyncBadRequest) IsClientError() bool {
return true
}
// IsServerError returns true when this force tools sync bad request response has a 5xx status code
func (o *ForceToolsSyncBadRequest) IsServerError() bool {
return false
}
// IsCode returns true when this force tools sync bad request response a status code equal to that given
func (o *ForceToolsSyncBadRequest) IsCode(code int) bool {
return code == 400
}
// Code gets the status code for the force tools sync bad request response
func (o *ForceToolsSyncBadRequest) Code() int {
return 400
}
func (o *ForceToolsSyncBadRequest) Error() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /controller/tools/sync][%d] forceToolsSyncBadRequest %s", 400, payload)
}
func (o *ForceToolsSyncBadRequest) String() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /controller/tools/sync][%d] forceToolsSyncBadRequest %s", 400, payload)
}
func (o *ForceToolsSyncBadRequest) GetPayload() apiserver_params.APIErrorResponse {
return o.Payload
}
func (o *ForceToolsSyncBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
// response payload
if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF {
return err
}
return nil
}
// NewForceToolsSyncUnauthorized creates a ForceToolsSyncUnauthorized with default headers values
func NewForceToolsSyncUnauthorized() *ForceToolsSyncUnauthorized {
return &ForceToolsSyncUnauthorized{}
}
/*
ForceToolsSyncUnauthorized describes a response with status code 401, with default header values.
APIErrorResponse
*/
type ForceToolsSyncUnauthorized struct {
Payload apiserver_params.APIErrorResponse
}
// IsSuccess returns true when this force tools sync unauthorized response has a 2xx status code
func (o *ForceToolsSyncUnauthorized) IsSuccess() bool {
return false
}
// IsRedirect returns true when this force tools sync unauthorized response has a 3xx status code
func (o *ForceToolsSyncUnauthorized) IsRedirect() bool {
return false
}
// IsClientError returns true when this force tools sync unauthorized response has a 4xx status code
func (o *ForceToolsSyncUnauthorized) IsClientError() bool {
return true
}
// IsServerError returns true when this force tools sync unauthorized response has a 5xx status code
func (o *ForceToolsSyncUnauthorized) IsServerError() bool {
return false
}
// IsCode returns true when this force tools sync unauthorized response a status code equal to that given
func (o *ForceToolsSyncUnauthorized) IsCode(code int) bool {
return code == 401
}
// Code gets the status code for the force tools sync unauthorized response
func (o *ForceToolsSyncUnauthorized) Code() int {
return 401
}
func (o *ForceToolsSyncUnauthorized) Error() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /controller/tools/sync][%d] forceToolsSyncUnauthorized %s", 401, payload)
}
func (o *ForceToolsSyncUnauthorized) String() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /controller/tools/sync][%d] forceToolsSyncUnauthorized %s", 401, payload)
}
func (o *ForceToolsSyncUnauthorized) GetPayload() apiserver_params.APIErrorResponse {
return o.Payload
}
func (o *ForceToolsSyncUnauthorized) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
// response payload
if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF {
return err
}
return nil
}

View file

@ -58,6 +58,8 @@ type ClientOption func(*runtime.ClientOperation)
type ClientService interface {
GarmAgentList(params *GarmAgentListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GarmAgentListOK, error)
UploadGARMAgentTool(params *UploadGARMAgentToolParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*UploadGARMAgentToolOK, error)
SetTransport(transport runtime.ClientTransport)
}
@ -100,6 +102,61 @@ func (a *Client) GarmAgentList(params *GarmAgentListParams, authInfo runtime.Cli
panic(msg)
}
/*
UploadGARMAgentTool uploads a g a r m agent tool binary
Uploads a GARM agent tool for a specific OS and architecture.
This will automatically replace any existing tool for the same OS/architecture combination.
Uses custom headers for metadata:
X-Tool-Name: Name of the tool
X-Tool-Description: Description
X-Tool-OS-Type: OS type (linux or windows)
X-Tool-OS-Arch: Architecture (amd64 or arm64)
X-Tool-Version: Version string
*/
func (a *Client) UploadGARMAgentTool(params *UploadGARMAgentToolParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*UploadGARMAgentToolOK, error) {
// TODO: Validate the params before sending
if params == nil {
params = NewUploadGARMAgentToolParams()
}
op := &runtime.ClientOperation{
ID: "UploadGARMAgentTool",
Method: "POST",
PathPattern: "/tools/garm-agent",
ProducesMediaTypes: []string{"application/json"},
ConsumesMediaTypes: []string{"application/json"},
Schemes: []string{"http"},
Params: params,
Reader: &UploadGARMAgentToolReader{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
}
success, ok := result.(*UploadGARMAgentToolOK)
if ok {
return success, nil
}
// unexpected success response
// safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue
msg := fmt.Sprintf("unexpected success response for UploadGARMAgentTool: API contract not enforced by server. Client expected to get an error, but got: %T", result)
panic(msg)
}
// SetTransport changes the transport on the client
func (a *Client) SetTransport(transport runtime.ClientTransport) {
a.transport = transport

View file

@ -0,0 +1,128 @@
// Code generated by go-swagger; DO NOT EDIT.
package tools
// 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"
)
// NewUploadGARMAgentToolParams creates a new UploadGARMAgentToolParams 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 NewUploadGARMAgentToolParams() *UploadGARMAgentToolParams {
return &UploadGARMAgentToolParams{
timeout: cr.DefaultTimeout,
}
}
// NewUploadGARMAgentToolParamsWithTimeout creates a new UploadGARMAgentToolParams object
// with the ability to set a timeout on a request.
func NewUploadGARMAgentToolParamsWithTimeout(timeout time.Duration) *UploadGARMAgentToolParams {
return &UploadGARMAgentToolParams{
timeout: timeout,
}
}
// NewUploadGARMAgentToolParamsWithContext creates a new UploadGARMAgentToolParams object
// with the ability to set a context for a request.
func NewUploadGARMAgentToolParamsWithContext(ctx context.Context) *UploadGARMAgentToolParams {
return &UploadGARMAgentToolParams{
Context: ctx,
}
}
// NewUploadGARMAgentToolParamsWithHTTPClient creates a new UploadGARMAgentToolParams object
// with the ability to set a custom HTTPClient for a request.
func NewUploadGARMAgentToolParamsWithHTTPClient(client *http.Client) *UploadGARMAgentToolParams {
return &UploadGARMAgentToolParams{
HTTPClient: client,
}
}
/*
UploadGARMAgentToolParams contains all the parameters to send to the API endpoint
for the upload g a r m agent tool operation.
Typically these are written to a http.Request.
*/
type UploadGARMAgentToolParams struct {
timeout time.Duration
Context context.Context
HTTPClient *http.Client
}
// WithDefaults hydrates default values in the upload g a r m agent tool params (not the query body).
//
// All values with no default are reset to their zero value.
func (o *UploadGARMAgentToolParams) WithDefaults() *UploadGARMAgentToolParams {
o.SetDefaults()
return o
}
// SetDefaults hydrates default values in the upload g a r m agent tool params (not the query body).
//
// All values with no default are reset to their zero value.
func (o *UploadGARMAgentToolParams) SetDefaults() {
// no default values defined for this parameter
}
// WithTimeout adds the timeout to the upload g a r m agent tool params
func (o *UploadGARMAgentToolParams) WithTimeout(timeout time.Duration) *UploadGARMAgentToolParams {
o.SetTimeout(timeout)
return o
}
// SetTimeout adds the timeout to the upload g a r m agent tool params
func (o *UploadGARMAgentToolParams) SetTimeout(timeout time.Duration) {
o.timeout = timeout
}
// WithContext adds the context to the upload g a r m agent tool params
func (o *UploadGARMAgentToolParams) WithContext(ctx context.Context) *UploadGARMAgentToolParams {
o.SetContext(ctx)
return o
}
// SetContext adds the context to the upload g a r m agent tool params
func (o *UploadGARMAgentToolParams) SetContext(ctx context.Context) {
o.Context = ctx
}
// WithHTTPClient adds the HTTPClient to the upload g a r m agent tool params
func (o *UploadGARMAgentToolParams) WithHTTPClient(client *http.Client) *UploadGARMAgentToolParams {
o.SetHTTPClient(client)
return o
}
// SetHTTPClient adds the HTTPClient to the upload g a r m agent tool params
func (o *UploadGARMAgentToolParams) SetHTTPClient(client *http.Client) {
o.HTTPClient = client
}
// WriteToRequest writes these params to a swagger request
func (o *UploadGARMAgentToolParams) 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
}

View file

@ -0,0 +1,253 @@
// Code generated by go-swagger; DO NOT EDIT.
package tools
// 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"
"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"
)
// UploadGARMAgentToolReader is a Reader for the UploadGARMAgentTool structure.
type UploadGARMAgentToolReader struct {
formats strfmt.Registry
}
// ReadResponse reads a server response into the received o.
func (o *UploadGARMAgentToolReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
switch response.Code() {
case 200:
result := NewUploadGARMAgentToolOK()
if err := result.readResponse(response, consumer, o.formats); err != nil {
return nil, err
}
return result, nil
case 400:
result := NewUploadGARMAgentToolBadRequest()
if err := result.readResponse(response, consumer, o.formats); err != nil {
return nil, err
}
return nil, result
case 401:
result := NewUploadGARMAgentToolUnauthorized()
if err := result.readResponse(response, consumer, o.formats); err != nil {
return nil, err
}
return nil, result
default:
return nil, runtime.NewAPIError("[POST /tools/garm-agent] UploadGARMAgentTool", response, response.Code())
}
}
// NewUploadGARMAgentToolOK creates a UploadGARMAgentToolOK with default headers values
func NewUploadGARMAgentToolOK() *UploadGARMAgentToolOK {
return &UploadGARMAgentToolOK{}
}
/*
UploadGARMAgentToolOK describes a response with status code 200, with default header values.
FileObject
*/
type UploadGARMAgentToolOK struct {
Payload garm_params.FileObject
}
// IsSuccess returns true when this upload g a r m agent tool o k response has a 2xx status code
func (o *UploadGARMAgentToolOK) IsSuccess() bool {
return true
}
// IsRedirect returns true when this upload g a r m agent tool o k response has a 3xx status code
func (o *UploadGARMAgentToolOK) IsRedirect() bool {
return false
}
// IsClientError returns true when this upload g a r m agent tool o k response has a 4xx status code
func (o *UploadGARMAgentToolOK) IsClientError() bool {
return false
}
// IsServerError returns true when this upload g a r m agent tool o k response has a 5xx status code
func (o *UploadGARMAgentToolOK) IsServerError() bool {
return false
}
// IsCode returns true when this upload g a r m agent tool o k response a status code equal to that given
func (o *UploadGARMAgentToolOK) IsCode(code int) bool {
return code == 200
}
// Code gets the status code for the upload g a r m agent tool o k response
func (o *UploadGARMAgentToolOK) Code() int {
return 200
}
func (o *UploadGARMAgentToolOK) Error() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /tools/garm-agent][%d] uploadGARMAgentToolOK %s", 200, payload)
}
func (o *UploadGARMAgentToolOK) String() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /tools/garm-agent][%d] uploadGARMAgentToolOK %s", 200, payload)
}
func (o *UploadGARMAgentToolOK) GetPayload() garm_params.FileObject {
return o.Payload
}
func (o *UploadGARMAgentToolOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
// response payload
if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF {
return err
}
return nil
}
// NewUploadGARMAgentToolBadRequest creates a UploadGARMAgentToolBadRequest with default headers values
func NewUploadGARMAgentToolBadRequest() *UploadGARMAgentToolBadRequest {
return &UploadGARMAgentToolBadRequest{}
}
/*
UploadGARMAgentToolBadRequest describes a response with status code 400, with default header values.
APIErrorResponse
*/
type UploadGARMAgentToolBadRequest struct {
Payload apiserver_params.APIErrorResponse
}
// IsSuccess returns true when this upload g a r m agent tool bad request response has a 2xx status code
func (o *UploadGARMAgentToolBadRequest) IsSuccess() bool {
return false
}
// IsRedirect returns true when this upload g a r m agent tool bad request response has a 3xx status code
func (o *UploadGARMAgentToolBadRequest) IsRedirect() bool {
return false
}
// IsClientError returns true when this upload g a r m agent tool bad request response has a 4xx status code
func (o *UploadGARMAgentToolBadRequest) IsClientError() bool {
return true
}
// IsServerError returns true when this upload g a r m agent tool bad request response has a 5xx status code
func (o *UploadGARMAgentToolBadRequest) IsServerError() bool {
return false
}
// IsCode returns true when this upload g a r m agent tool bad request response a status code equal to that given
func (o *UploadGARMAgentToolBadRequest) IsCode(code int) bool {
return code == 400
}
// Code gets the status code for the upload g a r m agent tool bad request response
func (o *UploadGARMAgentToolBadRequest) Code() int {
return 400
}
func (o *UploadGARMAgentToolBadRequest) Error() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /tools/garm-agent][%d] uploadGARMAgentToolBadRequest %s", 400, payload)
}
func (o *UploadGARMAgentToolBadRequest) String() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /tools/garm-agent][%d] uploadGARMAgentToolBadRequest %s", 400, payload)
}
func (o *UploadGARMAgentToolBadRequest) GetPayload() apiserver_params.APIErrorResponse {
return o.Payload
}
func (o *UploadGARMAgentToolBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
// response payload
if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF {
return err
}
return nil
}
// NewUploadGARMAgentToolUnauthorized creates a UploadGARMAgentToolUnauthorized with default headers values
func NewUploadGARMAgentToolUnauthorized() *UploadGARMAgentToolUnauthorized {
return &UploadGARMAgentToolUnauthorized{}
}
/*
UploadGARMAgentToolUnauthorized describes a response with status code 401, with default header values.
APIErrorResponse
*/
type UploadGARMAgentToolUnauthorized struct {
Payload apiserver_params.APIErrorResponse
}
// IsSuccess returns true when this upload g a r m agent tool unauthorized response has a 2xx status code
func (o *UploadGARMAgentToolUnauthorized) IsSuccess() bool {
return false
}
// IsRedirect returns true when this upload g a r m agent tool unauthorized response has a 3xx status code
func (o *UploadGARMAgentToolUnauthorized) IsRedirect() bool {
return false
}
// IsClientError returns true when this upload g a r m agent tool unauthorized response has a 4xx status code
func (o *UploadGARMAgentToolUnauthorized) IsClientError() bool {
return true
}
// IsServerError returns true when this upload g a r m agent tool unauthorized response has a 5xx status code
func (o *UploadGARMAgentToolUnauthorized) IsServerError() bool {
return false
}
// IsCode returns true when this upload g a r m agent tool unauthorized response a status code equal to that given
func (o *UploadGARMAgentToolUnauthorized) IsCode(code int) bool {
return code == 401
}
// Code gets the status code for the upload g a r m agent tool unauthorized response
func (o *UploadGARMAgentToolUnauthorized) Code() int {
return 401
}
func (o *UploadGARMAgentToolUnauthorized) Error() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /tools/garm-agent][%d] uploadGARMAgentToolUnauthorized %s", 401, payload)
}
func (o *UploadGARMAgentToolUnauthorized) String() string {
payload, _ := json.Marshal(o.Payload)
return fmt.Sprintf("[POST /tools/garm-agent][%d] uploadGARMAgentToolUnauthorized %s", 401, payload)
}
func (o *UploadGARMAgentToolUnauthorized) GetPayload() apiserver_params.APIErrorResponse {
return o.Payload
}
func (o *UploadGARMAgentToolUnauthorized) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
// response payload
if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF {
return err
}
return nil
}

View file

@ -15,7 +15,13 @@
package cmd
import (
"debug/elf"
"debug/pe"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
@ -23,6 +29,7 @@ import (
apiClientController "github.com/cloudbase/garm/client/controller"
apiClientControllerInfo "github.com/cloudbase/garm/client/controller_info"
apiClientObject "github.com/cloudbase/garm/client/objects"
apiClientTools "github.com/cloudbase/garm/client/tools"
"github.com/cloudbase/garm/cmd/garm-cli/common"
"github.com/cloudbase/garm/params"
@ -157,8 +164,8 @@ garm-cli controller update \
var controllerToolsCmd = &cobra.Command{
Use: "tools",
Short: "Show information about garm tools",
Long: `Show information about GARM tools available in this controller.
Short: "Manage GARM agent tools",
Long: `Manage GARM agent tools available in this controller.
GARM has two modes by which we deploy runners:
@ -179,9 +186,18 @@ and whether or not the user forcefully deleted the BM/VM/container the runner wa
runner registered in github/gitea. At that point we can clean up the runner without having to thech the
github/gitea API or the API of the provider in which the runner was spawned.
This command lists the available tools in the controller. Tools can either sync automatically or be
manually uploaded. As long as the controller has access to the tools, agent mode can be enabled.
Tools can either sync automatically from a GitHub release URL or be manually uploaded.
As long as the controller has access to the tools, agent mode can be enabled.
`,
SilenceUsage: true,
Run: nil,
}
var controllerToolsListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List GARM agent tools",
Long: `List all GARM agent tools available in the controller.`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
if needsInit {
@ -204,6 +220,255 @@ manually uploaded. As long as the controller has access to the tools, agent mode
},
}
var controllerToolsShowCmd = &cobra.Command{
Use: "show <tool-id>",
Short: "Show details of a specific GARM agent tool",
Long: `Display detailed information about a specific GARM agent tool by ID.`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(_ *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
toolID := args[0]
getReq := apiClientObject.NewGetFileObjectParams().WithObjectID(toolID)
resp, err := apiCli.Objects.GetFileObject(getReq, authToken)
if err != nil {
return err
}
formatOneObject(resp.Payload)
return nil
},
}
var controllerToolsDeleteCmd = &cobra.Command{
Use: "delete <tool-id>",
Aliases: []string{"remove", "rm"},
Short: "Delete a GARM agent tool",
Long: `Delete a specific GARM agent tool from the object store by ID.`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(_ *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
toolID := args[0]
delReq := apiClientObject.NewDeleteFileObjectParams().WithObjectID(toolID)
err := apiCli.Objects.DeleteFileObject(delReq, authToken)
if err != nil {
return err
}
fmt.Printf("Tool %s deleted successfully\n", toolID)
return nil
},
}
var controllerToolsSyncCmd = &cobra.Command{
Use: "sync",
Short: "Force immediate sync of GARM agent tools",
Long: `Force an immediate sync of GARM agent tools from the configured release URL.
This command triggers the background worker to fetch the latest tools from the
configured GARM agent release URL and sync them to the object store.
Note: This command requires that GARM agent tools sync is enabled in the controller
configuration. If sync is disabled, the command will return an error.`,
SilenceUsage: true,
RunE: func(_ *cobra.Command, _ []string) error {
if needsInit {
return errNeedsInitError
}
// POST to /controller/tools/sync endpoint
// Since this is not auto-generated, we'll make a direct HTTP request
apiURL := fmt.Sprintf("%s/api/v1/controller/tools/sync", mgr.BaseURL)
req, err := http.NewRequest("POST", apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Add auth token
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", mgr.Token))
req.Header.Add("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("sync failed with status %d: %s", resp.StatusCode, string(body))
}
var ctrlInfo params.ControllerInfo
if err := json.NewDecoder(resp.Body).Decode(&ctrlInfo); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
fmt.Println("Tools sync initiated successfully")
formatInfo(ctrlInfo)
return nil
},
}
var (
toolFilePath string
toolOSType string
toolOSArch string
toolVersion string
toolName string
)
var controllerToolsUploadCmd = &cobra.Command{
Use: "upload",
Short: "Upload a GARM agent tool binary",
Long: `Upload a GARM agent tool binary for a specific OS and architecture.
This command uploads a tool and automatically:
- Sets origin=manual tag
- Overwrites any existing auto-synced tool for the same OS/architecture
- Ensures only one tool version per OS/architecture combination`,
SilenceUsage: true,
RunE: func(_ *cobra.Command, _ []string) error {
if needsInit {
return errNeedsInitError
}
// Default name if not provided
if toolName == "" {
toolName = fmt.Sprintf("garm-agent-%s-%s", toolOSType, toolOSArch)
if toolOSType == "windows" {
toolName += ".exe"
}
}
// Get file info for size
stat, err := os.Stat(toolFilePath)
if err != nil {
return fmt.Errorf("failed to access file: %w", err)
}
// Open the file
file, err := os.Open(toolFilePath)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Validate file type and architecture matches OS using standard library
if toolOSType == "linux" {
elfFile, err := elf.NewFile(file)
if err != nil {
return fmt.Errorf("file is not a valid ELF binary (required for Linux): %w", err)
}
defer elfFile.Close()
// Check file type is executable (ET_EXEC or ET_DYN for PIE)
if elfFile.Type != elf.ET_EXEC && elfFile.Type != elf.ET_DYN {
return fmt.Errorf("file is not a valid ELF executable (required for Linux): type is %v (must be ET_EXEC or ET_DYN)", elfFile.Type)
}
// Check architecture matches
var expectedMachine elf.Machine
var archName string
switch toolOSArch {
case "amd64":
expectedMachine = elf.EM_X86_64
archName = "x86-64"
case "arm64":
expectedMachine = elf.EM_AARCH64
archName = "ARM64"
}
if elfFile.Machine != expectedMachine {
return fmt.Errorf("file is ELF binary for %v, but %s (%s) was specified", elfFile.Machine, toolOSArch, archName)
}
}
if toolOSType == "windows" {
peFile, err := pe.NewFile(file)
if err != nil {
return fmt.Errorf("file is not a valid PE executable (required for Windows): %w", err)
}
defer peFile.Close()
// Check architecture matches
var expectedMachine uint16
var archName string
switch toolOSArch {
case "amd64":
expectedMachine = pe.IMAGE_FILE_MACHINE_AMD64
archName = "x86-64"
case "arm64":
expectedMachine = pe.IMAGE_FILE_MACHINE_ARM64
archName = "ARM64"
}
if peFile.Machine != expectedMachine {
return fmt.Errorf("file is PE executable for machine type 0x%x, but %s (%s) was specified", peFile.Machine, toolOSArch, archName)
}
}
// Seek back to beginning for upload
if _, err := file.Seek(0, 0); err != nil {
return fmt.Errorf("failed to seek to beginning of file: %w", err)
}
// Show initial progress
fmt.Printf("Uploading %s (%.2f MB)...\n", toolName, float64(stat.Size())/1024/1024)
// Create request to tools endpoint using custom headers
description := fmt.Sprintf("GARM Agent %s for %s/%s (manually uploaded)", toolVersion, toolOSType, toolOSArch)
req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/tools/garm-agent", mgr.BaseURL), file)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set auth and metadata headers
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", mgr.Token))
req.Header.Set("X-Tool-Name", toolName)
req.Header.Set("X-Tool-Description", description)
req.Header.Set("X-Tool-OS-Type", toolOSType)
req.Header.Set("X-Tool-OS-Arch", toolOSArch)
req.Header.Set("X-Tool-Version", toolVersion)
req.ContentLength = stat.Size()
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to upload: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Check for non-2xx status codes
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
var uploadedTool params.FileObject
if err := json.Unmarshal(data, &uploadedTool); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
fmt.Printf("\nTool uploaded successfully\n")
fmt.Printf("ID: %d\n", uploadedTool.ID)
fmt.Printf("Name: %s\n", uploadedTool.Name)
fmt.Printf("Size: %s\n", formatSize(uploadedTool.Size))
fmt.Printf("SHA256: %s\n", uploadedTool.SHA256)
return nil
},
}
func renderControllerInfoTable(info params.ControllerInfo) string {
t := table.NewWriter()
header := table.Row{"Field", "Value"}
@ -254,8 +519,28 @@ func init() {
controllerUpdateCmd.Flags().BoolVarP(&enableToolsSync, "enable-tools-sync", "s", false, "Enable or disable automatic garm tools sync.")
controllerUpdateCmd.Flags().UintVarP(&minimumJobAgeBackoff, "minimum-job-age-backoff", "b", 0, "The minimum job age backoff for the controller")
controllerToolsCmd.Flags().Int64Var(&fileObjPage, "page", 0, "The tools page to display")
controllerToolsCmd.Flags().Int64Var(&fileObjPageSize, "page-size", 25, "Total number of results per page")
controllerToolsListCmd.Flags().Int64Var(&fileObjPage, "page", 0, "The tools page to display")
controllerToolsListCmd.Flags().Int64Var(&fileObjPageSize, "page-size", 25, "Total number of results per page")
controllerToolsUploadCmd.Flags().StringVar(&toolFilePath, "file", "", "Path to the garm-agent binary file (required)")
controllerToolsUploadCmd.Flags().StringVar(&toolOSType, "os", "", "Operating system: linux or windows (required)")
controllerToolsUploadCmd.Flags().StringVar(&toolOSArch, "arch", "", "Architecture: amd64 or arm64 (required)")
controllerToolsUploadCmd.Flags().StringVar(&toolVersion, "version", "", "Version string, e.g., v1.0.0 (required)")
controllerToolsUploadCmd.Flags().StringVar(&toolName, "name", "", "Custom name for the tool (optional, defaults to garm-agent-{os}-{arch})")
controllerToolsUploadCmd.MarkFlagRequired("file")
controllerToolsUploadCmd.MarkFlagRequired("os")
controllerToolsUploadCmd.MarkFlagRequired("arch")
controllerToolsUploadCmd.MarkFlagRequired("version")
controllerToolsCmd.AddCommand(
controllerToolsListCmd,
controllerToolsShowCmd,
controllerToolsDeleteCmd,
controllerToolsSyncCmd,
controllerToolsUploadCmd,
)
controllerCmd.AddCommand(
controllerShowCmd,
controllerUpdateCmd,

View file

@ -11,6 +11,8 @@ import (
mock "github.com/stretchr/testify/mock"
params "github.com/cloudbase/garm/params"
time "time"
)
// Store is an autogenerated mock type for the Store type
@ -1290,6 +1292,63 @@ func (_c *Store_DeleteFileObject_Call) RunAndReturn(run func(context.Context, ui
return _c
}
// DeleteFileObjectsByTags provides a mock function with given fields: ctx, tags
func (_m *Store) DeleteFileObjectsByTags(ctx context.Context, tags []string) (int64, error) {
ret := _m.Called(ctx, tags)
if len(ret) == 0 {
panic("no return value specified for DeleteFileObjectsByTags")
}
var r0 int64
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []string) (int64, error)); ok {
return rf(ctx, tags)
}
if rf, ok := ret.Get(0).(func(context.Context, []string) int64); ok {
r0 = rf(ctx, tags)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {
r1 = rf(ctx, tags)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_DeleteFileObjectsByTags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFileObjectsByTags'
type Store_DeleteFileObjectsByTags_Call struct {
*mock.Call
}
// DeleteFileObjectsByTags is a helper method to define mock.On call
// - ctx context.Context
// - tags []string
func (_e *Store_Expecter) DeleteFileObjectsByTags(ctx interface{}, tags interface{}) *Store_DeleteFileObjectsByTags_Call {
return &Store_DeleteFileObjectsByTags_Call{Call: _e.mock.On("DeleteFileObjectsByTags", ctx, tags)}
}
func (_c *Store_DeleteFileObjectsByTags_Call) Run(run func(ctx context.Context, tags []string)) *Store_DeleteFileObjectsByTags_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].([]string))
})
return _c
}
func (_c *Store_DeleteFileObjectsByTags_Call) Return(_a0 int64, _a1 error) *Store_DeleteFileObjectsByTags_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_DeleteFileObjectsByTags_Call) RunAndReturn(run func(context.Context, []string) (int64, error)) *Store_DeleteFileObjectsByTags_Call {
_c.Call.Return(run)
return _c
}
// DeleteGiteaCredentials provides a mock function with given fields: ctx, id
func (_m *Store) DeleteGiteaCredentials(ctx context.Context, id uint) error {
ret := _m.Called(ctx, id)
@ -3339,6 +3398,61 @@ func (_c *Store_HasAdminUser_Call) RunAndReturn(run func(context.Context) bool)
return _c
}
// HasEntitiesWithAgentModeEnabled provides a mock function with no fields
func (_m *Store) HasEntitiesWithAgentModeEnabled() (bool, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for HasEntitiesWithAgentModeEnabled")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func() (bool, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_HasEntitiesWithAgentModeEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntitiesWithAgentModeEnabled'
type Store_HasEntitiesWithAgentModeEnabled_Call struct {
*mock.Call
}
// HasEntitiesWithAgentModeEnabled is a helper method to define mock.On call
func (_e *Store_Expecter) HasEntitiesWithAgentModeEnabled() *Store_HasEntitiesWithAgentModeEnabled_Call {
return &Store_HasEntitiesWithAgentModeEnabled_Call{Call: _e.mock.On("HasEntitiesWithAgentModeEnabled")}
}
func (_c *Store_HasEntitiesWithAgentModeEnabled_Call) Run(run func()) *Store_HasEntitiesWithAgentModeEnabled_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *Store_HasEntitiesWithAgentModeEnabled_Call) Return(_a0 bool, _a1 error) *Store_HasEntitiesWithAgentModeEnabled_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_HasEntitiesWithAgentModeEnabled_Call) RunAndReturn(run func() (bool, error)) *Store_HasEntitiesWithAgentModeEnabled_Call {
_c.Call.Return(run)
return _c
}
// InitController provides a mock function with no fields
func (_m *Store) InitController() (params.ControllerInfo, error) {
ret := _m.Called()
@ -4936,6 +5050,53 @@ func (_c *Store_UnlockJob_Call) RunAndReturn(run func(context.Context, int64, st
return _c
}
// UpdateCachedGARMAgentRelease provides a mock function with given fields: releaseData, fetchedAt
func (_m *Store) UpdateCachedGARMAgentRelease(releaseData []byte, fetchedAt time.Time) error {
ret := _m.Called(releaseData, fetchedAt)
if len(ret) == 0 {
panic("no return value specified for UpdateCachedGARMAgentRelease")
}
var r0 error
if rf, ok := ret.Get(0).(func([]byte, time.Time) error); ok {
r0 = rf(releaseData, fetchedAt)
} else {
r0 = ret.Error(0)
}
return r0
}
// Store_UpdateCachedGARMAgentRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateCachedGARMAgentRelease'
type Store_UpdateCachedGARMAgentRelease_Call struct {
*mock.Call
}
// UpdateCachedGARMAgentRelease is a helper method to define mock.On call
// - releaseData []byte
// - fetchedAt time.Time
func (_e *Store_Expecter) UpdateCachedGARMAgentRelease(releaseData interface{}, fetchedAt interface{}) *Store_UpdateCachedGARMAgentRelease_Call {
return &Store_UpdateCachedGARMAgentRelease_Call{Call: _e.mock.On("UpdateCachedGARMAgentRelease", releaseData, fetchedAt)}
}
func (_c *Store_UpdateCachedGARMAgentRelease_Call) Run(run func(releaseData []byte, fetchedAt time.Time)) *Store_UpdateCachedGARMAgentRelease_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].([]byte), args[1].(time.Time))
})
return _c
}
func (_c *Store_UpdateCachedGARMAgentRelease_Call) Return(_a0 error) *Store_UpdateCachedGARMAgentRelease_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *Store_UpdateCachedGARMAgentRelease_Call) RunAndReturn(run func([]byte, time.Time) error) *Store_UpdateCachedGARMAgentRelease_Call {
_c.Call.Return(run)
return _c
}
// UpdateController provides a mock function with given fields: info
func (_m *Store) UpdateController(info params.UpdateControllerParams) (params.ControllerInfo, error) {
ret := _m.Called(info)

View file

@ -17,6 +17,7 @@ package common
import (
"context"
"io"
"time"
commonParams "github.com/cloudbase/garm-provider-common/params"
"github.com/cloudbase/garm/params"
@ -136,6 +137,7 @@ type ControllerStore interface {
InitController() (params.ControllerInfo, error)
UpdateController(info params.UpdateControllerParams) (params.ControllerInfo, error)
HasEntitiesWithAgentModeEnabled() (bool, error)
UpdateCachedGARMAgentRelease(releaseData []byte, fetchedAt time.Time) error
}
type ScaleSetsStore interface {

View file

@ -17,7 +17,9 @@ package sql
import (
"errors"
"fmt"
"log/slog"
"net/url"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
@ -25,6 +27,7 @@ import (
runnerErrors "github.com/cloudbase/garm-provider-common/errors"
"github.com/cloudbase/garm/database/common"
"github.com/cloudbase/garm/params"
garmUtil "github.com/cloudbase/garm/util"
"github.com/cloudbase/garm/util/appdefaults"
)
@ -39,17 +42,30 @@ func dbControllerToCommonController(dbInfo ControllerInfo) (params.ControllerInf
}
ret := params.ControllerInfo{
ControllerID: dbInfo.ControllerID,
MetadataURL: dbInfo.MetadataURL,
WebhookURL: dbInfo.WebhookBaseURL,
ControllerWebhookURL: url,
CallbackURL: dbInfo.CallbackURL,
AgentURL: dbInfo.AgentURL,
MinimumJobAgeBackoff: dbInfo.MinimumJobAgeBackoff,
Version: appdefaults.GetVersion(),
GARMAgentReleasesURL: dbInfo.GARMAgentReleasesURL,
SyncGARMAgentTools: dbInfo.SyncGARMAgentTools,
ControllerID: dbInfo.ControllerID,
MetadataURL: dbInfo.MetadataURL,
WebhookURL: dbInfo.WebhookBaseURL,
ControllerWebhookURL: url,
CallbackURL: dbInfo.CallbackURL,
AgentURL: dbInfo.AgentURL,
MinimumJobAgeBackoff: dbInfo.MinimumJobAgeBackoff,
Version: appdefaults.GetVersion(),
GARMAgentReleasesURL: dbInfo.GARMAgentReleasesURL,
SyncGARMAgentTools: dbInfo.SyncGARMAgentTools,
CachedGARMAgentReleaseFetchedAt: dbInfo.CachedGARMAgentReleaseFetchedAt,
CachedGARMAgentRelease: dbInfo.CachedGARMAgentRelease,
}
// Parse cached release data to populate CachedGARMAgentTools
if len(dbInfo.CachedGARMAgentRelease) > 0 {
tools, err := garmUtil.ParseToolsFromRelease(dbInfo.CachedGARMAgentRelease)
if err != nil {
slog.Warn("failed to parse cached tools during DB conversion", "error", err)
} else {
ret.CachedGARMAgentTools = tools
}
}
return ret, nil
}
@ -183,3 +199,36 @@ func (s *sqlDatabase) UpdateController(info params.UpdateControllerParams) (para
}
return paramInfo, nil
}
func (s *sqlDatabase) UpdateCachedGARMAgentRelease(releaseData []byte, fetchedAt time.Time) error {
var dbInfo ControllerInfo
err := s.conn.Transaction(func(tx *gorm.DB) error {
q := tx.Model(&ControllerInfo{}).First(&dbInfo)
if q.Error != nil {
if errors.Is(q.Error, gorm.ErrRecordNotFound) {
return fmt.Errorf("error fetching controller info: %w", runnerErrors.ErrNotFound)
}
return fmt.Errorf("error fetching controller info: %w", q.Error)
}
dbInfo.CachedGARMAgentRelease = releaseData
dbInfo.CachedGARMAgentReleaseFetchedAt = &fetchedAt
q = tx.Save(&dbInfo)
if q.Error != nil {
return fmt.Errorf("error saving controller info: %w", q.Error)
}
return nil
})
if err != nil {
return fmt.Errorf("error updating cached release: %w", err)
}
paramInfo, err := dbControllerToCommonController(dbInfo)
if err != nil {
return fmt.Errorf("error converting controller info: %w", err)
}
s.sendNotify(common.ControllerEntityType, common.UpdateOperation, paramInfo)
return nil
}

View file

@ -71,6 +71,10 @@ type ControllerInfo struct {
// pick up the job. GARM would allow this amount of time for runners to react
// before spinning up a new one and potentially having to scale down later.
MinimumJobAgeBackoff uint
// CachedGARMAgentRelease stores the cached JSON response from GARMAgentReleasesURL
CachedGARMAgentRelease datatypes.JSON
// CachedGARMAgentReleaseFetchedAt is the timestamp when the release data was last fetched
CachedGARMAgentReleaseFetchedAt *time.Time
}
type Tag struct {

View file

@ -995,6 +995,14 @@ type ControllerInfo struct {
MinimumJobAgeBackoff uint `json:"minimum_job_age_backoff,omitempty"`
// Version is the version of the GARM controller.
Version string `json:"version,omitempty"`
// CachedGARMAgentReleaseFetchedAt is the timestamp when the release data was last fetched from GARMAgentReleasesURL
CachedGARMAgentReleaseFetchedAt *time.Time `json:"cached_garm_agent_release_fetched_at,omitempty"`
// CachedGARMAgentRelease stores the cached JSON response from GARMAgentReleasesURL.
// This field is not serialized to JSON (internal use only).
CachedGARMAgentRelease []byte `json:"-"`
// CachedGARMAgentTools stores the parsed tools from CachedGARMAgentRelease, indexed by "os_type/os_arch".
// This field is not serialized to JSON (internal use only).
CachedGARMAgentTools map[string]GARMAgentTool `json:"-"`
}
func (c *ControllerInfo) JobBackoff() time.Duration {
@ -1005,6 +1013,32 @@ func (c *ControllerInfo) JobBackoff() time.Duration {
return time.Duration(int64(c.MinimumJobAgeBackoff))
}
// GetCachedAgentTool returns the cached GARM agent tool for the specified OS type and architecture.
// Returns nil if no cache exists, if the cache is older than 24 hours, or if the tool is not found.
func (c *ControllerInfo) GetCachedAgentTool(osType, osArch string) *GARMAgentTool {
// Check staleness (24 hour threshold)
if c.CachedGARMAgentReleaseFetchedAt == nil {
return nil
}
if time.Since(*c.CachedGARMAgentReleaseFetchedAt) > 24*time.Hour {
return nil
}
// No parsed tools
if c.CachedGARMAgentTools == nil {
return nil
}
// Look up tool by "os_type/os_arch" key
key := osType + "/" + osArch
tool, ok := c.CachedGARMAgentTools[key]
if !ok {
return nil
}
return &tool
}
// swagger:model GithubRateLimit
type GithubRateLimit struct {
Limit int `json:"limit,omitempty"`
@ -1468,6 +1502,11 @@ type GARMAgentTool struct {
OSType commonParams.OSType `json:"os_type"`
OSArch commonParams.OSArch `json:"os_arch"`
DownloadURL string `json:"download_url"`
// Origin defines where the GARM agent tool originated from.
// When manually uploaded, this field is set to "manual". When synced
// from a release URL, this will hold the release URL where the tools
// originated from.
Origin string `json:"origin"`
}
// swagger:model GARMAgentToolsPaginatedResponse

View file

@ -920,6 +920,7 @@ type CreateGARMToolParams struct {
OSType commonParams.OSType `json:"os_type"`
OSArch commonParams.OSArch `json:"os_arch"`
Version string `json:"version"`
Origin string `json:"origin,omitempty"`
}
// swagger:model RestoreTemplateRequest

View file

@ -19,6 +19,7 @@ import (
"fmt"
"io"
"log/slog"
"strings"
runnerErrors "github.com/cloudbase/garm-provider-common/errors"
"github.com/cloudbase/garm/auth"
@ -93,7 +94,7 @@ func (r *Runner) CreateGARMTool(ctx context.Context, param params.CreateGARMTool
return params.FileObject{}, runnerErrors.NewBadRequestError("invalid os_arch: must be 'amd64' or 'arm64'")
}
// Build tags: category, os_type, os_arch, version
// Build tags: category, os_type, os_arch, version, origin
tags := []string{
garmAgentFileTag,
osTypeTag,
@ -101,6 +102,13 @@ func (r *Runner) CreateGARMTool(ctx context.Context, param params.CreateGARMTool
fmt.Sprintf("version=%s", param.Version),
}
// Add origin tag
origin := param.Origin
if origin == "" {
origin = "manual" // Default to manual if not specified
}
tags = append(tags, fmt.Sprintf("origin=%s", origin))
// Create the file object params
createParams := params.CreateFileObjectParams{
Name: param.Name,
@ -147,6 +155,15 @@ func (r *Runner) CreateGARMTool(ctx context.Context, param params.CreateGARMTool
for _, tool := range allTools.Results {
if tool.ID != newTool.ID {
// Check if we're overwriting a synced tool
var oldOrigin string
for _, tag := range tool.Tags {
if strings.HasPrefix(tag, "origin=") {
oldOrigin = tag[7:]
break
}
}
// Delete old version directly via store (bypass API check since this is internal)
if err := r.store.DeleteFileObject(ctx, tool.ID); err != nil {
slog.WarnContext(ctx, "failed to delete old garm-agent version during cleanup",
@ -158,10 +175,20 @@ func (r *Runner) CreateGARMTool(ctx context.Context, param params.CreateGARMTool
continue
}
deletedCount++
slog.DebugContext(ctx, "deleted old garm-agent version",
"tool_id", tool.ID,
"tool_name", tool.Name,
"tags", tool.Tags)
// Log with appropriate level based on what's being replaced
if oldOrigin != origin {
slog.InfoContext(ctx, "replaced garm-agent tool with different origin",
"tool_id", tool.ID,
"tool_name", tool.Name,
"old_origin", oldOrigin,
"new_origin", origin)
} else {
slog.DebugContext(ctx, "replaced old garm-agent version",
"tool_id", tool.ID,
"tool_name", tool.Name,
"origin", origin)
}
}
}

View file

@ -173,6 +173,33 @@ func (r *Runner) getRunnerInstallTemplateContext(instance params.Instance, entit
return installRunnerParams, nil
}
// getAgentTool retrieves the GARM agent tool based on sync configuration.
// If sync is enabled, it tries to get tools from the object store first, then falls back to cached release URL.
// If sync is disabled, it gets tools directly from the cached release URL.
// Returns nil if no tools are available from any source.
func (r *Runner) getAgentTool(ctx context.Context, osType commonParams.OSType, osArch commonParams.OSArch) *params.GARMAgentTool {
ctrlInfo := cache.ControllerInfo()
switch {
case ctrlInfo.SyncGARMAgentTools:
// Sync enabled: try to get tools from GARM object store
agentTools, err := r.GetGARMTools(ctx, 0, 1)
if err != nil && !errors.Is(err, runnerErrors.ErrNotFound) {
slog.WarnContext(ctx, "failed to query garm agent tools", "error", err)
}
if agentTools.TotalCount > 0 {
return &agentTools.Results[0]
}
// No tools in object store, fall through to get from release URL
slog.WarnContext(ctx, "sync enabled but no tools found in object store, falling back to release URL")
fallthrough
default:
// Sync disabled OR fallback from sync: get tools from cached release URL
tool := ctrlInfo.GetCachedAgentTool(string(osType), string(osArch))
return tool
}
}
func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetadata, error) {
instance, err := validateInstanceState(ctx)
if err != nil {
@ -224,6 +251,7 @@ func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetada
return params.InstanceMetadata{}, runnerErrors.NewUnprocessableError("invalid forge type: %s", dbEntity.Credentials.ForgeType)
}
ctrlInfo := cache.ControllerInfo()
ret := params.InstanceMetadata{
RunnerName: instance.Name,
RunnerLabels: getLabelsForInstance(instance),
@ -231,7 +259,7 @@ func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetada
MetadataAccess: params.MetadataServiceAccessDetails{
CallbackURL: instance.CallbackURL,
MetadataURL: instance.MetadataURL,
AgentURL: cache.ControllerInfo().AgentURL,
AgentURL: ctrlInfo.AgentURL,
},
ForgeType: dbEntity.Credentials.ForgeType,
JITEnabled: len(instance.JitConfiguration) > 0,
@ -240,22 +268,20 @@ func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetada
}
if dbEntity.AgentMode {
agentTools, err := r.GetGARMTools(ctx, 0, 25)
if err != nil {
if !errors.Is(err, runnerErrors.ErrNotFound) {
return params.InstanceMetadata{}, fmt.Errorf("failed to find garm agent tools: %w", err)
}
slog.ErrorContext(ctx, "failed to find agent tools", "error", err)
}
if agentTools.TotalCount > 0 {
ret.AgentTools = &agentTools.Results[0]
agentTool := r.getAgentTool(ctx, instance.OSType, instance.OSArch)
// If we have tools, set agent metadata
if agentTool != nil {
ret.AgentTools = agentTool
agentToken, err := r.GetAgentJWTToken(r.ctx, instance.Name)
if err != nil {
return params.InstanceMetadata{}, fmt.Errorf("failed to get agent token: %w", err)
}
ret.AgentToken = agentToken
} else {
slog.WarnContext(ctx, "agent mode enabled but no tools found", "runner_name", instance.Name, "pool_id", instance.PoolID)
// No tools available from any source, disable agent mode
slog.WarnContext(ctx, "agent mode enabled but no tools available from any source",
"runner_name", instance.Name, "pool_id", instance.PoolID, "sync_enabled", ctrlInfo.SyncGARMAgentTools)
ret.AgentMode = false
}
}
@ -526,6 +552,7 @@ func fileObjectToGARMTool(obj params.FileObject, downloadURL string) (params.GAR
var version string
var osType string
var osArch string
var origin string
for _, val := range obj.Tags {
if strings.HasPrefix(val, "version=") {
version = val[8:]
@ -536,6 +563,9 @@ func fileObjectToGARMTool(obj params.FileObject, downloadURL string) (params.GAR
if strings.HasPrefix(val, "os_type=") {
osType = val[8:]
}
if strings.HasPrefix(val, "origin=") {
origin = val[7:]
}
}
switch {
case version == "":
@ -558,6 +588,7 @@ func fileObjectToGARMTool(obj params.FileObject, downloadURL string) (params.GAR
OSArch: commonParams.OSArch(osArch),
DownloadURL: downloadURL,
Version: version,
Origin: origin,
}
return res, nil
}
@ -620,6 +651,7 @@ func (r *Runner) ShowGARMTools(ctx context.Context, toolsID uint) (params.GARMAg
var version string
var osType string
var osArch string
var origin string
var category string
for _, val := range tools.Tags {
if strings.HasPrefix(val, "version=") {
@ -631,6 +663,9 @@ func (r *Runner) ShowGARMTools(ctx context.Context, toolsID uint) (params.GARMAg
if strings.HasPrefix(val, "os_type=") {
osType = val[8:]
}
if strings.HasPrefix(val, "origin=") {
origin = val[7:]
}
if strings.HasPrefix(val, "category=") {
category = val[9:]
}
@ -662,6 +697,7 @@ func (r *Runner) ShowGARMTools(ctx context.Context, toolsID uint) (params.GARMAg
OSType: commonParams.OSType(osType),
OSArch: commonParams.OSArch(osArch),
DownloadURL: downloadURL,
Origin: origin,
}
if version != "" {
res.Version = version

View file

@ -43,11 +43,11 @@ import (
// mockTokenGetter is a simple mock implementation of auth.InstanceTokenGetter
type mockTokenGetter struct{}
func (m *mockTokenGetter) NewInstanceJWTToken(instance params.Instance, entity params.ForgeEntity, ttlMinutes uint) (string, error) {
func (m *mockTokenGetter) NewInstanceJWTToken(_ params.Instance, _ params.ForgeEntity, _ uint) (string, error) {
return "mock-instance-jwt-token", nil
}
func (m *mockTokenGetter) NewAgentJWTToken(instance params.Instance, entity params.ForgeEntity) (string, error) {
func (m *mockTokenGetter) NewAgentJWTToken(_ params.Instance, _ params.ForgeEntity) (string, error) {
return "mock-agent-jwt-token", nil
}
@ -303,10 +303,10 @@ func (s *MetadataTestSuite) TestGetRunnerInstallScript() {
// Set up github tools cache for the entity
tools := []commonParams.RunnerApplicationDownload{
{
OS: garmTesting.Ptr("linux"),
Architecture: garmTesting.Ptr("x64"),
DownloadURL: garmTesting.Ptr("https://example.com/actions-runner-linux-x64-2.0.0.tar.gz"),
Filename: garmTesting.Ptr("actions-runner-linux-x64-2.0.0.tar.gz"),
OS: garmTesting.Ptr("linux"),
Architecture: garmTesting.Ptr("x64"),
DownloadURL: garmTesting.Ptr("https://example.com/actions-runner-linux-x64-2.0.0.tar.gz"),
Filename: garmTesting.Ptr("actions-runner-linux-x64-2.0.0.tar.gz"),
SHA256Checksum: garmTesting.Ptr("abc123"),
},
}
@ -1001,7 +1001,6 @@ func (s *MetadataTestSuite) TestGetGARMToolsInvalidState() {
s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized)
}
func (s *MetadataTestSuite) TestShowGARMToolsUnauthorized() {
_, err := s.Runner.ShowGARMTools(s.unauthorizedCtx, 1)
s.Require().NotNil(err)

View file

@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"io"
"slices"
runnerErrors "github.com/cloudbase/garm-provider-common/errors"
"github.com/cloudbase/garm/auth"
@ -28,11 +29,13 @@ func (r *Runner) CreateFileObject(ctx context.Context, param params.CreateFileOb
if !auth.IsAdmin(ctx) {
return params.FileObject{}, runnerErrors.ErrUnauthorized
}
for _, val := range param.Tags {
if val == garmAgentFileTag {
return params.FileObject{}, runnerErrors.NewBadRequestError("cannot create garm-agent tools via object storage API")
}
// Disallow creating GARM agent tools via the generic file objects endpoint
// Users must use the dedicated POST /tools/garm-agent endpoint instead
if slices.Contains(param.Tags, garmAgentFileTag) {
return params.FileObject{}, runnerErrors.NewBadRequestError("GARM agent tools cannot be uploaded via the file objects endpoint. Use POST /tools/garm-agent instead.")
}
fileObj, err := r.store.CreateFileObject(ctx, param, reader)
if err != nil {
return params.FileObject{}, fmt.Errorf("failed to create file object: %w", err)
@ -58,19 +61,10 @@ func (r *Runner) DeleteFileObject(ctx context.Context, objID uint) error {
return runnerErrors.ErrUnauthorized
}
object, err := r.store.GetFileObject(ctx, objID)
if err != nil {
if err := r.store.DeleteFileObject(ctx, objID); err != nil {
if errors.Is(err, runnerErrors.ErrNotFound) {
return nil
}
return fmt.Errorf("failed to query object in DB: %w", err)
}
for _, val := range object.Tags {
if val == garmAgentFileTag {
return runnerErrors.NewBadRequestError("cannot delete garm-agent tools via object storage API")
}
}
if err := r.store.DeleteFileObject(ctx, objID); err != nil {
return fmt.Errorf("failed to delete file object: %w", err)
}
return nil
@ -81,13 +75,6 @@ func (r *Runner) DeleteFileObjectsByTags(ctx context.Context, tags []string) (in
return 0, runnerErrors.ErrUnauthorized
}
// Check if any of the tags include garm-agent tag
for _, tag := range tags {
if tag == garmAgentFileTag {
return 0, runnerErrors.NewBadRequestError("cannot delete garm-agent tools via object storage API")
}
}
deletedCount, err := r.store.DeleteFileObjectsByTags(ctx, tags)
if err != nil {
return 0, fmt.Errorf("failed to delete file objects by tags: %w", err)
@ -128,10 +115,8 @@ func (r *Runner) UpdateFileObject(ctx context.Context, objID uint, param params.
}
}
for _, val := range param.Tags {
if val == garmAgentFileTag {
return params.FileObject{}, runnerErrors.NewBadRequestError("cannot update garm-agent tools via object storage API")
}
if slices.Contains(param.Tags, garmAgentFileTag) {
return params.FileObject{}, runnerErrors.NewBadRequestError("cannot update garm-agent tools via object storage API")
}
resp, err := r.store.UpdateFileObject(ctx, objID, param)
if err != nil {

View file

@ -256,6 +256,40 @@ func (r *Runner) UpdateController(ctx context.Context, param params.UpdateContro
return info, nil
}
// ForceToolsSync forces an immediate sync of GARM agent tools by resetting the cached timestamp.
// This triggers the garmToolsSync worker to fetch and sync tools on the next check.
func (r *Runner) ForceToolsSync(ctx context.Context) (params.ControllerInfo, error) {
if !auth.IsAdmin(ctx) {
return params.ControllerInfo{}, runnerErrors.ErrUnauthorized
}
// Get current controller info
info, err := r.store.ControllerInfo()
if err != nil {
return params.ControllerInfo{}, fmt.Errorf("error getting controller info: %w", err)
}
// Check if sync is enabled
if !info.SyncGARMAgentTools {
return params.ControllerInfo{}, fmt.Errorf("GARM agent tools sync is disabled")
}
// Reset the timestamp to nil to trigger force sync
// This will cause the watcher to pick up the change and sync immediately
err = r.store.UpdateCachedGARMAgentRelease(nil, time.Time{})
if err != nil {
return params.ControllerInfo{}, fmt.Errorf("error resetting cached release timestamp: %w", err)
}
// Get updated controller info
info, err = r.store.ControllerInfo()
if err != nil {
return params.ControllerInfo{}, fmt.Errorf("error getting updated controller info: %w", err)
}
return info, nil
}
// GetControllerInfo returns the controller id and the hostname.
// This data might be used in metrics and logging.
func (r *Runner) GetControllerInfo(ctx context.Context) (params.ControllerInfo, error) {

128
util/garm_agent.go Normal file
View file

@ -0,0 +1,128 @@
// Copyright 2025 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 util
import (
"encoding/json"
"fmt"
"strings"
"time"
commonParams "github.com/cloudbase/garm-provider-common/params"
"github.com/cloudbase/garm/params"
)
// GitHubReleaseAsset represents an asset from a GitHub release
type GitHubReleaseAsset struct {
ID uint `json:"id"`
Name string `json:"name"`
Size uint `json:"size"`
DownloadCount uint `json:"download_count"`
CreatedAt time.Time `json:"created_at"`
Digest string `json:"digest"`
DownloadURL string `json:"browser_download_url"`
}
// GitHubRelease represents a GitHub release
type GitHubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
TarballURL string `json:"tarball_url"`
Assets []GitHubReleaseAsset `json:"assets"`
}
// GitHubReleases represents an array of GitHub releases
type GitHubReleases []GitHubRelease
// ParseGARMAgentAssetName parses a garm-agent asset name to extract OS type and architecture
func ParseGARMAgentAssetName(name string) (osType, osArch string, err error) {
// Skip checksum files
if strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".md5") {
return "", "", fmt.Errorf("checksum file, skipping")
}
// Remove .exe extension if present
name = strings.TrimSuffix(name, ".exe")
// Expected format: garm-agent-{os}-{arch}[-{version}]
const prefix = "garm-agent-"
if len(name) < len(prefix) || !strings.HasPrefix(name, prefix) {
return "", "", fmt.Errorf("invalid asset name format: %s (expected to start with %s)", name, prefix)
}
// Split the remainder after "garm-agent-"
remainder := name[len(prefix):]
parts := strings.Split(remainder, "-")
if len(parts) < 2 {
return "", "", fmt.Errorf("invalid asset name format: %s (expected {os}-{arch})", name)
}
osType = parts[0]
osArch = parts[1]
return osType, osArch, nil
}
// ParseToolsFromRelease parses cached release data and extracts GARM agent tool information
func ParseToolsFromRelease(releaseData []byte) (map[string]params.GARMAgentTool, error) {
// Try to unmarshal as an array first
var releases GitHubReleases
var release GitHubRelease
err := json.Unmarshal(releaseData, &releases)
if err == nil && len(releases) > 0 {
// Successfully parsed as array with at least one release
release = releases[0]
} else {
// Try as a single release object
if err := json.Unmarshal(releaseData, &release); err != nil {
return nil, fmt.Errorf("failed to unmarshal release data: %w", err)
}
// Validate it has required fields
if release.TagName == "" {
return nil, fmt.Errorf("invalid release format: missing tag_name")
}
}
tools := make(map[string]params.GARMAgentTool)
for _, asset := range release.Assets {
// Skip checksum files
if strings.HasSuffix(asset.Name, ".sha256") || strings.HasSuffix(asset.Name, ".md5") {
continue
}
// Parse asset name
osType, osArch, err := ParseGARMAgentAssetName(asset.Name)
if err != nil {
continue
}
// Create key
key := osType + "/" + osArch
// Create tool
tools[key] = params.GARMAgentTool{
Name: asset.Name,
Description: fmt.Sprintf("GARM Agent %s for %s/%s", release.TagName, osType, osArch),
Size: int64(asset.Size),
Version: release.TagName,
OSType: commonParams.OSType(osType),
OSArch: commonParams.OSArch(osArch),
DownloadURL: asset.DownloadURL,
}
}
return tools, nil
}

View file

@ -13,6 +13,7 @@ docs/ControllerInfo.md
docs/ControllerInfoApi.md
docs/CreateEnterpriseParams.md
docs/CreateFileObjectParams.md
docs/CreateGARMToolParams.md
docs/CreateGiteaCredentialsParams.md
docs/CreateGiteaEndpointParams.md
docs/CreateGithubCredentialsParams.md

View file

@ -86,6 +86,12 @@ export interface ControllerInfo {
* @memberof ControllerInfo
*/
'agent_url'?: string;
/**
* CachedGARMAgentReleaseFetchedAt is the timestamp when the release data was last fetched from GARMAgentReleasesURL
* @type {string}
* @memberof ControllerInfo
*/
'cached_garm_agent_release_fetched_at'?: string;
/**
* CallbackURL is the URL where instances can send updates back to the controller. This URL is used by instances to send status updates back to the controller. The URL itself may be made available to instances via a reverse proxy or a load balancer. That means that the user is responsible for telling GARM what the public URL is, by setting this field.
* @type {string}
@ -215,6 +221,55 @@ export interface CreateFileObjectParams {
*/
'tags'?: Array<string>;
}
/**
*
* @export
* @interface CreateGARMToolParams
*/
export interface CreateGARMToolParams {
/**
*
* @type {string}
* @memberof CreateGARMToolParams
*/
'description'?: string;
/**
*
* @type {string}
* @memberof CreateGARMToolParams
*/
'name'?: string;
/**
*
* @type {string}
* @memberof CreateGARMToolParams
*/
'origin'?: string;
/**
*
* @type {string}
* @memberof CreateGARMToolParams
*/
'os_arch'?: string;
/**
*
* @type {string}
* @memberof CreateGARMToolParams
*/
'os_type'?: string;
/**
*
* @type {number}
* @memberof CreateGARMToolParams
*/
'size'?: number;
/**
*
* @type {string}
* @memberof CreateGARMToolParams
*/
'version'?: string;
}
/**
*
* @export
@ -1306,6 +1361,12 @@ export interface GARMAgentTool {
* @memberof GARMAgentTool
*/
'name'?: string;
/**
* Origin defines where the GARM agent tool originated from. When manually uploaded, this field is set to \"manual\". When synced from a release URL, this will hold the release URL where the tools originated from.
* @type {string}
* @memberof GARMAgentTool
*/
'origin'?: string;
/**
*
* @type {string}
@ -1428,6 +1489,12 @@ export interface GARMAgentToolsPaginatedResponseResultsInner {
* @memberof GARMAgentToolsPaginatedResponseResultsInner
*/
'name'?: string;
/**
* Origin defines where the GARM agent tool originated from. When manually uploaded, this field is set to \"manual\". When synced from a release URL, this will hold the release URL where the tools originated from.
* @type {string}
* @memberof GARMAgentToolsPaginatedResponseResultsInner
*/
'origin'?: string;
/**
*
* @type {string}
@ -3367,6 +3434,39 @@ export interface User {
*/
export const ControllerApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Forces an immediate sync of GARM agent tools by resetting the cached timestamp. This will trigger the background worker to fetch the latest tools from the configured release URL and sync them to the object store. Note: This endpoint requires that GARM agent tools sync is enabled. If sync is disabled, the request will return an error.
* @summary Force immediate sync of GARM agent tools.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
forceToolsSync: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/controller/tools/sync`;
// 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)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Update controller.
@ -3416,6 +3516,18 @@ export const ControllerApiAxiosParamCreator = function (configuration?: Configur
export const ControllerApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ControllerApiAxiosParamCreator(configuration)
return {
/**
* Forces an immediate sync of GARM agent tools by resetting the cached timestamp. This will trigger the background worker to fetch the latest tools from the configured release URL and sync them to the object store. Note: This endpoint requires that GARM agent tools sync is enabled. If sync is disabled, the request will return an error.
* @summary Force immediate sync of GARM agent tools.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async forceToolsSync(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ControllerInfo>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.forceToolsSync(options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ControllerApi.forceToolsSync']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Update controller.
@ -3439,6 +3551,15 @@ export const ControllerApiFp = function(configuration?: Configuration) {
export const ControllerApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ControllerApiFp(configuration)
return {
/**
* Forces an immediate sync of GARM agent tools by resetting the cached timestamp. This will trigger the background worker to fetch the latest tools from the configured release URL and sync them to the object store. Note: This endpoint requires that GARM agent tools sync is enabled. If sync is disabled, the request will return an error.
* @summary Force immediate sync of GARM agent tools.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
forceToolsSync(options?: RawAxiosRequestConfig): AxiosPromise<ControllerInfo> {
return localVarFp.forceToolsSync(options).then((request) => request(axios, basePath));
},
/**
*
* @summary Update controller.
@ -3459,6 +3580,17 @@ export const ControllerApiFactory = function (configuration?: Configuration, bas
* @extends {BaseAPI}
*/
export class ControllerApi extends BaseAPI {
/**
* Forces an immediate sync of GARM agent tools by resetting the cached timestamp. This will trigger the background worker to fetch the latest tools from the configured release URL and sync them to the object store. Note: This endpoint requires that GARM agent tools sync is enabled. If sync is disabled, the request will return an error.
* @summary Force immediate sync of GARM agent tools.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ControllerApi
*/
public forceToolsSync(options?: RawAxiosRequestConfig) {
return ControllerApiFp(this.configuration).forceToolsSync(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Update controller.
@ -13558,6 +13690,39 @@ export const ToolsApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Uploads a GARM agent tool for a specific OS and architecture. This will automatically replace any existing tool for the same OS/architecture combination. Uses custom headers for metadata: X-Tool-Name: Name of the tool X-Tool-Description: Description X-Tool-OS-Type: OS type (linux or windows) X-Tool-OS-Arch: Architecture (amd64 or arm64) X-Tool-Version: Version string
* @summary Upload a GARM agent tool binary.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
uploadGARMAgentTool: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/tools/garm-agent`;
// 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)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -13591,6 +13756,18 @@ export const ToolsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['ToolsApi.garmAgentList']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Uploads a GARM agent tool for a specific OS and architecture. This will automatically replace any existing tool for the same OS/architecture combination. Uses custom headers for metadata: X-Tool-Name: Name of the tool X-Tool-Description: Description X-Tool-OS-Type: OS type (linux or windows) X-Tool-OS-Arch: Architecture (amd64 or arm64) X-Tool-Version: Version string
* @summary Upload a GARM agent tool binary.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async uploadGARMAgentTool(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<FileObject>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.uploadGARMAgentTool(options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ToolsApi.uploadGARMAgentTool']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
@ -13612,6 +13789,15 @@ export const ToolsApiFactory = function (configuration?: Configuration, basePath
garmAgentList(page?: number, pageSize?: number, options?: RawAxiosRequestConfig): AxiosPromise<GARMAgentToolsPaginatedResponse> {
return localVarFp.garmAgentList(page, pageSize, options).then((request) => request(axios, basePath));
},
/**
* Uploads a GARM agent tool for a specific OS and architecture. This will automatically replace any existing tool for the same OS/architecture combination. Uses custom headers for metadata: X-Tool-Name: Name of the tool X-Tool-Description: Description X-Tool-OS-Type: OS type (linux or windows) X-Tool-OS-Arch: Architecture (amd64 or arm64) X-Tool-Version: Version string
* @summary Upload a GARM agent tool binary.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
uploadGARMAgentTool(options?: RawAxiosRequestConfig): AxiosPromise<FileObject> {
return localVarFp.uploadGARMAgentTool(options).then((request) => request(axios, basePath));
},
};
};
@ -13634,6 +13820,17 @@ export class ToolsApi extends BaseAPI {
public garmAgentList(page?: number, pageSize?: number, options?: RawAxiosRequestConfig) {
return ToolsApiFp(this.configuration).garmAgentList(page, pageSize, options).then((request) => request(this.axios, this.basePath));
}
/**
* Uploads a GARM agent tool for a specific OS and architecture. This will automatically replace any existing tool for the same OS/architecture combination. Uses custom headers for metadata: X-Tool-Name: Name of the tool X-Tool-Description: Description X-Tool-OS-Type: OS type (linux or windows) X-Tool-OS-Arch: Architecture (amd64 or arm64) X-Tool-Version: Version string
* @summary Upload a GARM agent tool binary.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ToolsApi
*/
public uploadGARMAgentTool(options?: RawAxiosRequestConfig) {
return ToolsApiFp(this.configuration).uploadGARMAgentTool(options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -39,6 +39,11 @@ definitions:
URL must be configured to allow websocket connections.
type: string
x-go-name: AgentURL
cached_garm_agent_release_fetched_at:
description: CachedGARMAgentReleaseFetchedAt is the timestamp when the release data was last fetched from GARMAgentReleasesURL
format: date-time
type: string
x-go-name: CachedGARMAgentReleaseFetchedAt
callback_url:
description: |-
CallbackURL is the URL where instances can send updates back to the controller.
@ -155,6 +160,30 @@ definitions:
x-go-name: Tags
type: object
x-go-package: github.com/cloudbase/garm/params
CreateGARMToolParams:
properties:
description:
type: string
x-go-name: Description
name:
type: string
x-go-name: Name
origin:
type: string
x-go-name: Origin
os_arch:
$ref: '#/definitions/OSArch'
os_type:
$ref: '#/definitions/OSType'
size:
format: int64
type: integer
x-go-name: Size
version:
type: string
x-go-name: Version
type: object
x-go-package: github.com/cloudbase/garm/params
CreateGiteaCredentialsParams:
properties:
app:
@ -792,6 +821,14 @@ definitions:
name:
type: string
x-go-name: Name
origin:
description: |-
Origin defines where the GARM agent tool originated from.
When manually uploaded, this field is set to "manual". When synced
from a release URL, this will hold the release URL where the tools
originated from.
type: string
x-go-name: Origin
os_arch:
$ref: '#/definitions/OSArch'
os_type:
@ -853,6 +890,14 @@ definitions:
name:
type: string
x-go-name: Name
origin:
description: |-
Origin defines where the GARM agent tool originated from.
When manually uploaded, this field is set to "manual". When synced
from a release URL, this will hold the release URL where the tools
originated from.
type: string
x-go-name: Origin
os_arch:
type: string
x-go-name: OSArch
@ -2184,6 +2229,32 @@ paths:
summary: Get controller info.
tags:
- controllerInfo
/controller/tools/sync:
post:
description: |-
Forces an immediate sync of GARM agent tools by resetting the cached timestamp.
This will trigger the background worker to fetch the latest tools from the configured
release URL and sync them to the object store.
Note: This endpoint requires that GARM agent tools sync is enabled. If sync is disabled,
the request will return an error.
operationId: ForceToolsSync
responses:
"200":
description: ControllerInfo
schema:
$ref: '#/definitions/ControllerInfo'
"400":
description: APIErrorResponse
schema:
$ref: '#/definitions/APIErrorResponse'
"401":
description: APIErrorResponse
schema:
$ref: '#/definitions/APIErrorResponse'
summary: Force immediate sync of GARM agent tools.
tags:
- controller
/enterprises:
get:
operationId: ListEnterprises
@ -4289,6 +4360,39 @@ paths:
summary: List GARM agent tools.
tags:
- tools
post:
description: |-
Uploads a GARM agent tool for a specific OS and architecture.
This will automatically replace any existing tool for the same OS/architecture combination.
Uses custom headers for metadata:
X-Tool-Name: Name of the tool
X-Tool-Description: Description
X-Tool-OS-Type: OS type (linux or windows)
X-Tool-OS-Arch: Architecture (amd64 or arm64)
X-Tool-Version: Version string
operationId: UploadGARMAgentTool
responses:
"200":
description: FileObject
schema:
$ref: '#/definitions/FileObject'
"400":
description: APIErrorResponse
schema:
$ref: '#/definitions/APIErrorResponse'
"401":
description: APIErrorResponse
schema:
$ref: '#/definitions/APIErrorResponse'
summary: Upload a GARM agent tool binary.
tags:
- tools
produces:
- application/json
security:

View file

@ -54,6 +54,7 @@ type Worker struct {
store common.Store
garmToolsManager params.GARMToolsManager
toolsWorkes map[string]*toolsUpdater
garmToolsSync *garmToolsSync
mux sync.Mutex
running bool
@ -232,6 +233,7 @@ func (w *Worker) Start() error {
if err != nil {
return fmt.Errorf("failed to get controller info: %w", err)
}
// CachedGARMAgentTools is now populated by the database layer during conversion
cache.SetControllerCache(ctrlInfo)
return nil
})
@ -278,6 +280,12 @@ func (w *Worker) Start() error {
w.running = true
w.quit = make(chan struct{})
// Initialize and start GARM tools sync worker
w.garmToolsSync = newGARMToolsSync(w.ctx, w.store, w.garmToolsManager)
if err := w.garmToolsSync.Start(); err != nil {
return fmt.Errorf("starting garm tools sync: %w", err)
}
go w.loop()
go w.rateLimitLoop()
return nil
@ -297,6 +305,11 @@ func (w *Worker) Stop() error {
slog.ErrorContext(w.ctx, "stopping tools updater", "error", err)
}
}
if w.garmToolsSync != nil {
if err := w.garmToolsSync.Stop(); err != nil {
slog.ErrorContext(w.ctx, "stopping garm tools sync", "error", err)
}
}
w.consumer.Close()
w.running = false
close(w.quit)

View file

@ -18,67 +18,398 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"sync"
"time"
commonParams "github.com/cloudbase/garm-provider-common/params"
garmCache "github.com/cloudbase/garm/cache"
"github.com/cloudbase/garm/database/common"
"github.com/cloudbase/garm/database/watcher"
"github.com/cloudbase/garm/params"
garmUtil "github.com/cloudbase/garm/util"
)
type GitHubReleaseAsset struct {
ID uint `json:"id"`
Name string `json:"name"`
Size uint `json:"size"`
DownloadCount uint `json:"download_count"`
CreatedAt time.Time `json:"created_at"`
Digest string `json:"digest"`
DownloadURL string `json:"browser_download_url"`
}
type GitHubRelease struct {
// TagName is the semver version of the release.
TagName string `json:"tag_name"`
Name string `json:"name"`
TarballURL string `json:"tarball_url"`
Assets []GitHubReleaseAsset `json:"assets"`
}
type GitHubReleases []GitHubRelease
func getLatestGithubReleaseFromURL(_ context.Context, releasesEndpoint string) (GitHubRelease, error) {
// getLatestGithubReleaseFromURL fetches release information from a GitHub API-compatible endpoint.
// This function is flexible and supports:
// - Array of releases: /repos/{owner}/{repo}/releases (returns first/latest)
// - Single release object: /repos/{owner}/{repo}/releases/latest
// - Custom URLs: Users can configure a fork or custom repository URL as long as it
// follows the GitHub release API format
//
// The response must follow GitHub's release API JSON structure with 'tag_name' and 'assets' fields.
// Arrays are tried first to avoid false positives (empty JSON objects can parse as valid releases).
func getLatestGithubReleaseFromURL(_ context.Context, releasesEndpoint string) (garmUtil.GitHubRelease, error) {
//nolint:gosec // G107: releasesEndpoint is a user-configured GitHub API URL from controller settings
resp, err := http.Get(releasesEndpoint)
if err != nil {
return GitHubRelease{}, fmt.Errorf("failed to fetch URL %s: %w", releasesEndpoint, err)
return garmUtil.GitHubRelease{}, fmt.Errorf("failed to fetch URL %s: %w", releasesEndpoint, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return GitHubRelease{}, fmt.Errorf("failed to read response from URL %s: %w", releasesEndpoint, err)
return garmUtil.GitHubRelease{}, fmt.Errorf("failed to read response from URL %s: %w", releasesEndpoint, err)
}
var tools GitHubReleases
// Try to unmarshal as an array first (for /releases endpoint)
var tools garmUtil.GitHubReleases
err = json.Unmarshal(data, &tools)
if err == nil && len(tools) > 0 {
// Successfully parsed as array with at least one release
if len(tools[0].Assets) == 0 {
return garmUtil.GitHubRelease{}, fmt.Errorf("no downloadable assets found from URL %s", releasesEndpoint)
}
return tools[0], nil
}
// If that fails or array is empty, try as a single release object (for /releases/latest endpoint)
var release garmUtil.GitHubRelease
err = json.Unmarshal(data, &release)
if err != nil {
return GitHubRelease{}, fmt.Errorf("failed to unmarshal response from URL %s: %w", releasesEndpoint, err)
return garmUtil.GitHubRelease{}, fmt.Errorf("failed to unmarshal response from URL %s: %w", releasesEndpoint, err)
}
if len(tools) == 0 {
return GitHubRelease{}, fmt.Errorf("no tools found from URL %s", releasesEndpoint)
// Validate the single release has required fields
if release.TagName == "" {
return garmUtil.GitHubRelease{}, fmt.Errorf("invalid release format from URL %s: missing tag_name", releasesEndpoint)
}
if len(tools[0].Assets) == 0 {
return GitHubRelease{}, fmt.Errorf("no downloadable assets found from URL %s", releasesEndpoint)
if len(release.Assets) == 0 {
return garmUtil.GitHubRelease{}, fmt.Errorf("no downloadable assets found from URL %s", releasesEndpoint)
}
return tools[0], nil
return release, nil
}
type garmToolsSync struct {
ctx context.Context
ctx context.Context
store common.Store
garmToolsManager params.GARMToolsManager
consumerID string
consumer common.Consumer
mux sync.Mutex
running bool
quit chan struct{}
}
func (g *garmToolsSync) loop() {
func newGARMToolsSync(ctx context.Context, store common.Store, garmToolsManager params.GARMToolsManager) *garmToolsSync {
consumerID := "garm-tools-sync"
ctx = garmUtil.WithSlogContext(
ctx,
slog.Any("worker", consumerID))
return &garmToolsSync{
ctx: ctx,
store: store,
consumerID: consumerID,
garmToolsManager: garmToolsManager,
quit: make(chan struct{}),
}
}
func (g *garmToolsSync) Start() error {
g.mux.Lock()
defer g.mux.Unlock()
if g.running {
return nil
}
// Register our own consumer to watch for controller info updates
consumer, err := watcher.RegisterConsumer(
g.ctx, g.consumerID,
watcher.WithEntityTypeFilter(common.ControllerEntityType))
if err != nil {
return fmt.Errorf("registering consumer for garm tools sync: %w", err)
}
g.consumer = consumer
g.running = true
g.quit = make(chan struct{})
go g.loop()
return nil
}
func (g *garmToolsSync) Stop() error {
g.mux.Lock()
defer g.mux.Unlock()
if !g.running {
return nil
}
g.running = false
close(g.quit)
return nil
}
func (g *garmToolsSync) syncToolsFromRelease(release garmUtil.GitHubRelease, originURL string) error {
// Get all existing tools once before the loop
allTools, err := g.garmToolsManager.ListAllGARMTools(g.ctx)
if err != nil {
slog.WarnContext(g.ctx, "failed to list existing tools", "error", err)
}
// Build a map of manually uploaded tools by os/arch for quick lookup
manualTools := make(map[string]bool)
for _, tool := range allTools {
if tool.Origin == "manual" {
key := string(tool.OSType) + "/" + string(tool.OSArch)
manualTools[key] = true
}
}
// For each asset in the release, determine OS type and arch, then sync to DB
for _, asset := range release.Assets {
// Parse the asset name to determine OS type and arch
// Expected format: garm-agent-{os}-{arch}[.exe]
// Examples: garm-agent-linux-amd64, garm-agent-windows-amd64.exe
osType, osArch, err := garmUtil.ParseGARMAgentAssetName(asset.Name)
if err != nil {
slog.WarnContext(g.ctx, "skipping asset with unparseable name",
"asset_name", asset.Name,
"error", err)
continue
}
// Check if there's already a manually uploaded tool for this os/arch combination
toolKey := osType + "/" + osArch
if manualTools[toolKey] {
slog.WarnContext(g.ctx, "skipping sync for tool with manually uploaded version",
"os_type", osType,
"os_arch", osArch,
"upstream_version", release.TagName)
continue
}
// Download the asset to a temporary file first to avoid locking the DB during download
resp, err := http.Get(asset.DownloadURL)
if err != nil {
return fmt.Errorf("failed to download asset %s: %w", asset.Name, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download asset %s: status %d", asset.Name, resp.StatusCode)
}
// Create temporary file
tmpFile, err := os.CreateTemp("", fmt.Sprintf("garm-agent-sync-%s-*", asset.Name))
if err != nil {
return fmt.Errorf("failed to create temp file for %s: %w", asset.Name, err)
}
tmpPath := tmpFile.Name()
defer func() {
tmpFile.Close()
os.Remove(tmpPath)
}()
// Download to temp file
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
return fmt.Errorf("failed to download asset %s to temp file: %w", asset.Name, err)
}
// Seek to beginning for upload
if _, err := tmpFile.Seek(0, 0); err != nil {
return fmt.Errorf("failed to seek to beginning of temp file: %w", err)
}
// Create GARM tool params
createParams := params.CreateGARMToolParams{
Name: asset.Name,
Description: fmt.Sprintf("GARM Agent %s for %s/%s", release.TagName, osType, osArch),
Size: int64(asset.Size),
Version: release.TagName,
OSType: commonParams.OSType(osType),
OSArch: commonParams.OSArch(osArch),
Origin: originURL, // Set origin to the releases URL
}
// Upload to GARM tools storage from temp file
if _, err := g.garmToolsManager.CreateGARMTool(g.ctx, createParams, tmpFile); err != nil {
return fmt.Errorf("failed to create GARM tool for %s: %w", asset.Name, err)
}
slog.InfoContext(g.ctx, "synced GARM agent tool",
"name", asset.Name,
"version", release.TagName,
"os_type", osType,
"os_arch", osArch,
"origin", originURL)
}
return nil
}
func (g *garmToolsSync) syncIfNeeded() error {
// Get controller info from cache
ctrlInfo := garmCache.ControllerInfo()
// Check cache freshness (at most once per day)
// We always need the release JSON, even when sync is disabled, because
// we serve GitHub URLs directly when sync is off
cachedData := ctrlInfo.CachedGARMAgentRelease
var fetchedAt time.Time
if ctrlInfo.CachedGARMAgentReleaseFetchedAt != nil {
fetchedAt = *ctrlInfo.CachedGARMAgentReleaseFetchedAt
}
cacheFresh := len(cachedData) > 0 && time.Since(fetchedAt) < 24*time.Hour
// If cache is fresh, we can skip fetching
if cacheFresh {
slog.DebugContext(g.ctx, "cached GARM agent release is still fresh",
"fetched_at", fetchedAt,
"age", time.Since(fetchedAt),
"sync_enabled", ctrlInfo.SyncGARMAgentTools)
return nil
}
// If sync is disabled and we need to fetch, just fetch and cache (don't sync to object store)
if !ctrlInfo.SyncGARMAgentTools {
release, releaseJSON, err := g.fetchRelease(ctrlInfo)
if err != nil {
return err
}
return g.updateCache(release.TagName, releaseJSON, false)
}
// Sync is enabled, proceed with full sync (fetch + sync to object store)
return g.fetchAndSyncRelease(ctrlInfo)
}
// fetchRelease fetches the latest release from GitHub and marshals it to JSON
// Returns the release struct and JSON bytes for use by callers
func (g *garmToolsSync) fetchRelease(ctrlInfo params.ControllerInfo) (garmUtil.GitHubRelease, []byte, error) {
slog.InfoContext(g.ctx, "fetching latest GARM agent release",
"url", ctrlInfo.GARMAgentReleasesURL,
"sync_enabled", ctrlInfo.SyncGARMAgentTools)
release, err := getLatestGithubReleaseFromURL(g.ctx, ctrlInfo.GARMAgentReleasesURL)
if err != nil {
return garmUtil.GitHubRelease{}, nil, fmt.Errorf("failed to fetch latest release: %w", err)
}
releaseJSON, err := json.Marshal(release)
if err != nil {
return garmUtil.GitHubRelease{}, nil, fmt.Errorf("failed to marshal release: %w", err)
}
return release, releaseJSON, nil
}
// fetchAndSyncRelease fetches the latest release from GitHub, syncs tools to object store if version changed, and updates the cache
func (g *garmToolsSync) fetchAndSyncRelease(ctrlInfo params.ControllerInfo) error {
// Fetch fresh release data from GitHub
release, releaseJSON, err := g.fetchRelease(ctrlInfo)
if err != nil {
return err
}
// Check if version changed by comparing with cached version
cachedData := ctrlInfo.CachedGARMAgentRelease
versionChanged := true // Default to true if no cache exists
if len(cachedData) > 0 {
var cachedRelease garmUtil.GitHubRelease
if err := json.Unmarshal(cachedData, &cachedRelease); err != nil {
slog.WarnContext(g.ctx, "failed to unmarshal cached release, will re-sync", "error", err)
} else if cachedRelease.TagName == release.TagName {
// Version hasn't changed, just update timestamp
slog.InfoContext(g.ctx, "GARM agent release version unchanged, updating timestamp only",
"version", release.TagName)
versionChanged = false
}
}
// Only sync to object store if version actually changed
if versionChanged {
slog.InfoContext(g.ctx, "new GARM agent release version detected, syncing to object store",
"version", release.TagName)
if err := g.syncToolsFromRelease(release, ctrlInfo.GARMAgentReleasesURL); err != nil {
return fmt.Errorf("failed to sync tools to object store: %w", err)
}
}
// Update cache with fresh data and timestamp
return g.updateCache(release.TagName, releaseJSON, versionChanged)
}
// updateCache updates the database with the release data.
// The in-memory cache is automatically updated via the database watcher notification.
func (g *garmToolsSync) updateCache(version string, releaseJSON []byte, synced bool) error {
now := time.Now()
// Update database - this triggers a watcher notification that updates the in-memory cache
if err := g.store.UpdateCachedGARMAgentRelease(releaseJSON, now); err != nil {
return fmt.Errorf("failed to update cached release: %w", err)
}
slog.InfoContext(g.ctx, "successfully updated GARM agent release cache",
"version", version,
"synced_to_object_store", synced)
return nil
}
func (g *garmToolsSync) loop() {
defer g.Stop()
// Check every hour (syncIfNeeded will skip if cache is fresh)
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
// Trigger an immediate check after a short delay to allow GARM to start accepting requests
initialSync := time.NewTimer(5 * time.Second)
defer initialSync.Stop()
for {
select {
case <-g.quit:
return
case <-g.ctx.Done():
return
case <-initialSync.C:
// Initial sync after startup delay (fires once)
if err := g.syncIfNeeded(); err != nil {
slog.ErrorContext(g.ctx, "failed initial sync of GARM agent tools", "error", err)
}
// Nil the channel so this case is never selected again
initialSync = nil
case <-ticker.C:
if err := g.syncIfNeeded(); err != nil {
slog.ErrorContext(g.ctx, "failed to sync GARM agent tools", "error", err)
}
case event, ok := <-g.consumer.Watch():
if !ok {
slog.InfoContext(g.ctx, "consumer channel closed")
return
}
slog.InfoContext(g.ctx, "got controller update event", "event_type", event.EntityType, "operation", event.Operation)
// Filter for controller info update events
if event.EntityType == common.ControllerEntityType && event.Operation == common.UpdateOperation {
g.handleControllerUpdate(event)
}
}
}
}
func (g *garmToolsSync) handleControllerUpdate(event common.ChangePayload) {
ctrlInfo, ok := event.Payload.(params.ControllerInfo)
if !ok {
slog.WarnContext(g.ctx, "invalid payload type for controller update event")
return
}
// Check if sync is enabled
if !ctrlInfo.SyncGARMAgentTools {
slog.WarnContext(g.ctx, "tools sync is disabled, skipping force sync")
return
}
if err := g.syncIfNeeded(); err != nil {
slog.ErrorContext(g.ctx, "failed to force sync GARM agent tools", "error", err)
}
}

235
workers/cache/garm_agent_test.go vendored Normal file
View file

@ -0,0 +1,235 @@
// Copyright 2025 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 (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetLatestGithubReleaseFromURL(t *testing.T) {
tests := []struct {
name string
responseBody string
wantErr bool
errContains string
wantTagName string
wantAssets int
}{
{
name: "valid /releases array with single release",
responseBody: `[
{
"tag_name": "v0.1.0-beta1",
"assets": [
{
"name": "garm-agent-linux-amd64-v0.1.0-beta1",
"size": 7749816,
"browser_download_url": "https://github.com/cloudbase/garm-agent/releases/download/v0.1.0-beta1/garm-agent-linux-amd64-v0.1.0-beta1"
},
{
"name": "garm-agent-linux-arm64-v0.1.0-beta1",
"size": 7274680,
"browser_download_url": "https://github.com/cloudbase/garm-agent/releases/download/v0.1.0-beta1/garm-agent-linux-arm64-v0.1.0-beta1"
}
]
}
]`,
wantErr: false,
wantTagName: "v0.1.0-beta1",
wantAssets: 2,
},
{
name: "valid /releases array with multiple releases",
responseBody: `[
{
"tag_name": "v0.2.0",
"assets": [
{
"name": "garm-agent-linux-amd64-v0.2.0",
"size": 8000000,
"browser_download_url": "https://example.com/v0.2.0/garm-agent-linux-amd64"
}
]
},
{
"tag_name": "v0.1.0",
"assets": [
{
"name": "garm-agent-linux-amd64-v0.1.0",
"size": 7000000,
"browser_download_url": "https://example.com/v0.1.0/garm-agent-linux-amd64"
}
]
}
]`,
wantErr: false,
wantTagName: "v0.2.0", // Should return first (latest) release
wantAssets: 1,
},
{
name: "valid /releases/latest single object",
responseBody: `{
"tag_name": "v0.1.0-beta1",
"name": "v0.1.0-beta1",
"draft": false,
"prerelease": false,
"assets": [
{
"name": "garm-agent-linux-amd64-v0.1.0-beta1",
"size": 7749816,
"browser_download_url": "https://github.com/cloudbase/garm-agent/releases/download/v0.1.0-beta1/garm-agent-linux-amd64-v0.1.0-beta1"
},
{
"name": "garm-agent-windows-amd64-v0.1.0-beta1.exe",
"size": 7843328,
"browser_download_url": "https://github.com/cloudbase/garm-agent/releases/download/v0.1.0-beta1/garm-agent-windows-amd64-v0.1.0-beta1.exe"
}
]
}`,
wantErr: false,
wantTagName: "v0.1.0-beta1",
wantAssets: 2,
},
{
name: "empty array",
responseBody: `[]`,
wantErr: true,
errContains: "failed to unmarshal", // Empty array tries to parse as single object and fails
},
{
name: "empty object",
responseBody: `{}`,
wantErr: true,
errContains: "missing tag_name",
},
{
name: "object without tag_name",
responseBody: `{"name": "some-release", "draft": false}`,
wantErr: true,
errContains: "missing tag_name",
},
{
name: "release without assets",
responseBody: `{
"tag_name": "v1.0.0",
"assets": []
}`,
wantErr: true,
errContains: "no downloadable assets",
},
{
name: "array with release without assets",
responseBody: `[
{
"tag_name": "v1.0.0",
"assets": []
}
]`,
wantErr: true,
errContains: "no downloadable assets",
},
{
name: "invalid JSON",
responseBody: `{"invalid": json}`,
wantErr: true,
errContains: "failed to unmarshal",
},
{
name: "unrelated valid JSON object",
responseBody: `{
"message": "Not Found",
"documentation_url": "https://docs.github.com/rest/releases/releases#get-the-latest-release"
}`,
wantErr: true,
errContains: "missing tag_name",
},
{
name: "unrelated valid JSON array",
responseBody: `[
{"id": 1, "name": "item1"},
{"id": 2, "name": "item2"}
]`,
wantErr: true,
errContains: "no downloadable assets", // Array parses successfully but has no assets
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(tt.responseBody))
}))
defer server.Close()
// Call the function
release, err := getLatestGithubReleaseFromURL(context.Background(), server.URL)
// Check error expectations
if tt.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errContains)
return
}
if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
t.Errorf("expected error containing %q, got %q", tt.errContains, err.Error())
}
return
}
// Check success expectations
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if release.TagName != tt.wantTagName {
t.Errorf("expected tag_name %q, got %q", tt.wantTagName, release.TagName)
}
if len(release.Assets) != tt.wantAssets {
t.Errorf("expected %d assets, got %d", tt.wantAssets, len(release.Assets))
}
})
}
}
func TestGetLatestGithubReleaseFromURL_NetworkError(t *testing.T) {
// Test with invalid URL to trigger network error
_, err := getLatestGithubReleaseFromURL(context.Background(), "http://invalid-url-that-does-not-exist-12345.local")
if err == nil {
t.Error("expected network error, got nil")
}
}
// Helper function to check if a string contains a substring
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && containsHelper(s, substr)))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}