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