Merge pull request #163 from gabriel-samfira/add-jit-config

Add jit config
This commit is contained in:
Gabriel 2023-09-24 17:15:38 +03:00 committed by GitHub
commit a48ec0c0a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 783 additions and 89 deletions

View file

@ -16,8 +16,12 @@ package controllers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/cloudbase/garm/apiserver/params"
"github.com/gorilla/mux"
)
func (a *APIController) InstanceGithubRegistrationTokenHandler(w http.ResponseWriter, r *http.Request) {
@ -36,6 +40,74 @@ func (a *APIController) InstanceGithubRegistrationTokenHandler(w http.ResponseWr
}
}
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 {
log.Printf("failed to encode response: %q", err)
}
return
}
dotFileName := fmt.Sprintf(".%s", fileName)
data, err := a.r.GetJITConfigFile(ctx, dotFileName)
if err != nil {
log.Printf("getting JIT config file: %s", err)
handleError(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 {
log.Printf("failed to encode response: %q", err)
}
}
func (a *APIController) SystemdServiceNameHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
serviceName, err := a.r.GetRunnerServiceName(ctx)
if err != nil {
handleError(w, err)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(serviceName)); err != nil {
log.Printf("failed to encode response: %q", err)
}
}
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(w, err)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(data); err != nil {
log.Printf("failed to encode response: %q", err)
}
}
func (a *APIController) RootCertificateBundleHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View file

@ -117,6 +117,14 @@ func NewAPIRouter(han *controllers.APIController, logWriter io.Writer, authMiddl
// 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")
// JIT credential files
metadataRouter.Handle("/credentials/{fileName}/", http.HandlerFunc(han.JITCredentialsFileHandler)).Methods("GET", "OPTIONS")
metadataRouter.Handle("/credentials/{fileName}", http.HandlerFunc(han.JITCredentialsFileHandler)).Methods("GET", "OPTIONS")
// Systemd files
metadataRouter.Handle("/system/service-name/", http.HandlerFunc(han.SystemdServiceNameHandler)).Methods("GET", "OPTIONS")
metadataRouter.Handle("/system/service-name", http.HandlerFunc(han.SystemdServiceNameHandler)).Methods("GET", "OPTIONS")
metadataRouter.Handle("/systemd/unit-file/", http.HandlerFunc(han.SystemdUnitFileHandler)).Methods("GET", "OPTIONS")
metadataRouter.Handle("/systemd/unit-file", http.HandlerFunc(han.SystemdUnitFileHandler)).Methods("GET", "OPTIONS")
metadataRouter.Handle("/system/cert-bundle/", http.HandlerFunc(han.RootCertificateBundleHandler)).Methods("GET", "OPTIONS")
metadataRouter.Handle("/system/cert-bundle", http.HandlerFunc(han.RootCertificateBundleHandler)).Methods("GET", "OPTIONS")

View file

@ -39,6 +39,7 @@ const (
instanceEntityKey contextFlags = "entity"
instanceRunnerStatus contextFlags = "status"
instanceTokenFetched contextFlags = "tokenFetched"
instanceHasJITConfig contextFlags = "hasJITConfig"
instanceParams contextFlags = "instanceParams"
)
@ -66,6 +67,18 @@ func InstanceTokenFetched(ctx context.Context) bool {
return elem.(bool)
}
func SetInstanceHasJITConfig(ctx context.Context, cfg map[string]string) context.Context {
return context.WithValue(ctx, instanceHasJITConfig, len(cfg) > 0)
}
func InstanceHasJITConfig(ctx context.Context) bool {
elem := ctx.Value(instanceHasJITConfig)
if elem == nil {
return false
}
return elem.(bool)
}
func SetInstanceParams(ctx context.Context, instance params.Instance) context.Context {
return context.WithValue(ctx, instanceParams, instance)
}
@ -149,6 +162,7 @@ func PopulateInstanceContext(ctx context.Context, instance params.Instance) cont
ctx = SetInstancePoolID(ctx, instance.PoolID)
ctx = SetInstanceRunnerStatus(ctx, instance.RunnerStatus)
ctx = SetInstanceTokenFetched(ctx, instance.TokenFetched)
ctx = SetInstanceHasJITConfig(ctx, instance.JitConfiguration)
ctx = SetInstanceParams(ctx, instance)
return ctx
}

View file

@ -19,6 +19,7 @@ import (
"encoding/json"
runnerErrors "github.com/cloudbase/garm-provider-common/errors"
"github.com/cloudbase/garm-provider-common/util"
"github.com/cloudbase/garm/params"
"github.com/google/uuid"
@ -28,6 +29,25 @@ import (
"gorm.io/gorm/clause"
)
func (s *sqlDatabase) marshalAndSeal(data interface{}) ([]byte, error) {
enc, err := json.Marshal(data)
if err != nil {
return nil, errors.Wrap(err, "marshalling data")
}
return util.Seal(enc, []byte(s.cfg.Passphrase))
}
func (s *sqlDatabase) unsealAndUnmarshal(data []byte, target interface{}) error {
decrypted, err := util.Unseal(data, []byte(s.cfg.Passphrase))
if err != nil {
return errors.Wrap(err, "decrypting data")
}
if err := json.Unmarshal(decrypted, target); err != nil {
return errors.Wrap(err, "unmarshalling data")
}
return nil
}
func (s *sqlDatabase) CreateInstance(ctx context.Context, poolID string, param params.CreateInstanceParams) (params.Instance, error) {
pool, err := s.getPoolByID(ctx, poolID)
if err != nil {
@ -42,6 +62,14 @@ func (s *sqlDatabase) CreateInstance(ctx context.Context, poolID string, param p
}
}
var secret []byte
if len(param.JitConfiguration) > 0 {
secret, err = s.marshalAndSeal(param.JitConfiguration)
if err != nil {
return params.Instance{}, errors.Wrap(err, "marshalling jit config")
}
}
newInstance := Instance{
Pool: pool,
Name: param.Name,
@ -52,7 +80,9 @@ func (s *sqlDatabase) CreateInstance(ctx context.Context, poolID string, param p
CallbackURL: param.CallbackURL,
MetadataURL: param.MetadataURL,
GitHubRunnerGroup: param.GitHubRunnerGroup,
JitConfiguration: secret,
AditionalLabels: labels,
AgentID: param.AgentID,
}
q := s.conn.Create(&newInstance)
if q.Error != nil {
@ -235,6 +265,14 @@ func (s *sqlDatabase) UpdateInstance(ctx context.Context, instanceID string, par
instance.TokenFetched = *param.TokenFetched
}
if param.JitConfiguration != nil {
secret, err := s.marshalAndSeal(param.JitConfiguration)
if err != nil {
return params.Instance{}, errors.Wrap(err, "marshalling jit config")
}
instance.JitConfiguration = secret
}
instance.ProviderFault = param.ProviderFault
q := s.conn.Save(&instance)

View file

@ -155,6 +155,7 @@ type Instance struct {
ProviderFault []byte `gorm:"type:longblob"`
CreateAttempt int
TokenFetched bool
JitConfiguration []byte `gorm:"type:longblob"`
GitHubRunnerGroup string
AditionalLabels datatypes.JSON

View file

@ -41,6 +41,12 @@ func (s *sqlDatabase) sqlToParamsInstance(instance Instance) (params.Instance, e
}
}
var jitConfig map[string]string
if len(instance.JitConfiguration) > 0 {
if err := s.unsealAndUnmarshal(instance.JitConfiguration, &jitConfig); err != nil {
return params.Instance{}, errors.Wrap(err, "unmarshalling jit configuration")
}
}
ret := params.Instance{
ID: instance.ID.String(),
ProviderID: id,
@ -59,6 +65,7 @@ func (s *sqlDatabase) sqlToParamsInstance(instance Instance) (params.Instance, e
CreateAttempt: instance.CreateAttempt,
UpdatedAt: instance.UpdatedAt,
TokenFetched: instance.TokenFetched,
JitConfiguration: jitConfig,
GitHubRunnerGroup: instance.GitHubRunnerGroup,
AditionalLabels: labels,
}

View file

@ -156,11 +156,12 @@ type Instance struct {
GitHubRunnerGroup string `json:"github-runner-group"`
// Do not serialize sensitive info.
CallbackURL string `json:"-"`
MetadataURL string `json:"-"`
CreateAttempt int `json:"-"`
TokenFetched bool `json:"-"`
AditionalLabels []string `json:"-"`
CallbackURL string `json:"-"`
MetadataURL string `json:"-"`
CreateAttempt int `json:"-"`
TokenFetched bool `json:"-"`
AditionalLabels []string `json:"-"`
JitConfiguration map[string]string `json:"-"`
}
func (i Instance) GetName() string {

View file

@ -136,8 +136,10 @@ type CreateInstanceParams struct {
// GithubRunnerGroup is the github runner group to which the runner belongs.
// The runner group must be created by someone with access to the enterprise.
GitHubRunnerGroup string
CreateAttempt int `json:"-"`
CreateAttempt int `json:"-"`
AgentID int64 `json:"-"`
AditionalLabels []string
JitConfiguration map[string]string
}
type CreatePoolParams struct {
@ -198,12 +200,13 @@ type UpdateInstanceParams struct {
// for this instance.
Addresses []commonParams.Address `json:"addresses,omitempty"`
// Status is the status of the instance inside the provider (eg: running, stopped, etc)
Status commonParams.InstanceStatus `json:"status,omitempty"`
RunnerStatus RunnerStatus `json:"runner_status,omitempty"`
ProviderFault []byte `json:"provider_fault,omitempty"`
AgentID int64 `json:"-"`
CreateAttempt int `json:"-"`
TokenFetched *bool `json:"-"`
Status commonParams.InstanceStatus `json:"status,omitempty"`
RunnerStatus RunnerStatus `json:"runner_status,omitempty"`
ProviderFault []byte `json:"provider_fault,omitempty"`
AgentID int64 `json:"-"`
CreateAttempt int `json:"-"`
TokenFetched *bool `json:"-"`
JitConfiguration map[string]string `json:"-"`
}
type UpdateUserParams struct {

View file

@ -206,6 +206,76 @@ func (_m *GithubClient) DeleteRepoHook(ctx context.Context, owner string, repo s
return r0, r1
}
// GenerateOrgJITConfig provides a mock function with given fields: ctx, owner, request
func (_m *GithubClient) GenerateOrgJITConfig(ctx context.Context, owner string, request *github.GenerateJITConfigRequest) (*github.JITRunnerConfig, *github.Response, error) {
ret := _m.Called(ctx, owner, request)
var r0 *github.JITRunnerConfig
var r1 *github.Response
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, string, *github.GenerateJITConfigRequest) (*github.JITRunnerConfig, *github.Response, error)); ok {
return rf(ctx, owner, request)
}
if rf, ok := ret.Get(0).(func(context.Context, string, *github.GenerateJITConfigRequest) *github.JITRunnerConfig); ok {
r0 = rf(ctx, owner, request)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*github.JITRunnerConfig)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, *github.GenerateJITConfigRequest) *github.Response); ok {
r1 = rf(ctx, owner, request)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*github.Response)
}
}
if rf, ok := ret.Get(2).(func(context.Context, string, *github.GenerateJITConfigRequest) error); ok {
r2 = rf(ctx, owner, request)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// GenerateRepoJITConfig provides a mock function with given fields: ctx, owner, repo, request
func (_m *GithubClient) GenerateRepoJITConfig(ctx context.Context, owner string, repo string, request *github.GenerateJITConfigRequest) (*github.JITRunnerConfig, *github.Response, error) {
ret := _m.Called(ctx, owner, repo, request)
var r0 *github.JITRunnerConfig
var r1 *github.Response
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.GenerateJITConfigRequest) (*github.JITRunnerConfig, *github.Response, error)); ok {
return rf(ctx, owner, repo, request)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.GenerateJITConfigRequest) *github.JITRunnerConfig); ok {
r0 = rf(ctx, owner, repo, request)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*github.JITRunnerConfig)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, *github.GenerateJITConfigRequest) *github.Response); ok {
r1 = rf(ctx, owner, repo, request)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*github.Response)
}
}
if rf, ok := ret.Get(2).(func(context.Context, string, string, *github.GenerateJITConfigRequest) error); ok {
r2 = rf(ctx, owner, repo, request)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// GetOrgHook provides a mock function with given fields: ctx, org, id
func (_m *GithubClient) GetOrgHook(ctx context.Context, org string, id int64) (*github.Hook, *github.Response, error) {
ret := _m.Called(ctx, org, id)
@ -381,6 +451,41 @@ func (_m *GithubClient) ListOrganizationRunnerApplicationDownloads(ctx context.C
return r0, r1, r2
}
// ListOrganizationRunnerGroups provides a mock function with given fields: ctx, org, opts
func (_m *GithubClient) ListOrganizationRunnerGroups(ctx context.Context, org string, opts *github.ListOrgRunnerGroupOptions) (*github.RunnerGroups, *github.Response, error) {
ret := _m.Called(ctx, org, opts)
var r0 *github.RunnerGroups
var r1 *github.Response
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, string, *github.ListOrgRunnerGroupOptions) (*github.RunnerGroups, *github.Response, error)); ok {
return rf(ctx, org, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, string, *github.ListOrgRunnerGroupOptions) *github.RunnerGroups); ok {
r0 = rf(ctx, org, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*github.RunnerGroups)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, *github.ListOrgRunnerGroupOptions) *github.Response); ok {
r1 = rf(ctx, org, opts)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*github.Response)
}
}
if rf, ok := ret.Get(2).(func(context.Context, string, *github.ListOrgRunnerGroupOptions) error); ok {
r2 = rf(ctx, org, opts)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// ListOrganizationRunners provides a mock function with given fields: ctx, owner, opts
func (_m *GithubClient) ListOrganizationRunners(ctx context.Context, owner string, opts *github.ListOptions) (*github.Runners, *github.Response, error) {
ret := _m.Called(ctx, owner, opts)

View file

@ -49,6 +49,41 @@ func (_m *GithubEnterpriseClient) CreateRegistrationToken(ctx context.Context, e
return r0, r1, r2
}
// GenerateEnterpriseJITConfig provides a mock function with given fields: ctx, enterprise, request
func (_m *GithubEnterpriseClient) GenerateEnterpriseJITConfig(ctx context.Context, enterprise string, request *github.GenerateJITConfigRequest) (*github.JITRunnerConfig, *github.Response, error) {
ret := _m.Called(ctx, enterprise, request)
var r0 *github.JITRunnerConfig
var r1 *github.Response
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, string, *github.GenerateJITConfigRequest) (*github.JITRunnerConfig, *github.Response, error)); ok {
return rf(ctx, enterprise, request)
}
if rf, ok := ret.Get(0).(func(context.Context, string, *github.GenerateJITConfigRequest) *github.JITRunnerConfig); ok {
r0 = rf(ctx, enterprise, request)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*github.JITRunnerConfig)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, *github.GenerateJITConfigRequest) *github.Response); ok {
r1 = rf(ctx, enterprise, request)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*github.Response)
}
}
if rf, ok := ret.Get(2).(func(context.Context, string, *github.GenerateJITConfigRequest) error); ok {
r2 = rf(ctx, enterprise, request)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// ListRunnerApplicationDownloads provides a mock function with given fields: ctx, enterprise
func (_m *GithubEnterpriseClient) ListRunnerApplicationDownloads(ctx context.Context, enterprise string) ([]*github.RunnerApplicationDownload, *github.Response, error) {
ret := _m.Called(ctx, enterprise)
@ -84,6 +119,41 @@ func (_m *GithubEnterpriseClient) ListRunnerApplicationDownloads(ctx context.Con
return r0, r1, r2
}
// ListRunnerGroups provides a mock function with given fields: ctx, enterprise, opts
func (_m *GithubEnterpriseClient) ListRunnerGroups(ctx context.Context, enterprise string, opts *github.ListEnterpriseRunnerGroupOptions) (*github.EnterpriseRunnerGroups, *github.Response, error) {
ret := _m.Called(ctx, enterprise, opts)
var r0 *github.EnterpriseRunnerGroups
var r1 *github.Response
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, string, *github.ListEnterpriseRunnerGroupOptions) (*github.EnterpriseRunnerGroups, *github.Response, error)); ok {
return rf(ctx, enterprise, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, string, *github.ListEnterpriseRunnerGroupOptions) *github.EnterpriseRunnerGroups); ok {
r0 = rf(ctx, enterprise, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*github.EnterpriseRunnerGroups)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, *github.ListEnterpriseRunnerGroupOptions) *github.Response); ok {
r1 = rf(ctx, enterprise, opts)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*github.Response)
}
}
if rf, ok := ret.Get(2).(func(context.Context, string, *github.ListEnterpriseRunnerGroupOptions) error); ok {
r2 = rf(ctx, enterprise, opts)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// ListRunners provides a mock function with given fields: ctx, enterprise, opts
func (_m *GithubEnterpriseClient) ListRunners(ctx context.Context, enterprise string, opts *github.ListOptions) (*github.Runners, *github.Response, error) {
ret := _m.Called(ctx, enterprise, opts)

View file

@ -41,6 +41,8 @@ type GithubClient interface {
RemoveRunner(ctx context.Context, owner, repo string, runnerID int64) (*github.Response, error)
// CreateRegistrationToken creates a runner registration token for one repository.
CreateRegistrationToken(ctx context.Context, owner, repo string) (*github.RegistrationToken, *github.Response, error)
// GenerateRepoJITConfig generates a just-in-time configuration for a repository.
GenerateRepoJITConfig(ctx context.Context, owner, repo string, request *github.GenerateJITConfigRequest) (*github.JITRunnerConfig, *github.Response, error)
// ListOrganizationRunners lists all runners within an organization.
ListOrganizationRunners(ctx context.Context, owner string, opts *github.ListOptions) (*github.Runners, *github.Response, error)
@ -51,6 +53,10 @@ type GithubClient interface {
RemoveOrganizationRunner(ctx context.Context, owner string, runnerID int64) (*github.Response, error)
// CreateOrganizationRegistrationToken creates a runner registration token for an organization.
CreateOrganizationRegistrationToken(ctx context.Context, owner string) (*github.RegistrationToken, *github.Response, error)
// GenerateOrgJITConfig generate a just-in-time configuration for an organization.
GenerateOrgJITConfig(ctx context.Context, owner string, request *github.GenerateJITConfigRequest) (*github.JITRunnerConfig, *github.Response, error)
// ListOrganizationRunnerGroups lists all runner groups within an organization.
ListOrganizationRunnerGroups(ctx context.Context, org string, opts *github.ListOrgRunnerGroupOptions) (*github.RunnerGroups, *github.Response, error)
}
type GithubEnterpriseClient interface {
@ -63,4 +69,8 @@ type GithubEnterpriseClient interface {
// ListRunnerApplicationDownloads returns a list of github runner application downloads for the
// various supported operating systems and architectures.
ListRunnerApplicationDownloads(ctx context.Context, enterprise string) ([]*github.RunnerApplicationDownload, *github.Response, error)
// GenerateEnterpriseJITConfig generate a just-in-time configuration for an enterprise.
GenerateEnterpriseJITConfig(ctx context.Context, enterprise string, request *github.GenerateJITConfigRequest) (*github.JITRunnerConfig, *github.Response, error)
// ListRunnerGroups lists all self-hosted runner groups configured in an enterprise.
ListRunnerGroups(ctx context.Context, enterprise string, opts *github.ListEnterpriseRunnerGroupOptions) (*github.EnterpriseRunnerGroups, *github.Response, error)
}

View file

@ -1,15 +1,177 @@
package runner
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"html/template"
"log"
"strings"
"github.com/cloudbase/garm-provider-common/defaults"
runnerErrors "github.com/cloudbase/garm-provider-common/errors"
"github.com/cloudbase/garm/auth"
"github.com/cloudbase/garm/params"
"github.com/pkg/errors"
)
var systemdUnitTemplate = `[Unit]
Description=GitHub Actions Runner ({{.ServiceName}})
After=network.target
[Service]
ExecStart=/home/{{.RunAsUser}}/actions-runner/runsvc.sh
User=runner
WorkingDirectory=/home/{{.RunAsUser}}/actions-runner
KillMode=process
KillSignal=SIGTERM
TimeoutStopSec=5min
[Install]
WantedBy=multi-user.target
`
func validateInstanceState(ctx context.Context) (params.Instance, error) {
if !auth.InstanceHasJITConfig(ctx) {
return params.Instance{}, fmt.Errorf("instance not configured for JIT: %w", runnerErrors.ErrNotFound)
}
status := auth.InstanceRunnerStatus(ctx)
if status != params.RunnerPending && status != params.RunnerInstalling {
return params.Instance{}, runnerErrors.ErrUnauthorized
}
instance, err := auth.InstanceParams(ctx)
if err != nil {
log.Printf("failed to get instance params: %s", err)
return params.Instance{}, runnerErrors.ErrUnauthorized
}
return instance, nil
}
func (r *Runner) GetRunnerServiceName(ctx context.Context) (string, error) {
instance, err := validateInstanceState(ctx)
if err != nil {
log.Printf("failed to get instance params: %s", err)
return "", runnerErrors.ErrUnauthorized
}
pool, err := r.store.GetPoolByID(r.ctx, instance.PoolID)
if err != nil {
log.Printf("failed to get pool: %s", err)
return "", errors.Wrap(err, "fetching pool")
}
tpl := "actions.runner.%s.%s"
var serviceName string
switch pool.PoolType() {
case params.EnterprisePool:
serviceName = fmt.Sprintf(tpl, pool.EnterpriseName, instance.Name)
case params.OrganizationPool:
serviceName = fmt.Sprintf(tpl, pool.OrgName, instance.Name)
case params.RepositoryPool:
serviceName = fmt.Sprintf(tpl, strings.Replace(pool.RepoName, "/", "-", -1), instance.Name)
}
return serviceName, nil
}
func (r *Runner) GenerateSystemdUnitFile(ctx context.Context, runAsUser string) ([]byte, error) {
serviceName, err := r.GetRunnerServiceName(ctx)
if err != nil {
return nil, errors.Wrap(err, "fetching runner service name")
}
unitTemplate, err := template.New("").Parse(systemdUnitTemplate)
if err != nil {
return nil, errors.Wrap(err, "parsing template")
}
if runAsUser == "" {
runAsUser = defaults.DefaultUser
}
data := struct {
ServiceName string
RunAsUser string
}{
ServiceName: serviceName,
RunAsUser: runAsUser,
}
var unitFile bytes.Buffer
if err := unitTemplate.Execute(&unitFile, data); err != nil {
return nil, errors.Wrap(err, "executing template")
}
return unitFile.Bytes(), nil
}
func (r *Runner) GetJITConfigFile(ctx context.Context, file string) ([]byte, error) {
instance, err := validateInstanceState(ctx)
if err != nil {
log.Printf("failed to get instance params: %s", err)
return nil, runnerErrors.ErrUnauthorized
}
jitConfig := instance.JitConfiguration
contents, ok := jitConfig[file]
if !ok {
return nil, errors.Wrap(runnerErrors.ErrNotFound, "retrieving file")
}
decoded, err := base64.StdEncoding.DecodeString(contents)
if err != nil {
return nil, errors.Wrap(err, "decoding file contents")
}
return decoded, nil
}
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,
// 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) {
return "", runnerErrors.ErrUnauthorized
}
status := auth.InstanceRunnerStatus(ctx)
if status != params.RunnerPending && status != params.RunnerInstalling {
return "", runnerErrors.ErrUnauthorized
}
instance, err := auth.InstanceParams(ctx)
if err != nil {
log.Printf("failed to get instance params: %s", err)
return "", runnerErrors.ErrUnauthorized
}
poolMgr, err := r.getPoolManagerFromInstance(ctx, instance)
if err != nil {
return "", errors.Wrap(err, "fetching pool manager for instance")
}
token, err := poolMgr.GithubRunnerRegistrationToken()
if err != nil {
return "", errors.Wrap(err, "fetching runner token")
}
tokenFetched := true
updateParams := params.UpdateInstanceParams{
TokenFetched: &tokenFetched,
}
if _, err := r.store.UpdateInstance(r.ctx, instance.ID, updateParams); err != nil {
return "", errors.Wrap(err, "setting token_fetched for instance")
}
if err := r.store.AddInstanceEvent(ctx, instance.ID, params.FetchTokenEvent, params.EventInfo, "runner registration token was retrieved"); err != nil {
return "", errors.Wrap(err, "recording event")
}
return token, nil
}
func (r *Runner) GetRootCertificateBundle(ctx context.Context) (params.CertificateBundle, error) {
instance, err := auth.InstanceParams(ctx)
if err != nil {

View file

@ -2,7 +2,10 @@ package pool
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
@ -44,6 +47,12 @@ func NewEnterprisePoolManager(ctx context.Context, cfg params.Enterprise, cfgInt
store: store,
providers: providers,
controllerID: cfgInternal.ControllerID,
urls: urls{
webhookURL: cfgInternal.BaseWebhookURL,
callbackURL: cfgInternal.InstanceCallbackURL,
metadataURL: cfgInternal.InstanceMetadataURL,
controllerWebhookURL: cfgInternal.ControllerWebhookURL,
},
quit: make(chan struct{}),
helper: helper,
credsDetails: cfgInternal.GithubCredentialsDetails,
@ -65,6 +74,82 @@ type enterprise struct {
mux sync.Mutex
}
func (r *enterprise) findRunnerGroupByName(ctx context.Context, name string) (*github.EnterpriseRunnerGroup, error) {
// TODO(gabriel-samfira): implement caching
opts := github.ListEnterpriseRunnerGroupOptions{
ListOptions: github.ListOptions{
PerPage: 100,
},
}
for {
runnerGroups, ghResp, err := r.ghcEnterpriseCli.ListRunnerGroups(r.ctx, r.cfg.Name, &opts)
if err != nil {
if ghResp != nil && ghResp.StatusCode == http.StatusUnauthorized {
return nil, errors.Wrap(runnerErrors.ErrUnauthorized, "fetching runners")
}
return nil, errors.Wrap(err, "fetching runners")
}
for _, runnerGroup := range runnerGroups.RunnerGroups {
if runnerGroup.Name != nil && *runnerGroup.Name == name {
return runnerGroup, nil
}
}
if ghResp.NextPage == 0 {
break
}
opts.Page = ghResp.NextPage
}
return nil, errors.Wrap(runnerErrors.ErrNotFound, "runner group not found")
}
func (r *enterprise) GetJITConfig(ctx context.Context, instance string, pool params.Pool, labels []string) (jitConfigMap map[string]string, runner *github.Runner, err error) {
var rg int64 = 1
if pool.GitHubRunnerGroup != "" {
runnerGroup, err := r.findRunnerGroupByName(ctx, pool.GitHubRunnerGroup)
if err != nil {
return nil, nil, fmt.Errorf("failed to find runner group: %w", err)
}
rg = *runnerGroup.ID
}
req := github.GenerateJITConfigRequest{
Name: instance,
RunnerGroupID: rg,
Labels: labels,
// TODO(gabriel-samfira): Should we make this configurable?
WorkFolder: github.String("_work"),
}
jitConfig, resp, err := r.ghcEnterpriseCli.GenerateEnterpriseJITConfig(ctx, r.cfg.Name, &req)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
return nil, nil, fmt.Errorf("failed to get JIT config: %w", err)
}
return nil, nil, fmt.Errorf("failed to get JIT config: %w", err)
}
runner = jitConfig.Runner
defer func() {
if err != nil && runner != nil {
_, innerErr := r.ghcEnterpriseCli.RemoveRunner(r.ctx, r.cfg.Name, runner.GetID())
log.Printf("failed to remove runner: %v", innerErr)
}
}()
decoded, err := base64.StdEncoding.DecodeString(*jitConfig.EncodedJITConfig)
if err != nil {
return nil, nil, fmt.Errorf("failed to decode JIT config: %w", err)
}
var ret map[string]string
if err := json.Unmarshal(decoded, &ret); err != nil {
return nil, nil, fmt.Errorf("failed to unmarshal JIT config: %w", err)
}
return ret, jitConfig.Runner, nil
}
func (r *enterprise) GithubCLI() common.GithubClient {
return r.ghcli
}

View file

@ -37,6 +37,8 @@ type poolHelper interface {
GithubCLI() common.GithubClient
GetJITConfig(ctx context.Context, instanceName string, pool params.Pool, labels []string) (map[string]string, *github.Runner, error)
FetchDbInstances() ([]params.Instance, error)
ListPools() ([]params.Pool, error)
GithubURL() string

View file

@ -16,6 +16,8 @@ package pool
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
@ -84,6 +86,82 @@ type organization struct {
mux sync.Mutex
}
func (r *organization) findRunnerGroupByName(ctx context.Context, name string) (*github.RunnerGroup, error) {
// TODO(gabriel-samfira): implement caching
opts := github.ListOrgRunnerGroupOptions{
ListOptions: github.ListOptions{
PerPage: 100,
},
}
for {
runnerGroups, ghResp, err := r.ghcli.ListOrganizationRunnerGroups(r.ctx, r.cfg.Name, &opts)
if err != nil {
if ghResp != nil && ghResp.StatusCode == http.StatusUnauthorized {
return nil, errors.Wrap(runnerErrors.ErrUnauthorized, "fetching runners")
}
return nil, errors.Wrap(err, "fetching runners")
}
for _, runnerGroup := range runnerGroups.RunnerGroups {
if runnerGroup.GetName() == name {
return runnerGroup, nil
}
}
if ghResp.NextPage == 0 {
break
}
opts.Page = ghResp.NextPage
}
return nil, errors.Wrap(runnerErrors.ErrNotFound, "runner group not found")
}
func (r *organization) GetJITConfig(ctx context.Context, instance string, pool params.Pool, labels []string) (jitConfigMap map[string]string, runner *github.Runner, err error) {
var rg int64 = 1
if pool.GitHubRunnerGroup != "" {
runnerGroup, err := r.findRunnerGroupByName(ctx, pool.GitHubRunnerGroup)
if err != nil {
return nil, nil, fmt.Errorf("failed to find runner group: %w", err)
}
rg = runnerGroup.GetID()
}
req := github.GenerateJITConfigRequest{
Name: instance,
RunnerGroupID: rg,
Labels: labels,
// TODO(gabriel-samfira): Should we make this configurable?
WorkFolder: github.String("_work"),
}
jitConfig, resp, err := r.ghcli.GenerateOrgJITConfig(ctx, r.cfg.Name, &req)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
return nil, nil, fmt.Errorf("failed to get JIT config: %w", err)
}
return nil, nil, fmt.Errorf("failed to get JIT config: %w", err)
}
runner = jitConfig.GetRunner()
defer func() {
if err != nil && runner != nil {
_, innerErr := r.ghcli.RemoveOrganizationRunner(r.ctx, r.cfg.Name, runner.GetID())
log.Printf("failed to remove runner: %v", innerErr)
}
}()
decoded, err := base64.StdEncoding.DecodeString(jitConfig.GetEncodedJITConfig())
if err != nil {
return nil, nil, fmt.Errorf("failed to decode JIT config: %w", err)
}
var ret map[string]string
if err := json.Unmarshal(decoded, &ret); err != nil {
return nil, nil, fmt.Errorf("failed to unmarshal JIT config: %w", err)
}
return ret, runner, nil
}
func (r *organization) GithubCLI() common.GithubClient {
return r.ghcli
}

View file

@ -388,11 +388,18 @@ func (r *basePoolManager) cleanupOrphanedProviderRunners(runners []*github.Runne
continue
}
pool, err := r.store.GetPoolByID(r.ctx, instance.PoolID)
if err != nil {
return errors.Wrap(err, "fetching instance pool info")
}
switch instance.RunnerStatus {
case params.RunnerPending, params.RunnerInstalling:
// runner is still installing. We give it a chance to finish.
r.log("runner %s is still installing, give it a chance to finish", instance.Name)
continue
if time.Since(instance.UpdatedAt).Minutes() < float64(pool.RunnerTimeout()) {
// runner is still installing. We give it a chance to finish.
r.log("runner %s is still installing, give it a chance to finish", instance.Name)
continue
}
}
if time.Since(instance.UpdatedAt).Minutes() < 5 {
@ -451,20 +458,7 @@ func (r *basePoolManager) reapTimedOutRunners(runners []*github.Runner) error {
// * The runner never joined github within the pool timeout
// * The runner managed to join github, but the setup process failed later and the runner
// never started on the instance.
//
// There are several steps in the user data that sets up the runner:
// * Download and unarchive the runner from github (or used the cached version)
// * Configure runner (connects to github). At this point the runner is seen as offline.
// * Install the service
// * Set SELinux context (if SELinux is enabled)
// * Start the service (if successful, the runner will transition to "online")
// * Get the runner ID
//
// If we fail getting the runner ID after it's started, garm will set the runner status to "failed",
// even though, technically the runner is online and fully functional. This is why we check here for
// both the runner status as reported by GitHub and the runner status as reported by the provider.
// If the runner is "offline" and marked as "failed", it should be safe to reap it.
if runner, ok := runnersByName[instance.Name]; !ok || (runner.GetStatus() == "offline" && instance.RunnerStatus == params.RunnerFailed) {
if runner, ok := runnersByName[instance.Name]; !ok || runner.GetStatus() == "offline" {
r.log("reaping timed-out/failed runner %s", instance.Name)
if err := r.ForceDeleteRunner(instance); err != nil {
r.log("failed to update runner %s status: %s", instance.Name, err)
@ -527,6 +521,18 @@ func (r *basePoolManager) cleanupOrphanedGithubRunners(runners []*github.Runner)
// already marked for deletion or is in the process of being deleted.
// Let consolidate take care of it.
continue
case commonParams.InstancePendingCreate, commonParams.InstanceCreating:
// instance is still being created. We give it a chance to finish.
r.log("instance %s is still being created, give it a chance to finish", dbInstance.Name)
continue
case commonParams.InstanceRunning:
// this check is not strictly needed, but can help avoid unnecessary strain on the provider.
// At worst, we will have a runner that is offline in github for 5 minutes before we reap it.
if time.Since(dbInstance.UpdatedAt).Minutes() < 5 {
// instance was updated recently. We give it a chance to register itself in github.
r.log("instance %s was updated recently, skipping check", dbInstance.Name)
continue
}
}
pool, err := r.helper.GetPoolByID(dbInstance.PoolID)
@ -680,13 +686,19 @@ func (r *basePoolManager) setInstanceStatus(runnerName string, status commonPara
return instance, nil
}
func (r *basePoolManager) AddRunner(ctx context.Context, poolID string, aditionalLabels []string) error {
func (r *basePoolManager) AddRunner(ctx context.Context, poolID string, aditionalLabels []string) (err error) {
pool, err := r.helper.GetPoolByID(poolID)
if err != nil {
return errors.Wrap(err, "fetching pool")
}
name := fmt.Sprintf("%s-%s", pool.GetRunnerPrefix(), util.NewID())
labels := r.getLabelsForInstance(pool)
// Attempt to create JIT config
jitConfig, runner, err := r.helper.GetJITConfig(ctx, name, pool, labels)
if err != nil {
r.log("failed to get JIT config, falling back to registration token: %s", err)
}
createParams := params.CreateInstanceParams{
Name: name,
@ -699,13 +711,35 @@ func (r *basePoolManager) AddRunner(ctx context.Context, poolID string, aditiona
CreateAttempt: 1,
GitHubRunnerGroup: pool.GitHubRunnerGroup,
AditionalLabels: aditionalLabels,
JitConfiguration: jitConfig,
}
_, err = r.store.CreateInstance(r.ctx, poolID, createParams)
if runner != nil {
createParams.AgentID = runner.GetID()
}
instance, err := r.store.CreateInstance(r.ctx, poolID, createParams)
if err != nil {
return errors.Wrap(err, "creating instance")
}
defer func() {
if err != nil {
if instance.ID != "" {
if err := r.ForceDeleteRunner(instance); err != nil {
r.log("failed to cleanup instance: %s", instance.Name)
}
}
if runner != nil {
_, runnerCleanupErr := r.helper.RemoveGithubRunner(runner.GetID())
if err != nil {
r.log("failed to remove runner %d: %s", runner.GetID(), runnerCleanupErr)
}
}
}
}()
return nil
}
@ -734,6 +768,16 @@ func (r *basePoolManager) setPoolRunningState(isRunning bool, failureReason stri
r.mux.Unlock()
}
func (r *basePoolManager) getLabelsForInstance(pool params.Pool) []string {
labels := []string{}
for _, tag := range pool.Tags {
labels = append(labels, tag.Name)
}
labels = append(labels, r.controllerLabel())
labels = append(labels, r.poolLabel(pool.ID))
return labels
}
func (r *basePoolManager) addInstanceToProvider(instance params.Instance) error {
pool, err := r.helper.GetPoolByID(instance.PoolID)
if err != nil {
@ -745,13 +789,6 @@ func (r *basePoolManager) addInstanceToProvider(instance params.Instance) error
return fmt.Errorf("unknown provider %s for pool %s", pool.ProviderName, pool.ID)
}
labels := []string{}
for _, tag := range pool.Tags {
labels = append(labels, tag.Name)
}
labels = append(labels, r.controllerLabel())
labels = append(labels, r.poolLabel(pool.ID))
jwtValidity := pool.RunnerTimeout()
entity := r.helper.String()
@ -760,6 +797,8 @@ func (r *basePoolManager) addInstanceToProvider(instance params.Instance) error
return errors.Wrap(err, "fetching instance jwt token")
}
hasJITConfig := len(instance.JitConfiguration) > 0
bootstrapArgs := commonParams.BootstrapInstance{
Name: instance.Name,
Tools: r.tools,
@ -772,10 +811,17 @@ func (r *basePoolManager) addInstanceToProvider(instance params.Instance) error
Flavor: pool.Flavor,
Image: pool.Image,
ExtraSpecs: pool.ExtraSpecs,
Labels: labels,
PoolID: instance.PoolID,
CACertBundle: r.credsDetails.CABundle,
GitHubRunnerGroup: instance.GitHubRunnerGroup,
JitConfigEnabled: hasJITConfig,
}
if !hasJITConfig {
// We still need the labels here for situations where we don't have a JIT config generated.
// This can happen if GARM is used against an instance of GHES older than version 3.10.
// The labels field should be ignored by providers if JIT config is enabled.
bootstrapArgs.Labels = r.getLabelsForInstance(pool)
}
var instanceIDToDelete string
@ -1110,11 +1156,12 @@ func (r *basePoolManager) retryFailedInstancesForOnePool(ctx context.Context, po
// TODO(gabriel-samfira): Incrementing CreateAttempt should be done within a transaction.
// It's fairly safe to do here (for now), as there should be no other code path that updates
// an instance in this state.
var tokenFetched bool = false
var tokenFetched bool = len(instance.JitConfiguration) > 0
updateParams := params.UpdateInstanceParams{
CreateAttempt: instance.CreateAttempt + 1,
TokenFetched: &tokenFetched,
Status: commonParams.InstancePendingCreate,
RunnerStatus: params.RunnerPending,
}
r.log("queueing previously failed instance %s for retry", instance.Name)
// Set instance to pending create and wait for retry.

View file

@ -16,6 +16,8 @@ package pool
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
@ -86,6 +88,44 @@ type repository struct {
mux sync.Mutex
}
func (r *repository) GetJITConfig(ctx context.Context, instance string, pool params.Pool, labels []string) (jitConfigMap map[string]string, runner *github.Runner, err error) {
req := github.GenerateJITConfigRequest{
Name: instance,
// At the repository level we only have the default runner group.
RunnerGroupID: 1,
Labels: labels,
// TODO(gabriel-samfira): Should we make this configurable?
WorkFolder: github.String("_work"),
}
jitConfig, resp, err := r.ghcli.GenerateRepoJITConfig(ctx, r.cfg.Owner, r.cfg.Name, &req)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
return nil, nil, fmt.Errorf("failed to get JIT config: %w", err)
}
return nil, nil, fmt.Errorf("failed to get JIT config: %w", err)
}
runner = jitConfig.Runner
defer func() {
if err != nil && runner != nil {
_, innerErr := r.ghcli.RemoveRunner(r.ctx, r.cfg.Owner, r.cfg.Name, runner.GetID())
log.Printf("failed to remove runner: %v", innerErr)
}
}()
decoded, err := base64.StdEncoding.DecodeString(jitConfig.GetEncodedJITConfig())
if err != nil {
return nil, nil, fmt.Errorf("failed to decode JIT config: %w", err)
}
var ret map[string]string
if err := json.Unmarshal(decoded, &ret); err != nil {
return nil, nil, fmt.Errorf("failed to unmarshal JIT config: %w", err)
}
return ret, runner, nil
}
func (r *repository) GithubCLI() common.GithubClient {
return r.ghcli
}

View file

@ -858,55 +858,6 @@ func (r *Runner) AddInstanceStatusMessage(ctx context.Context, param params.Inst
return nil
}
func (r *Runner) GetInstanceGithubRegistrationToken(ctx context.Context) (string, error) {
instanceName := auth.InstanceName(ctx)
if instanceName == "" {
return "", runnerErrors.ErrUnauthorized
}
// Check if this instance already fetched a registration token. 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) {
return "", runnerErrors.ErrUnauthorized
}
status := auth.InstanceRunnerStatus(ctx)
if status != params.RunnerPending && status != params.RunnerInstalling {
return "", runnerErrors.ErrUnauthorized
}
instance, err := r.store.GetInstanceByName(ctx, instanceName)
if err != nil {
return "", errors.Wrap(err, "fetching instance")
}
poolMgr, err := r.getPoolManagerFromInstance(ctx, instance)
if err != nil {
return "", errors.Wrap(err, "fetching pool manager for instance")
}
token, err := poolMgr.GithubRunnerRegistrationToken()
if err != nil {
return "", errors.Wrap(err, "fetching runner token")
}
tokenFetched := true
updateParams := params.UpdateInstanceParams{
TokenFetched: &tokenFetched,
}
if _, err := r.store.UpdateInstance(r.ctx, instance.ID, updateParams); err != nil {
return "", errors.Wrap(err, "setting token_fetched for instance")
}
if err := r.store.AddInstanceEvent(ctx, instance.ID, params.FetchTokenEvent, params.EventInfo, "runner registration token was retrieved"); err != nil {
return "", errors.Wrap(err, "recording event")
}
return token, nil
}
func (r *Runner) getPoolManagerFromInstance(ctx context.Context, instance params.Instance) (common.PoolManager, error) {
pool, err := r.store.GetPoolByID(ctx, instance.PoolID)
if err != nil {