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:
parent
c29e8d4459
commit
def4b4aaf1
29 changed files with 2755 additions and 97 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
128
client/controller/force_tools_sync_parameters.go
Normal file
128
client/controller/force_tools_sync_parameters.go
Normal 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
|
||||
}
|
||||
253
client/controller/force_tools_sync_responses.go
Normal file
253
client/controller/force_tools_sync_responses.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
128
client/tools/upload_g_a_r_m_agent_tool_parameters.go
Normal file
128
client/tools/upload_g_a_r_m_agent_tool_parameters.go
Normal 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
|
||||
}
|
||||
253
client/tools/upload_g_a_r_m_agent_tool_responses.go
Normal file
253
client/tools/upload_g_a_r_m_agent_tool_responses.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
128
util/garm_agent.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
13
workers/cache/cache.go
vendored
13
workers/cache/cache.go
vendored
|
|
@ -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)
|
||||
|
|
|
|||
397
workers/cache/garm_agent.go
vendored
397
workers/cache/garm_agent.go
vendored
|
|
@ -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
235
workers/cache/garm_agent_test.go
vendored
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue