From b47caa4bf57b3e3168001fae575f54cfe71c0d58 Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Fri, 10 Oct 2025 13:55:54 +0000 Subject: [PATCH] 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 --- apiserver/controllers/metadata.go | 15 +++++ apiserver/routers/routers.go | 12 ++-- params/params.go | 27 ++++++++ runner/metadata.go | 103 +++++++++++++++++++++++++++--- 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/apiserver/controllers/metadata.go b/apiserver/controllers/metadata.go index a08afdbb..23817acc 100644 --- a/apiserver/controllers/metadata.go +++ b/apiserver/controllers/metadata.go @@ -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() diff --git a/apiserver/routers/routers.go b/apiserver/routers/routers.go index 3cf4e8d1..4866c156 100644 --- a/apiserver/routers/routers.go +++ b/apiserver/routers/routers.go @@ -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 // diff --git a/params/params.go b/params/params.go index d2d5365e..a60f314d 100644 --- a/params/params.go +++ b/params/params.go @@ -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"` +} diff --git a/runner/metadata.go b/runner/metadata.go index 886dd0ee..3a33b11f 100644 --- a/runner/metadata.go +++ b/runner/metadata.go @@ -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