Add instance metadata URL

The runner metatada URL is meant to give runner install scripts an
easier way to get instance specific metadata, needed for the setup
process. We can use this URL to easier expand installation metadata as
opposed to having to change the cloud config InstallRunnerParams{}.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This commit is contained in:
Gabriel Adrian Samfira 2025-10-10 13:55:54 +00:00 committed by Gabriel
parent 78856b56b2
commit b47caa4bf5
4 changed files with 145 additions and 12 deletions

View file

@ -26,6 +26,21 @@ import (
"github.com/cloudbase/garm/apiserver/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")
}
}
func (a *APIController) InstanceGARMToolsHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View file

@ -159,6 +159,9 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware
metadataRouter := apiSubRouter.PathPrefix("/metadata").Subrouter()
metadataRouter.Use(instanceMiddleware.Middleware)
// Instance metadata
metadataRouter.Handle("/runner-metadata/", http.HandlerFunc(han.InstanceMetadataHandler)).Methods("GET", "OPTIONS")
metadataRouter.Handle("/runner-metadata", http.HandlerFunc(han.InstanceMetadataHandler)).Methods("GET", "OPTIONS")
// Registration token
metadataRouter.Handle("/runner-registration-token/", http.HandlerFunc(han.InstanceGithubRegistrationTokenHandler)).Methods("GET", "OPTIONS")
metadataRouter.Handle("/runner-registration-token", http.HandlerFunc(han.InstanceGithubRegistrationTokenHandler)).Methods("GET", "OPTIONS")
@ -241,10 +244,11 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware
apiRouter.Handle("/objects/{objectID}/", http.HandlerFunc(han.UpdateFileObject)).Methods("PUT", "OPTIONS")
apiRouter.Handle("/objects/{objectID}", http.HandlerFunc(han.UpdateFileObject)).Methods("PUT", "OPTIONS")
// DELETEME //
// Tools
apiRouter.Handle("/tools/garm/", http.HandlerFunc(han.InstanceGARMToolsHandler)).Methods("GET", "OPTIONS")
apiRouter.Handle("/tools/garm", http.HandlerFunc(han.InstanceGARMToolsHandler)).Methods("GET", "OPTIONS")
///////////////////////////////////////////////////////
// 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")
//////////
// Jobs //

View file

@ -1350,3 +1350,30 @@ type GARMAgentTool struct {
// swagger:model GARMAgentToolsPaginatedResponse
type GARMAgentToolsPaginatedResponse = PaginatedResponse[GARMAgentTool]
// swagger:model MetadataServiceAccessDetails
type MetadataServiceAccessDetails struct {
CallbackURL string `json:"callback_url"`
MetadataURL string `json:"metadata_url"`
}
// swagger:model InstanceMetadata
type InstanceMetadata struct {
MetadataAccess MetadataServiceAccessDetails `json:"metadata_access"`
ForgeType EndpointType `json:"forge_type"`
// RunnerRegistrationURL is the URL the runner needs to configure itself
// against. This can be a repository, organization, enterprise (github)
// or system (gitea)
RunnerRegistrationURL string `json:"runner_registration_url"`
RunnerName string `json:"runner_name"`
RunnerLabels []string `json:"runner_labels,omitempty"`
CABundle map[string][]byte `json:"ca_bundles,omitempty"`
// ExtraSpecs represents the extra specs set on the pool or scale set. No secrets should
// be set in extra specs.
// Also, the instance metadata should never be saved to disk, and the metadata URL is only
// accessible during setup of the runner. The API returns unauthorized once the runner
// transitions to failed/idle.
ExtraSpecs map[string]any `json:"extra_specs,omitempty"`
JITEnabled bool `json:"jit_enabled"`
RunnerTools commonParams.RunnerApplicationDownload `json:"runner_tools"`
}

View file

@ -171,6 +171,93 @@ func (r *Runner) getRunnerInstallTemplateContext(instance params.Instance, entit
return installRunnerParams, nil
}
func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetadata, error) {
instance, err := validateInstanceState(ctx)
if err != nil {
return params.InstanceMetadata{}, runnerErrors.ErrUnauthorized
}
var entityGetter params.EntityGetter
var extraSpecs json.RawMessage
switch {
case instance.PoolID != "":
pool, err := r.store.GetPoolByID(r.ctx, instance.PoolID)
if err != nil {
return params.InstanceMetadata{}, fmt.Errorf("failed to get pool: %w", err)
}
entityGetter = pool
extraSpecs = pool.ExtraSpecs
case instance.ScaleSetID != 0:
scaleSet, err := r.store.GetScaleSetByID(r.ctx, instance.ScaleSetID)
if err != nil {
return params.InstanceMetadata{}, fmt.Errorf("failed to get scale set: %w", err)
}
entityGetter = scaleSet
extraSpecs = scaleSet.ExtraSpecs
default:
// This is not actually an unauthorized scenario. This case means that an
// instance was created but it does not belong to any pool or scale set.
// This is an internal error state, but it's not something we should expose
// to a potential runner that is trying to start.
slog.ErrorContext(ctx, "runner is authentic but does not belong to a pool or scale set", "instance_name", instance.Name, "instance_id", instance.ID)
return params.InstanceMetadata{}, runnerErrors.ErrUnauthorized
}
entity, err := entityGetter.GetEntity()
if err != nil {
return params.InstanceMetadata{}, fmt.Errorf("failed to get entity: %w", err)
}
dbEntity, err := r.store.GetForgeEntity(ctx, entity.EntityType, entity.ID)
if err != nil {
return params.InstanceMetadata{}, fmt.Errorf("failed to get entity: %w", err)
}
ret := params.InstanceMetadata{
RunnerName: instance.Name,
RunnerLabels: getLabelsForInstance(instance),
RunnerRegistrationURL: dbEntity.ForgeURL(),
MetadataAccess: params.MetadataServiceAccessDetails{
CallbackURL: instance.CallbackURL,
MetadataURL: instance.MetadataURL,
},
ForgeType: dbEntity.Credentials.ForgeType,
JITEnabled: len(instance.JitConfiguration) > 0,
}
if len(dbEntity.Credentials.Endpoint.CACertBundle) > 0 {
// We can add other CA bundles here as needed.
ret.CABundle["forge_ca"] = dbEntity.Credentials.Endpoint.CACertBundle
}
if len(extraSpecs) > 0 {
var specs map[string]any
if err := json.Unmarshal(extraSpecs, &specs); err != nil {
return params.InstanceMetadata{}, fmt.Errorf("failed to decode extra specs: %w", err)
}
ret.ExtraSpecs = specs
}
tools, err := cache.GetGithubToolsCache(dbEntity.ID)
if err != nil {
return params.InstanceMetadata{}, fmt.Errorf("failed to find tools: %w", err)
}
filtered, err := util.GetTools(instance.OSType, instance.OSArch, tools)
if err != nil {
return params.InstanceMetadata{}, fmt.Errorf("no tools available: %w", err)
}
ret.RunnerTools = filtered
switch dbEntity.Credentials.ForgeType {
case params.GiteaEndpointType:
case params.GithubEndpointType:
default:
return params.InstanceMetadata{}, fmt.Errorf("invalid forge type: %s", dbEntity.Credentials.ForgeType)
}
return ret, nil
}
func (r *Runner) GetRunnerInstallScript(ctx context.Context) ([]byte, error) {
instance, err := validateInstanceState(ctx)
if err != nil {
@ -331,7 +418,7 @@ func (r *Runner) GetJITConfigFile(ctx context.Context, file string) ([]byte, err
func (r *Runner) GetInstanceGithubRegistrationToken(ctx context.Context) (string, error) {
// Check if this instance already fetched a registration token or if it was configured using
// the new Just In Time runner feature. If we're still using the old way of configuring a runner,
// the Just In Time runner feature. If we're still using the old way of configuring a runner,
// we only allow an instance to fetch one token. If the instance fails to bootstrap after a token
// is fetched, we reset the token fetched field when re-queueing the instance.
if auth.InstanceTokenFetched(ctx) || auth.InstanceHasJITConfig(ctx) {
@ -407,7 +494,7 @@ func (r *Runner) GetGARMTools(ctx context.Context, page, pageSize uint64) (param
tags := []string{
"category=garm-agent",
}
instance, err := auth.InstanceParams(ctx)
instance, err := validateInstanceState(ctx)
if err != nil {
if !auth.IsAdmin(ctx) {
return params.GARMAgentToolsPaginatedResponse{}, runnerErrors.ErrUnauthorized
@ -426,17 +513,17 @@ func (r *Runner) GetGARMTools(ctx context.Context, page, pageSize uint64) (param
for _, val := range files.Results {
tags := val.Tags
var version string
var os_type string
var os_arch string
var osType string
var osArch string
for _, val := range tags {
if strings.HasPrefix(val, "version=") {
version = val[8:]
}
if strings.HasPrefix(val, "os_arch=") {
os_arch = val[8:]
osArch = val[8:]
}
if strings.HasPrefix(val, "os_type=") {
os_type = val[8:]
osType = val[8:]
}
}
res := params.GARMAgentTool{
@ -448,8 +535,8 @@ func (r *Runner) GetGARMTools(ctx context.Context, page, pageSize uint64) (param
CreatedAt: val.CreatedAt,
UpdatedAt: val.UpdatedAt,
FileType: val.FileType,
OSType: commonParams.OSType(os_type),
OSArch: commonParams.OSArch(os_arch),
OSType: commonParams.OSType(osType),
OSArch: commonParams.OSArch(osArch),
}
if version != "" {
res.Version = version