From 61b4b4cadd0c49dc59d1b18a078cb97325b3d80d Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Sun, 8 Feb 2026 15:53:05 +0200 Subject: [PATCH] Use separate endpoints to list tools Users and instances now have different endpoint for listing tools. Moreover, users can now use a flag to see what tools are available upstream if sync is off: garm-cli controller tools list --upstream Signed-off-by: Gabriel Adrian Samfira --- apiserver/controllers/metadata.go | 60 ++++- apiserver/routers/routers.go | 4 +- apiserver/swagger.yaml | 8 +- .../tools/admin_garm_agent_list_parameters.go | 232 ++++++++++++++++++ .../tools/admin_garm_agent_list_responses.go | 179 ++++++++++++++ client/tools/garm_agent_list_parameters.go | 198 --------------- client/tools/garm_agent_list_responses.go | 179 -------------- client/tools/tools_client.go | 16 +- cmd/garm-cli/cmd/controller.go | 52 ++-- params/params.go | 13 + runner/metadata.go | 161 ++++++++++-- runner/metadata_test.go | 166 ++++++++++++- 12 files changed, 828 insertions(+), 440 deletions(-) create mode 100644 client/tools/admin_garm_agent_list_parameters.go create mode 100644 client/tools/admin_garm_agent_list_responses.go delete mode 100644 client/tools/garm_agent_list_parameters.go delete mode 100644 client/tools/garm_agent_list_responses.go diff --git a/apiserver/controllers/metadata.go b/apiserver/controllers/metadata.go index c5fb409f..f23bfd1c 100644 --- a/apiserver/controllers/metadata.go +++ b/apiserver/controllers/metadata.go @@ -85,7 +85,65 @@ func (a *APIController) InstanceGARMToolsHandler(w http.ResponseWriter, r *http. } } - tools, err := a.r.GetGARMTools(ctx, uint64(pageLocation), uint64(pageSize)) + tools, err := a.r.GetAgentGARMTools(ctx, uint64(pageLocation), uint64(pageSize)) + if err != nil { + handleError(ctx, w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(tools); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response") + } +} + +// swagger:route GET /tools/garm-agent tools AdminGarmAgentList +// +// List GARM agent tools for admin users. +// +// Parameters: +// + name: page +// description: The page at which to list. +// type: integer +// in: query +// required: false +// + name: pageSize +// description: Number of items per page. +// type: integer +// in: query +// required: false +// + name: upstream +// description: If true, list tools from the upstream cached release instead of the local object store. +// type: boolean +// in: query +// required: false +// +// Responses: +// 200: GARMAgentToolsPaginatedResponse +// 400: APIErrorResponse +func (a *APIController) AdminGARMToolsHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var pageLocation int64 + var pageSize int64 = 25 + pageArg := r.URL.Query().Get("page") + pageSizeArg := r.URL.Query().Get("pageSize") + + if pageArg != "" { + pageInt, err := strconv.ParseInt(pageArg, 10, 64) + if err == nil && pageInt >= 0 { + pageLocation = pageInt + } + } + if pageSizeArg != "" { + pageSizeInt, err := strconv.ParseInt(pageSizeArg, 10, 64) + if err == nil && pageSizeInt >= 0 { + pageSize = pageSizeInt + } + } + + upstream := r.URL.Query().Get("upstream") == "true" + tools, err := a.r.GetGARMTools(ctx, uint64(pageLocation), uint64(pageSize), upstream) if err != nil { handleError(ctx, w, err) return diff --git a/apiserver/routers/routers.go b/apiserver/routers/routers.go index 971dd361..f92ff7a4 100644 --- a/apiserver/routers/routers.go +++ b/apiserver/routers/routers.go @@ -267,8 +267,8 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware /////////////////////////////////////////////////////// // Tools URLs (garm agent, cached gitea runner, etc) // /////////////////////////////////////////////////////// - apiRouter.Handle("/tools/garm-agent/", http.HandlerFunc(han.InstanceGARMToolsHandler)).Methods("GET", "OPTIONS") - apiRouter.Handle("/tools/garm-agent", http.HandlerFunc(han.InstanceGARMToolsHandler)).Methods("GET", "OPTIONS") + apiRouter.Handle("/tools/garm-agent/", http.HandlerFunc(han.AdminGARMToolsHandler)).Methods("GET", "OPTIONS") + apiRouter.Handle("/tools/garm-agent", http.HandlerFunc(han.AdminGARMToolsHandler)).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") diff --git a/apiserver/swagger.yaml b/apiserver/swagger.yaml index 2cfa2fae..e7575b4b 100644 --- a/apiserver/swagger.yaml +++ b/apiserver/swagger.yaml @@ -2603,7 +2603,7 @@ paths: - templates /tools/garm-agent: get: - operationId: GarmAgentList + operationId: AdminGarmAgentList parameters: - description: The page at which to list. in: query @@ -2613,6 +2613,10 @@ paths: in: query name: pageSize type: integer + - description: If true, list tools from the upstream cached release instead of the local object store. + in: query + name: upstream + type: boolean responses: "200": description: GARMAgentToolsPaginatedResponse @@ -2622,7 +2626,7 @@ paths: description: APIErrorResponse schema: $ref: '#/definitions/APIErrorResponse' - summary: List GARM agent tools. + summary: List GARM agent tools for admin users. tags: - tools post: diff --git a/client/tools/admin_garm_agent_list_parameters.go b/client/tools/admin_garm_agent_list_parameters.go new file mode 100644 index 00000000..86f49ba7 --- /dev/null +++ b/client/tools/admin_garm_agent_list_parameters.go @@ -0,0 +1,232 @@ +// 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" + "github.com/go-openapi/swag" +) + +// NewAdminGarmAgentListParams creates a new AdminGarmAgentListParams 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 NewAdminGarmAgentListParams() *AdminGarmAgentListParams { + return &AdminGarmAgentListParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewAdminGarmAgentListParamsWithTimeout creates a new AdminGarmAgentListParams object +// with the ability to set a timeout on a request. +func NewAdminGarmAgentListParamsWithTimeout(timeout time.Duration) *AdminGarmAgentListParams { + return &AdminGarmAgentListParams{ + timeout: timeout, + } +} + +// NewAdminGarmAgentListParamsWithContext creates a new AdminGarmAgentListParams object +// with the ability to set a context for a request. +func NewAdminGarmAgentListParamsWithContext(ctx context.Context) *AdminGarmAgentListParams { + return &AdminGarmAgentListParams{ + Context: ctx, + } +} + +// NewAdminGarmAgentListParamsWithHTTPClient creates a new AdminGarmAgentListParams object +// with the ability to set a custom HTTPClient for a request. +func NewAdminGarmAgentListParamsWithHTTPClient(client *http.Client) *AdminGarmAgentListParams { + return &AdminGarmAgentListParams{ + HTTPClient: client, + } +} + +/* +AdminGarmAgentListParams contains all the parameters to send to the API endpoint + + for the admin garm agent list operation. + + Typically these are written to a http.Request. +*/ +type AdminGarmAgentListParams struct { + + /* Page. + + The page at which to list. + */ + Page *int64 + + /* PageSize. + + Number of items per page. + */ + PageSize *int64 + + /* Upstream. + + If true, list tools from the upstream cached release instead of the local object store. + */ + Upstream *bool + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the admin garm agent list params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *AdminGarmAgentListParams) WithDefaults() *AdminGarmAgentListParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the admin garm agent list params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *AdminGarmAgentListParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the admin garm agent list params +func (o *AdminGarmAgentListParams) WithTimeout(timeout time.Duration) *AdminGarmAgentListParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the admin garm agent list params +func (o *AdminGarmAgentListParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the admin garm agent list params +func (o *AdminGarmAgentListParams) WithContext(ctx context.Context) *AdminGarmAgentListParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the admin garm agent list params +func (o *AdminGarmAgentListParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the admin garm agent list params +func (o *AdminGarmAgentListParams) WithHTTPClient(client *http.Client) *AdminGarmAgentListParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the admin garm agent list params +func (o *AdminGarmAgentListParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithPage adds the page to the admin garm agent list params +func (o *AdminGarmAgentListParams) WithPage(page *int64) *AdminGarmAgentListParams { + o.SetPage(page) + return o +} + +// SetPage adds the page to the admin garm agent list params +func (o *AdminGarmAgentListParams) SetPage(page *int64) { + o.Page = page +} + +// WithPageSize adds the pageSize to the admin garm agent list params +func (o *AdminGarmAgentListParams) WithPageSize(pageSize *int64) *AdminGarmAgentListParams { + o.SetPageSize(pageSize) + return o +} + +// SetPageSize adds the pageSize to the admin garm agent list params +func (o *AdminGarmAgentListParams) SetPageSize(pageSize *int64) { + o.PageSize = pageSize +} + +// WithUpstream adds the upstream to the admin garm agent list params +func (o *AdminGarmAgentListParams) WithUpstream(upstream *bool) *AdminGarmAgentListParams { + o.SetUpstream(upstream) + return o +} + +// SetUpstream adds the upstream to the admin garm agent list params +func (o *AdminGarmAgentListParams) SetUpstream(upstream *bool) { + o.Upstream = upstream +} + +// WriteToRequest writes these params to a swagger request +func (o *AdminGarmAgentListParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if o.Page != nil { + + // query param page + var qrPage int64 + + if o.Page != nil { + qrPage = *o.Page + } + qPage := swag.FormatInt64(qrPage) + if qPage != "" { + + if err := r.SetQueryParam("page", qPage); err != nil { + return err + } + } + } + + if o.PageSize != nil { + + // query param pageSize + var qrPageSize int64 + + if o.PageSize != nil { + qrPageSize = *o.PageSize + } + qPageSize := swag.FormatInt64(qrPageSize) + if qPageSize != "" { + + if err := r.SetQueryParam("pageSize", qPageSize); err != nil { + return err + } + } + } + + if o.Upstream != nil { + + // query param upstream + var qrUpstream bool + + if o.Upstream != nil { + qrUpstream = *o.Upstream + } + qUpstream := swag.FormatBool(qrUpstream) + if qUpstream != "" { + + if err := r.SetQueryParam("upstream", qUpstream); err != nil { + return err + } + } + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/client/tools/admin_garm_agent_list_responses.go b/client/tools/admin_garm_agent_list_responses.go new file mode 100644 index 00000000..560e1092 --- /dev/null +++ b/client/tools/admin_garm_agent_list_responses.go @@ -0,0 +1,179 @@ +// 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" +) + +// AdminGarmAgentListReader is a Reader for the AdminGarmAgentList structure. +type AdminGarmAgentListReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *AdminGarmAgentListReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 200: + result := NewAdminGarmAgentListOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + case 400: + result := NewAdminGarmAgentListBadRequest() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + default: + return nil, runtime.NewAPIError("[GET /tools/garm-agent] AdminGarmAgentList", response, response.Code()) + } +} + +// NewAdminGarmAgentListOK creates a AdminGarmAgentListOK with default headers values +func NewAdminGarmAgentListOK() *AdminGarmAgentListOK { + return &AdminGarmAgentListOK{} +} + +/* +AdminGarmAgentListOK describes a response with status code 200, with default header values. + +GARMAgentToolsPaginatedResponse +*/ +type AdminGarmAgentListOK struct { + Payload garm_params.GARMAgentToolsPaginatedResponse +} + +// IsSuccess returns true when this admin garm agent list o k response has a 2xx status code +func (o *AdminGarmAgentListOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this admin garm agent list o k response has a 3xx status code +func (o *AdminGarmAgentListOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this admin garm agent list o k response has a 4xx status code +func (o *AdminGarmAgentListOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this admin garm agent list o k response has a 5xx status code +func (o *AdminGarmAgentListOK) IsServerError() bool { + return false +} + +// IsCode returns true when this admin garm agent list o k response a status code equal to that given +func (o *AdminGarmAgentListOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the admin garm agent list o k response +func (o *AdminGarmAgentListOK) Code() int { + return 200 +} + +func (o *AdminGarmAgentListOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /tools/garm-agent][%d] adminGarmAgentListOK %s", 200, payload) +} + +func (o *AdminGarmAgentListOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /tools/garm-agent][%d] adminGarmAgentListOK %s", 200, payload) +} + +func (o *AdminGarmAgentListOK) GetPayload() garm_params.GARMAgentToolsPaginatedResponse { + return o.Payload +} + +func (o *AdminGarmAgentListOK) 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 +} + +// NewAdminGarmAgentListBadRequest creates a AdminGarmAgentListBadRequest with default headers values +func NewAdminGarmAgentListBadRequest() *AdminGarmAgentListBadRequest { + return &AdminGarmAgentListBadRequest{} +} + +/* +AdminGarmAgentListBadRequest describes a response with status code 400, with default header values. + +APIErrorResponse +*/ +type AdminGarmAgentListBadRequest struct { + Payload apiserver_params.APIErrorResponse +} + +// IsSuccess returns true when this admin garm agent list bad request response has a 2xx status code +func (o *AdminGarmAgentListBadRequest) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this admin garm agent list bad request response has a 3xx status code +func (o *AdminGarmAgentListBadRequest) IsRedirect() bool { + return false +} + +// IsClientError returns true when this admin garm agent list bad request response has a 4xx status code +func (o *AdminGarmAgentListBadRequest) IsClientError() bool { + return true +} + +// IsServerError returns true when this admin garm agent list bad request response has a 5xx status code +func (o *AdminGarmAgentListBadRequest) IsServerError() bool { + return false +} + +// IsCode returns true when this admin garm agent list bad request response a status code equal to that given +func (o *AdminGarmAgentListBadRequest) IsCode(code int) bool { + return code == 400 +} + +// Code gets the status code for the admin garm agent list bad request response +func (o *AdminGarmAgentListBadRequest) Code() int { + return 400 +} + +func (o *AdminGarmAgentListBadRequest) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /tools/garm-agent][%d] adminGarmAgentListBadRequest %s", 400, payload) +} + +func (o *AdminGarmAgentListBadRequest) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /tools/garm-agent][%d] adminGarmAgentListBadRequest %s", 400, payload) +} + +func (o *AdminGarmAgentListBadRequest) GetPayload() apiserver_params.APIErrorResponse { + return o.Payload +} + +func (o *AdminGarmAgentListBadRequest) 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 +} diff --git a/client/tools/garm_agent_list_parameters.go b/client/tools/garm_agent_list_parameters.go deleted file mode 100644 index dcb65bad..00000000 --- a/client/tools/garm_agent_list_parameters.go +++ /dev/null @@ -1,198 +0,0 @@ -// 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" - "github.com/go-openapi/swag" -) - -// NewGarmAgentListParams creates a new GarmAgentListParams 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 NewGarmAgentListParams() *GarmAgentListParams { - return &GarmAgentListParams{ - timeout: cr.DefaultTimeout, - } -} - -// NewGarmAgentListParamsWithTimeout creates a new GarmAgentListParams object -// with the ability to set a timeout on a request. -func NewGarmAgentListParamsWithTimeout(timeout time.Duration) *GarmAgentListParams { - return &GarmAgentListParams{ - timeout: timeout, - } -} - -// NewGarmAgentListParamsWithContext creates a new GarmAgentListParams object -// with the ability to set a context for a request. -func NewGarmAgentListParamsWithContext(ctx context.Context) *GarmAgentListParams { - return &GarmAgentListParams{ - Context: ctx, - } -} - -// NewGarmAgentListParamsWithHTTPClient creates a new GarmAgentListParams object -// with the ability to set a custom HTTPClient for a request. -func NewGarmAgentListParamsWithHTTPClient(client *http.Client) *GarmAgentListParams { - return &GarmAgentListParams{ - HTTPClient: client, - } -} - -/* -GarmAgentListParams contains all the parameters to send to the API endpoint - - for the garm agent list operation. - - Typically these are written to a http.Request. -*/ -type GarmAgentListParams struct { - - /* Page. - - The page at which to list. - */ - Page *int64 - - /* PageSize. - - Number of items per page. - */ - PageSize *int64 - - timeout time.Duration - Context context.Context - HTTPClient *http.Client -} - -// WithDefaults hydrates default values in the garm agent list params (not the query body). -// -// All values with no default are reset to their zero value. -func (o *GarmAgentListParams) WithDefaults() *GarmAgentListParams { - o.SetDefaults() - return o -} - -// SetDefaults hydrates default values in the garm agent list params (not the query body). -// -// All values with no default are reset to their zero value. -func (o *GarmAgentListParams) SetDefaults() { - // no default values defined for this parameter -} - -// WithTimeout adds the timeout to the garm agent list params -func (o *GarmAgentListParams) WithTimeout(timeout time.Duration) *GarmAgentListParams { - o.SetTimeout(timeout) - return o -} - -// SetTimeout adds the timeout to the garm agent list params -func (o *GarmAgentListParams) SetTimeout(timeout time.Duration) { - o.timeout = timeout -} - -// WithContext adds the context to the garm agent list params -func (o *GarmAgentListParams) WithContext(ctx context.Context) *GarmAgentListParams { - o.SetContext(ctx) - return o -} - -// SetContext adds the context to the garm agent list params -func (o *GarmAgentListParams) SetContext(ctx context.Context) { - o.Context = ctx -} - -// WithHTTPClient adds the HTTPClient to the garm agent list params -func (o *GarmAgentListParams) WithHTTPClient(client *http.Client) *GarmAgentListParams { - o.SetHTTPClient(client) - return o -} - -// SetHTTPClient adds the HTTPClient to the garm agent list params -func (o *GarmAgentListParams) SetHTTPClient(client *http.Client) { - o.HTTPClient = client -} - -// WithPage adds the page to the garm agent list params -func (o *GarmAgentListParams) WithPage(page *int64) *GarmAgentListParams { - o.SetPage(page) - return o -} - -// SetPage adds the page to the garm agent list params -func (o *GarmAgentListParams) SetPage(page *int64) { - o.Page = page -} - -// WithPageSize adds the pageSize to the garm agent list params -func (o *GarmAgentListParams) WithPageSize(pageSize *int64) *GarmAgentListParams { - o.SetPageSize(pageSize) - return o -} - -// SetPageSize adds the pageSize to the garm agent list params -func (o *GarmAgentListParams) SetPageSize(pageSize *int64) { - o.PageSize = pageSize -} - -// WriteToRequest writes these params to a swagger request -func (o *GarmAgentListParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { - - if err := r.SetTimeout(o.timeout); err != nil { - return err - } - var res []error - - if o.Page != nil { - - // query param page - var qrPage int64 - - if o.Page != nil { - qrPage = *o.Page - } - qPage := swag.FormatInt64(qrPage) - if qPage != "" { - - if err := r.SetQueryParam("page", qPage); err != nil { - return err - } - } - } - - if o.PageSize != nil { - - // query param pageSize - var qrPageSize int64 - - if o.PageSize != nil { - qrPageSize = *o.PageSize - } - qPageSize := swag.FormatInt64(qrPageSize) - if qPageSize != "" { - - if err := r.SetQueryParam("pageSize", qPageSize); err != nil { - return err - } - } - } - - if len(res) > 0 { - return errors.CompositeValidationError(res...) - } - return nil -} diff --git a/client/tools/garm_agent_list_responses.go b/client/tools/garm_agent_list_responses.go deleted file mode 100644 index 5b0d8994..00000000 --- a/client/tools/garm_agent_list_responses.go +++ /dev/null @@ -1,179 +0,0 @@ -// 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" -) - -// GarmAgentListReader is a Reader for the GarmAgentList structure. -type GarmAgentListReader struct { - formats strfmt.Registry -} - -// ReadResponse reads a server response into the received o. -func (o *GarmAgentListReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { - switch response.Code() { - case 200: - result := NewGarmAgentListOK() - if err := result.readResponse(response, consumer, o.formats); err != nil { - return nil, err - } - return result, nil - case 400: - result := NewGarmAgentListBadRequest() - if err := result.readResponse(response, consumer, o.formats); err != nil { - return nil, err - } - return nil, result - default: - return nil, runtime.NewAPIError("[GET /tools/garm-agent] GarmAgentList", response, response.Code()) - } -} - -// NewGarmAgentListOK creates a GarmAgentListOK with default headers values -func NewGarmAgentListOK() *GarmAgentListOK { - return &GarmAgentListOK{} -} - -/* -GarmAgentListOK describes a response with status code 200, with default header values. - -GARMAgentToolsPaginatedResponse -*/ -type GarmAgentListOK struct { - Payload garm_params.GARMAgentToolsPaginatedResponse -} - -// IsSuccess returns true when this garm agent list o k response has a 2xx status code -func (o *GarmAgentListOK) IsSuccess() bool { - return true -} - -// IsRedirect returns true when this garm agent list o k response has a 3xx status code -func (o *GarmAgentListOK) IsRedirect() bool { - return false -} - -// IsClientError returns true when this garm agent list o k response has a 4xx status code -func (o *GarmAgentListOK) IsClientError() bool { - return false -} - -// IsServerError returns true when this garm agent list o k response has a 5xx status code -func (o *GarmAgentListOK) IsServerError() bool { - return false -} - -// IsCode returns true when this garm agent list o k response a status code equal to that given -func (o *GarmAgentListOK) IsCode(code int) bool { - return code == 200 -} - -// Code gets the status code for the garm agent list o k response -func (o *GarmAgentListOK) Code() int { - return 200 -} - -func (o *GarmAgentListOK) Error() string { - payload, _ := json.Marshal(o.Payload) - return fmt.Sprintf("[GET /tools/garm-agent][%d] garmAgentListOK %s", 200, payload) -} - -func (o *GarmAgentListOK) String() string { - payload, _ := json.Marshal(o.Payload) - return fmt.Sprintf("[GET /tools/garm-agent][%d] garmAgentListOK %s", 200, payload) -} - -func (o *GarmAgentListOK) GetPayload() garm_params.GARMAgentToolsPaginatedResponse { - return o.Payload -} - -func (o *GarmAgentListOK) 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 -} - -// NewGarmAgentListBadRequest creates a GarmAgentListBadRequest with default headers values -func NewGarmAgentListBadRequest() *GarmAgentListBadRequest { - return &GarmAgentListBadRequest{} -} - -/* -GarmAgentListBadRequest describes a response with status code 400, with default header values. - -APIErrorResponse -*/ -type GarmAgentListBadRequest struct { - Payload apiserver_params.APIErrorResponse -} - -// IsSuccess returns true when this garm agent list bad request response has a 2xx status code -func (o *GarmAgentListBadRequest) IsSuccess() bool { - return false -} - -// IsRedirect returns true when this garm agent list bad request response has a 3xx status code -func (o *GarmAgentListBadRequest) IsRedirect() bool { - return false -} - -// IsClientError returns true when this garm agent list bad request response has a 4xx status code -func (o *GarmAgentListBadRequest) IsClientError() bool { - return true -} - -// IsServerError returns true when this garm agent list bad request response has a 5xx status code -func (o *GarmAgentListBadRequest) IsServerError() bool { - return false -} - -// IsCode returns true when this garm agent list bad request response a status code equal to that given -func (o *GarmAgentListBadRequest) IsCode(code int) bool { - return code == 400 -} - -// Code gets the status code for the garm agent list bad request response -func (o *GarmAgentListBadRequest) Code() int { - return 400 -} - -func (o *GarmAgentListBadRequest) Error() string { - payload, _ := json.Marshal(o.Payload) - return fmt.Sprintf("[GET /tools/garm-agent][%d] garmAgentListBadRequest %s", 400, payload) -} - -func (o *GarmAgentListBadRequest) String() string { - payload, _ := json.Marshal(o.Payload) - return fmt.Sprintf("[GET /tools/garm-agent][%d] garmAgentListBadRequest %s", 400, payload) -} - -func (o *GarmAgentListBadRequest) GetPayload() apiserver_params.APIErrorResponse { - return o.Payload -} - -func (o *GarmAgentListBadRequest) 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 -} diff --git a/client/tools/tools_client.go b/client/tools/tools_client.go index 47fb6775..9f4222ad 100644 --- a/client/tools/tools_client.go +++ b/client/tools/tools_client.go @@ -56,7 +56,7 @@ type ClientOption func(*runtime.ClientOperation) // ClientService is the interface for Client methods type ClientService interface { - GarmAgentList(params *GarmAgentListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GarmAgentListOK, error) + AdminGarmAgentList(params *AdminGarmAgentListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*AdminGarmAgentListOK, error) UploadGARMAgentTool(params *UploadGARMAgentToolParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*UploadGARMAgentToolOK, error) @@ -64,22 +64,22 @@ type ClientService interface { } /* -GarmAgentList lists g a r m agent tools +AdminGarmAgentList lists g a r m agent tools for admin users */ -func (a *Client) GarmAgentList(params *GarmAgentListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GarmAgentListOK, error) { +func (a *Client) AdminGarmAgentList(params *AdminGarmAgentListParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*AdminGarmAgentListOK, error) { // TODO: Validate the params before sending if params == nil { - params = NewGarmAgentListParams() + params = NewAdminGarmAgentListParams() } op := &runtime.ClientOperation{ - ID: "GarmAgentList", + ID: "AdminGarmAgentList", Method: "GET", PathPattern: "/tools/garm-agent", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, - Reader: &GarmAgentListReader{formats: a.formats}, + Reader: &AdminGarmAgentListReader{formats: a.formats}, AuthInfo: authInfo, Context: params.Context, Client: params.HTTPClient, @@ -92,13 +92,13 @@ func (a *Client) GarmAgentList(params *GarmAgentListParams, authInfo runtime.Cli if err != nil { return nil, err } - success, ok := result.(*GarmAgentListOK) + success, ok := result.(*AdminGarmAgentListOK) 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 GarmAgentList: API contract not enforced by server. Client expected to get an error, but got: %T", result) + msg := fmt.Sprintf("unexpected success response for AdminGarmAgentList: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } diff --git a/cmd/garm-cli/cmd/controller.go b/cmd/garm-cli/cmd/controller.go index 87d7e687..033e97c5 100644 --- a/cmd/garm-cli/cmd/controller.go +++ b/cmd/garm-cli/cmd/controller.go @@ -197,25 +197,29 @@ var controllerToolsListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List GARM agent tools", - Long: `List all GARM agent tools available in the controller.`, + Long: `List all GARM agent tools available in the controller. Use --upstream to list tools from the upstream cached release.`, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { if needsInit { return errNeedsInitError } - showTools := apiClientTools.NewGarmAgentListParams() + showTools := apiClientTools.NewAdminGarmAgentListParams() if cmd.Flags().Changed("page") { showTools.Page = &fileObjPage } if cmd.Flags().Changed("page-size") { showTools.PageSize = &fileObjPageSize } - response, err := apiCli.Tools.GarmAgentList(showTools, authToken) + if cmd.Flags().Changed("upstream") { + upstreamVal := true + showTools.Upstream = &upstreamVal + } + response, err := apiCli.Tools.AdminGarmAgentList(showTools, authToken) if err != nil { return err } - formatGARMToolsList(response.Payload) + formatGARMToolsList(response.Payload, cmd.Flags().Changed("upstream")) return nil }, } @@ -521,6 +525,7 @@ func init() { controllerToolsListCmd.Flags().Int64Var(&fileObjPage, "page", 0, "The tools page to display") controllerToolsListCmd.Flags().Int64Var(&fileObjPageSize, "page-size", 25, "Total number of results per page") + controllerToolsListCmd.Flags().Bool("upstream", false, "List tools from the upstream cached release instead of the local object store") controllerToolsUploadCmd.Flags().StringVar(&toolFilePath, "file", "", "Path to the garm-agent binary file (required)") controllerToolsUploadCmd.Flags().StringVar(&toolOSType, "os", "", "Operating system: linux or windows (required)") @@ -550,17 +555,25 @@ func init() { rootCmd.AddCommand(controllerCmd) } -func formatGARMToolsList(files params.GARMAgentToolsPaginatedResponse) { +func formatGARMToolsList(files params.GARMAgentToolsPaginatedResponse, upstream bool) { if outputFormat == common.OutputFormatJSON { printAsJSON(files) return } t := table.NewWriter() - // Define column count - numCols := 8 t.Style().Options.SeparateHeader = true t.Style().Options.SeparateRows = true + var numCols int + var header table.Row + if upstream { + numCols = 6 + header = table.Row{"Name", "Size", "Version", "OS Type", "OS Architecture", "Download URL"} + } else { + numCols = 8 + header = table.Row{"ID", "Name", "Size", "Version", "OS Type", "OS Architecture", "Created", "Updated"} + } + // Page header - fill all columns with the same text pageHeaderText := fmt.Sprintf("Page %d of %d", files.CurrentPage, files.Pages) pageHeader := make(table.Row, numCols) @@ -571,18 +584,23 @@ func formatGARMToolsList(files params.GARMAgentToolsPaginatedResponse) { AutoMerge: true, AutoMergeAlign: text.AlignCenter, }) - // Column headers - header := table.Row{"ID", "Name", "Size", "Version", "OS Type", "OS Architecture", "Created", "Updated"} t.AppendHeader(header) - // Right-align numeric columns - t.SetColumnConfigs([]table.ColumnConfig{ - {Number: 1, Align: text.AlignRight}, - {Number: 3, Align: text.AlignRight}, - }) - for _, val := range files.Results { - row := table.Row{val.ID, val.Name, formatSize(val.Size), val.Version, val.OSType, val.OSArch, val.CreatedAt.Format("2006-01-02 15:04:05"), val.UpdatedAt.Format("2006-01-02 15:04:05")} - t.AppendRow(row) + if upstream { + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 2, Align: text.AlignRight}, + }) + for _, val := range files.Results { + t.AppendRow(table.Row{val.Name, formatSize(val.Size), val.Version, val.OSType, val.OSArch, val.DownloadURL}) + } + } else { + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, Align: text.AlignRight}, + {Number: 3, Align: text.AlignRight}, + }) + for _, val := range files.Results { + t.AppendRow(table.Row{val.ID, val.Name, formatSize(val.Size), val.Version, val.OSType, val.OSArch, val.CreatedAt, val.UpdatedAt}) + } } fmt.Println(t.Render()) } diff --git a/params/params.go b/params/params.go index d5983e57..3cae573b 100644 --- a/params/params.go +++ b/params/params.go @@ -1488,6 +1488,14 @@ type PaginatedResponse[T any] struct { // swagger:model FileObjectPaginatedResponse type FileObjectPaginatedResponse = PaginatedResponse[FileObject] +const ( + // ToolSourceLocal indicates the tool is stored in the internal object store. + ToolSourceLocal = "local" + // ToolSourceUpstream indicates the tool is only available from the upstream + // cached release and has not been downloaded locally. + ToolSourceUpstream = "upstream" +) + // swagger:model GARMAgentTool type GARMAgentTool struct { ID uint `json:"id"` @@ -1507,6 +1515,11 @@ type GARMAgentTool struct { // from a release URL, this will hold the release URL where the tools // originated from. Origin string `json:"origin"` + // Source indicates where this tool is currently stored. + // "local" means the tool is stored in the internal object store. + // "upstream" means the tool is only available from the upstream + // cached release and has not been downloaded locally. + Source string `json:"source"` } // swagger:model GARMAgentToolsPaginatedResponse diff --git a/runner/metadata.go b/runner/metadata.go index 20b4f8e6..66f306c1 100644 --- a/runner/metadata.go +++ b/runner/metadata.go @@ -26,6 +26,7 @@ import ( "log/slog" "net/url" "strings" + "time" "github.com/cloudbase/garm-provider-common/cloudconfig" "github.com/cloudbase/garm-provider-common/defaults" @@ -173,30 +174,73 @@ func (r *Runner) getRunnerInstallTemplateContext(instance params.Instance, entit return installRunnerParams, nil } -// getAgentTool retrieves the GARM agent tool for the given OS type and architecture. -// Priority: -// 1. Tools from object store (manual uploads or synced) -// 2. Tools from cached upstream release (GitHub release API data) -// 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 { +// getUpstreamToolsForGaps returns upstream cached tools for os/arch combinations +// not already covered by object store tools. If osType and osArch are non-nil, +// only the matching platform is considered (instance-scoped). If nil, all +// platforms are considered (admin-scoped). +func getUpstreamToolsForGaps(objectStoreTools []params.GARMAgentTool, osType *commonParams.OSType, osArch *commonParams.OSArch) []params.GARMAgentTool { ctrlInfo := cache.ControllerInfo() - // Check object store first (for both manual uploads and synced tools) - agentTools, err := r.GetGARMTools(ctx, 0, 100) - if err != nil && !errors.Is(err, runnerErrors.ErrNotFound) { - slog.WarnContext(ctx, "failed to query garm agent tools", "error", err) + // Check staleness - don't use cached data older than 24 hours + if ctrlInfo.CachedGARMAgentReleaseFetchedAt == nil { + return nil + } + if time.Since(*ctrlInfo.CachedGARMAgentReleaseFetchedAt) > 24*time.Hour { + return nil + } + if ctrlInfo.CachedGARMAgentTools == nil { + return nil } - // Filter results to match the requested OS type and architecture - for _, tool := range agentTools.Results { - if tool.OSType == osType && tool.OSArch == osArch { - return &tool + // Build set of os_type/os_arch keys already covered by object store tools + covered := make(map[string]bool, len(objectStoreTools)) + for _, tool := range objectStoreTools { + covered[string(tool.OSType)+"/"+string(tool.OSArch)] = true + } + + var result []params.GARMAgentTool + for key, tool := range ctrlInfo.CachedGARMAgentTools { + if covered[key] { + continue + } + // For instance-scoped calls, only include matching os/arch + if osType != nil && osArch != nil { + if tool.OSType != *osType || tool.OSArch != *osArch { + continue + } + } + tool.Source = params.ToolSourceUpstream + result = append(result, tool) + } + return result +} + +// getInstanceAgentTool retrieves the GARM agent tool for the given instance. +// It first checks the local object store, then falls back to upstream cached tools. +// Returns nil if no tools are available from any source. +func (r *Runner) getInstanceAgentTool(instance params.Instance) *params.GARMAgentTool { + tags := []string{ + garmAgentFileTag, + "os_type=" + string(instance.OSType), + "os_arch=" + string(instance.OSArch), + } + files, err := r.store.SearchFileObjectByTags(r.ctx, tags, 0, 1) + if err == nil && files.TotalCount > 0 { + downloadURL, err := url.JoinPath(instance.MetadataURL, "tools/garm-agent", fmt.Sprintf("%d", files.Results[0].ID), "download") + if err == nil { + tool, err := fileObjectToGARMTool(files.Results[0], downloadURL) + if err == nil { + return &tool + } } } - // No tools in object store - fall back to cached upstream release data - tool := ctrlInfo.GetCachedAgentTool(string(osType), string(osArch)) - return tool + // Fall back to upstream cached tools + upstreamTools := getUpstreamToolsForGaps(nil, &instance.OSType, &instance.OSArch) + if len(upstreamTools) > 0 { + return &upstreamTools[0] + } + return nil } func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetadata, error) { @@ -267,7 +311,7 @@ func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetada } if dbEntity.AgentMode { - agentTool := r.getAgentTool(ctx, instance.OSType, instance.OSArch) + agentTool := r.getInstanceAgentTool(instance) // If we have tools, set agent metadata if agentTool != nil { @@ -588,22 +632,81 @@ func fileObjectToGARMTool(obj params.FileObject, downloadURL string) (params.GAR DownloadURL: downloadURL, Version: version, Origin: origin, + Source: params.ToolSourceLocal, } return res, nil } -func (r *Runner) GetGARMTools(ctx context.Context, page, pageSize uint64) (params.GARMAgentToolsPaginatedResponse, error) { +// GetGARMTools lists GARM agent tools available to admin users. +// When upstream is false, it lists tools from the local object store. +// When upstream is true, it lists tools from the cached upstream release JSON. +func (r *Runner) GetGARMTools(ctx context.Context, page, pageSize uint64, upstream bool) (params.GARMAgentToolsPaginatedResponse, error) { + if !auth.IsAdmin(ctx) { + return params.GARMAgentToolsPaginatedResponse{}, runnerErrors.ErrUnauthorized + } + + if upstream { + return r.getUpstreamGARMTools() + } + tags := []string{ garmAgentFileTag, } + metadataURL := cache.ControllerInfo().MetadataURL + + files, err := r.store.SearchFileObjectByTags(r.ctx, tags, page, pageSize) + if err != nil { + return params.GARMAgentToolsPaginatedResponse{}, fmt.Errorf("failed to list files: %w", err) + } + + var tools []params.GARMAgentTool + for _, val := range files.Results { + objectIDAsString := fmt.Sprintf("%d", val.ID) + downloadURL, err := url.JoinPath(metadataURL, "tools/garm-agent", objectIDAsString, "download") + if err != nil { + return params.GARMAgentToolsPaginatedResponse{}, fmt.Errorf("failed to construct agent tools download URL: %w", err) + } + res, err := fileObjectToGARMTool(val, downloadURL) + if err != nil { + return params.GARMAgentToolsPaginatedResponse{}, fmt.Errorf("failed parse tools object: %w", err) + } + tools = append(tools, res) + } + + return params.GARMAgentToolsPaginatedResponse{ + TotalCount: files.TotalCount, + Pages: files.Pages, + CurrentPage: files.CurrentPage, + NextPage: files.NextPage, + PreviousPage: files.PreviousPage, + Results: tools, + }, nil +} + +// getUpstreamGARMTools returns tools from the cached upstream release JSON. +func (r *Runner) getUpstreamGARMTools() (params.GARMAgentToolsPaginatedResponse, error) { + upstreamTools := getUpstreamToolsForGaps(nil, nil, nil) + return params.GARMAgentToolsPaginatedResponse{ + TotalCount: uint64(len(upstreamTools)), + Pages: 1, + CurrentPage: 0, + Results: upstreamTools, + }, nil +} + +// GetAgentGARMTools lists GARM agent tools available for an instance, filtered by +// the instance's OS type and architecture. On the last page, upstream cached tools +// are merged to fill gaps for os/arch combos not present in the local object store. +func (r *Runner) GetAgentGARMTools(ctx context.Context, page, pageSize uint64) (params.GARMAgentToolsPaginatedResponse, error) { instance, err := validateInstanceState(ctx) if err != nil { - if !auth.IsAdmin(ctx) { - return params.GARMAgentToolsPaginatedResponse{}, runnerErrors.ErrUnauthorized - } - } else { - tags = append(tags, "os_type="+string(instance.OSType)) - tags = append(tags, "os_arch="+string(instance.OSArch)) + return params.GARMAgentToolsPaginatedResponse{}, runnerErrors.ErrUnauthorized + } + + tags := []string{ + garmAgentFileTag, + "os_type=" + string(instance.OSType), + "os_arch=" + string(instance.OSArch), } files, err := r.store.SearchFileObjectByTags(r.ctx, tags, page, pageSize) @@ -624,6 +727,14 @@ func (r *Runner) GetGARMTools(ctx context.Context, page, pageSize uint64) (param } tools = append(tools, res) } + + // On the last page, merge upstream cached tools that fill gaps + // (os/arch combos not present in the object store). + if files.NextPage == nil { + upstreamTools := getUpstreamToolsForGaps(tools, &instance.OSType, &instance.OSArch) + tools = append(tools, upstreamTools...) + } + return params.GARMAgentToolsPaginatedResponse{ TotalCount: files.TotalCount, Pages: files.Pages, diff --git a/runner/metadata_test.go b/runner/metadata_test.go index e7552b51..8f72957e 100644 --- a/runner/metadata_test.go +++ b/runner/metadata_test.go @@ -23,6 +23,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -739,6 +740,11 @@ func (s *MetadataTestSuite) TestGetInstanceMetadataBasicFields() { } func (s *MetadataTestSuite) TestGetInstanceMetadataWithAgentModeNoTools() { + // Clear any upstream tools from controller cache (may be set by other tests) + cache.SetControllerCache(params.ControllerInfo{ + MetadataURL: "http://metadata.example.com", + }) + // Set up runner tools cache tools := []commonParams.RunnerApplicationDownload{ { @@ -789,6 +795,11 @@ func (s *MetadataTestSuite) TestGetInstanceMetadataAgentModeDisabledByDefault() } func (s *MetadataTestSuite) TestGetInstanceMetadataWithAgentModeToolsCountZero() { + // Clear any upstream tools from controller cache (may be set by other tests) + cache.SetControllerCache(params.ControllerInfo{ + MetadataURL: "http://metadata.example.com", + }) + // Set up runner tools cache tools := []commonParams.RunnerApplicationDownload{ { @@ -820,6 +831,11 @@ func (s *MetadataTestSuite) TestGetInstanceMetadataWithAgentModeToolsCountZero() } func (s *MetadataTestSuite) TestGetInstanceMetadataWithAgentModeGetToolsReturnsNotFoundError() { + // Clear any upstream tools from controller cache (may be set by other tests) + cache.SetControllerCache(params.ControllerInfo{ + MetadataURL: "http://metadata.example.com", + }) + // Set up runner tools cache tools := []commonParams.RunnerApplicationDownload{ { @@ -980,27 +996,161 @@ func (s *MetadataTestSuite) TestFileObjectToGARMTool() { } func (s *MetadataTestSuite) TestGetGARMTools() { - // GetGARMTools requires file objects in database - // This is tested in file_store_test.go and garm_tools_test.go - // Here we just test the authorization paths - _, err := s.Runner.GetGARMTools(s.instanceCtx, 0, 25) + // GetGARMTools is admin-only, should succeed for admin context + _, err := s.Runner.GetGARMTools(s.adminCtx, 0, 25, false) + s.Require().NoError(err) +} - // Should not error on authorization (might have no results) +func (s *MetadataTestSuite) TestGetAgentGARMTools() { + // GetAgentGARMTools is instance-only, should succeed for instance context + _, err := s.Runner.GetAgentGARMTools(s.instanceCtx, 0, 25) s.Require().NoError(err) } func (s *MetadataTestSuite) TestGetGARMToolsUnauthorized() { - _, err := s.Runner.GetGARMTools(s.unauthorizedCtx, 0, 25) + _, err := s.Runner.GetGARMTools(s.unauthorizedCtx, 0, 25, false) s.Require().NotNil(err) s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) } -func (s *MetadataTestSuite) TestGetGARMToolsInvalidState() { - _, err := s.Runner.GetGARMTools(s.invalidInstanceCtx, 0, 25) +func (s *MetadataTestSuite) TestGetGARMToolsInstanceUnauthorized() { + // GetGARMTools is admin-only, instance context should fail + _, err := s.Runner.GetGARMTools(s.instanceCtx, 0, 25, false) s.Require().NotNil(err) s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) } +func (s *MetadataTestSuite) TestGetAgentGARMToolsUnauthorized() { + _, err := s.Runner.GetAgentGARMTools(s.unauthorizedCtx, 0, 25) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestGetAgentGARMToolsInvalidState() { + _, err := s.Runner.GetAgentGARMTools(s.invalidInstanceCtx, 0, 25) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestGetAgentGARMToolsMergesUpstreamTools() { + // Set up controller cache with upstream tools but no object store tools + now := time.Now() + cache.SetControllerCache(params.ControllerInfo{ + MetadataURL: "http://metadata.example.com", + CachedGARMAgentReleaseFetchedAt: &now, + CachedGARMAgentTools: map[string]params.GARMAgentTool{ + "linux/amd64": { + Name: "garm-agent-linux-amd64", + Version: "v0.1.0", + OSType: commonParams.Linux, + OSArch: commonParams.Amd64, + DownloadURL: "https://github.com/example/releases/download/v0.1.0/garm-agent-linux-amd64", + }, + "linux/arm64": { + Name: "garm-agent-linux-arm64", + Version: "v0.1.0", + OSType: commonParams.Linux, + OSArch: commonParams.Arm64, + DownloadURL: "https://github.com/example/releases/download/v0.1.0/garm-agent-linux-arm64", + }, + }, + }) + + // Instance context filters to linux/amd64 - should get the upstream tool + result, err := s.Runner.GetAgentGARMTools(s.instanceCtx, 0, 25) + s.Require().NoError(err) + s.Require().Len(result.Results, 1) + s.Require().Equal("garm-agent-linux-amd64", result.Results[0].Name) + s.Require().Equal(params.ToolSourceUpstream, result.Results[0].Source) +} + +func (s *MetadataTestSuite) TestGetAgentGARMToolsObjectStoreOverridesUpstream() { + // Set up controller cache with upstream tools + now := time.Now() + cache.SetControllerCache(params.ControllerInfo{ + MetadataURL: "http://metadata.example.com", + CachedGARMAgentReleaseFetchedAt: &now, + CachedGARMAgentTools: map[string]params.GARMAgentTool{ + "linux/amd64": { + Name: "garm-agent-linux-amd64", + Version: "v0.1.0", + OSType: commonParams.Linux, + OSArch: commonParams.Amd64, + DownloadURL: "https://github.com/example/releases/download/v0.1.0/garm-agent-linux-amd64", + }, + "linux/arm64": { + Name: "garm-agent-linux-arm64", + Version: "v0.1.0", + OSType: commonParams.Linux, + OSArch: commonParams.Arm64, + DownloadURL: "https://github.com/example/releases/download/v0.1.0/garm-agent-linux-arm64", + }, + }, + }) + + // Create object store tool for linux/amd64 + agentBinary := []byte("fake garm agent binary") + _, err := s.Runner.CreateGARMTool(s.adminCtx, params.CreateGARMToolParams{ + Name: "garm-agent-linux-amd64", + Size: int64(len(agentBinary)), + OSType: commonParams.Linux, + OSArch: commonParams.Amd64, + Version: "v1.0.0", + }, bytes.NewReader(agentBinary)) + s.Require().NoError(err) + + // Instance is linux/amd64 - should get local tool, not upstream + result, err := s.Runner.GetAgentGARMTools(s.instanceCtx, 0, 25) + s.Require().NoError(err) + s.Require().Len(result.Results, 1) + s.Require().Equal(params.ToolSourceLocal, result.Results[0].Source) + s.Require().Equal("v1.0.0", result.Results[0].Version) +} + +func (s *MetadataTestSuite) TestGetAgentGARMToolsStaleUpstreamNotIncluded() { + // Set up controller cache with stale upstream tools (>24h old) + staleTime := time.Now().Add(-25 * time.Hour) + cache.SetControllerCache(params.ControllerInfo{ + MetadataURL: "http://metadata.example.com", + CachedGARMAgentReleaseFetchedAt: &staleTime, + CachedGARMAgentTools: map[string]params.GARMAgentTool{ + "linux/amd64": { + Name: "garm-agent-linux-amd64", + Version: "v0.1.0", + OSType: commonParams.Linux, + OSArch: commonParams.Amd64, + }, + }, + }) + + // Instance context - stale upstream tools should not be included + result, err := s.Runner.GetAgentGARMTools(s.instanceCtx, 0, 25) + s.Require().NoError(err) + s.Require().Empty(result.Results) +} + +func (s *MetadataTestSuite) TestGetGARMToolsAdminNoUpstream() { + // Set up controller cache with upstream tools but no object store tools + now := time.Now() + cache.SetControllerCache(params.ControllerInfo{ + MetadataURL: "http://metadata.example.com", + CachedGARMAgentReleaseFetchedAt: &now, + CachedGARMAgentTools: map[string]params.GARMAgentTool{ + "linux/amd64": { + Name: "garm-agent-linux-amd64", + Version: "v0.1.0", + OSType: commonParams.Linux, + OSArch: commonParams.Amd64, + }, + }, + }) + + // Admin context should NOT include upstream tools + result, err := s.Runner.GetGARMTools(s.adminCtx, 0, 25, false) + s.Require().NoError(err) + s.Require().Empty(result.Results) +} + func (s *MetadataTestSuite) TestShowGARMToolsUnauthorized() { _, err := s.Runner.ShowGARMTools(s.unauthorizedCtx, 1) s.Require().NotNil(err)