garm/runner/garm_tools.go
Gabriel Adrian Samfira def4b4aaf1 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>
2026-02-08 00:27:47 +02:00

250 lines
6.9 KiB
Go

// 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 runner
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
runnerErrors "github.com/cloudbase/garm-provider-common/errors"
"github.com/cloudbase/garm/auth"
"github.com/cloudbase/garm/params"
)
var (
garmAgentFileTag = "category=garm-agent"
garmAgentOSTypeWindowsTag = "os_type=windows"
garmAgentOSTypeLinuxTag = "os_type=linux"
garmAgentOSArchAMD64Tag = "os_arch=amd64"
garmAgentOSArchARM64Tag = "os_arch=arm64"
)
func (r *Runner) ListAllGARMTools(ctx context.Context) ([]params.GARMAgentTool, error) {
if !auth.IsAdmin(ctx) {
return nil, runnerErrors.ErrUnauthorized
}
ret := []params.GARMAgentTool{}
var next uint64 = 1
for {
allAgentTools, err := r.store.SearchFileObjectByTags(r.ctx, []string{garmAgentFileTag}, next, 100)
if err != nil {
return nil, fmt.Errorf("failed to list files: %w", err)
}
if allAgentTools.TotalCount == 0 {
return nil, nil
}
for _, tool := range allAgentTools.Results {
parsed, err := fileObjectToGARMTool(tool, "")
if err != nil {
return nil, fmt.Errorf("failed to parse object with ID %d", tool.ID)
}
ret = append(ret, parsed)
}
if allAgentTools.NextPage == nil {
break
}
next = *allAgentTools.NextPage
}
return ret, nil
}
func (r *Runner) CreateGARMTool(ctx context.Context, param params.CreateGARMToolParams, reader io.Reader) (params.FileObject, error) {
if !auth.IsAdmin(ctx) {
return params.FileObject{}, runnerErrors.ErrUnauthorized
}
// Validate version is provided
if param.Version == "" {
return params.FileObject{}, runnerErrors.NewBadRequestError("version is required")
}
// Build tags based on OS type and arch
var osTypeTag, osArchTag string
switch param.OSType {
case "windows":
osTypeTag = garmAgentOSTypeWindowsTag
case "linux":
osTypeTag = garmAgentOSTypeLinuxTag
default:
return params.FileObject{}, runnerErrors.NewBadRequestError("invalid os_type: must be 'windows' or 'linux'")
}
switch param.OSArch {
case "amd64":
osArchTag = garmAgentOSArchAMD64Tag
case "arm64":
osArchTag = garmAgentOSArchARM64Tag
default:
return params.FileObject{}, runnerErrors.NewBadRequestError("invalid os_arch: must be 'amd64' or 'arm64'")
}
// Build tags: category, os_type, os_arch, version, origin
tags := []string{
garmAgentFileTag,
osTypeTag,
osArchTag,
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,
Description: param.Description,
Size: param.Size,
Tags: tags,
}
// Upload the new binary
newTool, err := r.store.CreateFileObject(ctx, createParams, reader)
if err != nil {
return params.FileObject{}, fmt.Errorf("failed to upload garm-agent tool: %w", err)
}
slog.DebugContext(ctx, "uploaded new garm-agent tool",
"tool_id", newTool.ID,
"name", newTool.Name,
"os_type", param.OSType,
"os_arch", param.OSArch,
"version", param.Version,
"size", newTool.Size)
// Clean up old versions (keep only the newly uploaded one)
// Build tags to find all binaries with same OS/ARCH (excluding version)
cleanupTags := []string{garmAgentFileTag, osTypeTag, osArchTag}
// Delete all except the one we just uploaded
// Paginate through all results to ensure we delete everything
deletedCount := 0
page := uint64(1)
pageSize := uint64(100)
for {
allTools, err := r.store.SearchFileObjectByTags(ctx, cleanupTags, page, pageSize)
if err != nil {
slog.ErrorContext(ctx, "failed to search for old garm-agent versions during cleanup",
"error", err,
"os_type", param.OSType,
"os_arch", param.OSArch,
"new_tool_id", newTool.ID,
"page", page)
// Don't fail - upload succeeded
break
}
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",
"error", err,
"tool_id", tool.ID,
"tool_name", tool.Name,
"os_type", param.OSType,
"os_arch", param.OSArch)
continue
}
deletedCount++
// 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)
}
}
}
// Check if there's a next page
if allTools.NextPage == nil {
break
}
page = *allTools.NextPage
}
if deletedCount > 0 {
slog.InfoContext(ctx, "cleaned up old garm-agent versions",
"deleted_count", deletedCount,
"os_type", param.OSType,
"os_arch", param.OSArch,
"kept_version", param.Version)
}
return newTool, nil
}
func (r *Runner) DeleteGarmTool(ctx context.Context, osType, osArch string) error {
if !auth.IsAdmin(ctx) {
return runnerErrors.ErrUnauthorized
}
// Build tags based on OS type and arch
tags := []string{garmAgentFileTag}
switch osType {
case "windows":
tags = append(tags, garmAgentOSTypeWindowsTag)
case "linux":
tags = append(tags, garmAgentOSTypeLinuxTag)
default:
return runnerErrors.NewBadRequestError("invalid os_type: must be 'windows' or 'linux'")
}
switch osArch {
case "amd64":
tags = append(tags, garmAgentOSArchAMD64Tag)
case "arm64":
tags = append(tags, garmAgentOSArchARM64Tag)
default:
return runnerErrors.NewBadRequestError("invalid os_arch: must be 'amd64' or 'arm64'")
}
// Delete all tools matching these tags
deletedCount, err := r.store.DeleteFileObjectsByTags(ctx, tags)
if err != nil {
return fmt.Errorf("failed to delete garm-agent tools: %w", err)
}
if deletedCount == 0 {
return runnerErrors.NewNotFoundError("no garm-agent tools found for %s/%s", osType, osArch)
}
return nil
}