garm/apiserver/controllers/metadata.go
Gabriel Adrian Samfira 61b4b4cadd 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 <gsamfira@cloudbasesolutions.com>
2026-02-08 16:00:50 +02:00

378 lines
11 KiB
Go

// Copyright 2023 Cloudbase Solutions SRL
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
package controllers
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"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) {
ctx := r.Context()
metadata, err := a.r.GetInstanceMetadata(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get instance metadata", "error", err)
handleError(ctx, w, err)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(metadata); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}
// swagger:route GET /tools/garm-agent tools GarmAgentList
//
// List GARM agent tools.
//
// 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
//
// Responses:
// 200: GARMAgentToolsPaginatedResponse
// 400: APIErrorResponse
func (a *APIController) InstanceGARMToolsHandler(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
}
}
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
}
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")
}
}
func (a *APIController) InstanceShowGARMToolHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
objectID, err := getObjectIDFromVars(vars)
if err != nil {
slog.ErrorContext(ctx, "failed to get object ID", "error", err)
handleError(ctx, w, gErrors.NewBadRequestError("invalid objectID: %s", err))
return
}
tools, err := a.r.ShowGARMTools(ctx, objectID)
if err != nil {
slog.ErrorContext(ctx, "failed to get garm tools", "error", err)
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")
}
}
func (a *APIController) InstanceGARMToolDownloadHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
objectID, err := getObjectIDFromVars(vars)
if err != nil {
slog.ErrorContext(ctx, "failed to get object ID", "error", err)
handleError(ctx, w, gErrors.NewBadRequestError("invalid objectID: %s", err))
return
}
reader, err := a.r.GetGARMToolsReadHandler(ctx, objectID)
if err != nil {
handleError(ctx, w, err)
return
}
defer reader.Close()
if _, err := io.Copy(w, reader); err != nil {
slog.ErrorContext(ctx, "failed to stream data", "error", err)
}
}
func (a *APIController) InstanceGithubRegistrationTokenHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token, err := a.r.GetInstanceGithubRegistrationToken(ctx)
if err != nil {
handleError(ctx, w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(token)); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}
func (a *APIController) JITCredentialsFileHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
fileName, ok := vars["fileName"]
if !ok {
w.WriteHeader(http.StatusNotFound)
if err := json.NewEncoder(w).Encode(params.APIErrorResponse{
Error: "Not Found",
Details: "Not Found",
}); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
return
}
dotFileName := fmt.Sprintf(".%s", fileName)
data, err := a.r.GetJITConfigFile(ctx, dotFileName)
if err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "getting JIT config file")
handleError(ctx, w, err)
return
}
// Note the leading dot in the filename
name := fmt.Sprintf("attachment; filename=%s", dotFileName)
w.Header().Set("Content-Disposition", name)
w.Header().Set("Content-Type", "octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.WriteHeader(http.StatusOK)
if _, err := w.Write(data); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}
func (a *APIController) SystemdServiceNameHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
serviceName, err := a.r.GetRunnerServiceName(ctx)
if err != nil {
handleError(ctx, w, err)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(serviceName)); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}
func (a *APIController) SystemdUnitFileHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
runAsUser := r.URL.Query().Get("runAsUser")
data, err := a.r.GenerateSystemdUnitFile(ctx, runAsUser)
if err != nil {
handleError(ctx, w, err)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(data); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}
func (a *APIController) RootCertificateBundleHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
bundle, err := a.r.GetRootCertificateBundle(ctx)
if err != nil {
handleError(ctx, w, err)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(bundle); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}
// 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()
installScript, err := a.r.GetRunnerInstallScript(ctx)
if err != nil {
slog.InfoContext(ctx, "failed to get runner install template", "error", err)
handleError(ctx, w, err)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(installScript); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}