diff --git a/cmd/loadtest/loadtest.go b/cmd/loadtest/loadtest.go new file mode 100644 index 0000000..38ce036 --- /dev/null +++ b/cmd/loadtest/loadtest.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/eurofurence/reg-attendee-service/internal/web/app" + "os" +) + +func main() { + os.Exit(app.New().Loadtest()) +} diff --git a/docs/config-template.yaml b/docs/config-template.yaml index 71d82a6..60ca8a7 100644 --- a/docs/config-template.yaml +++ b/docs/config-template.yaml @@ -52,6 +52,8 @@ security: allow_origin: 'http://localhost:8000' # set this to true to require a valid oidc token for initial reg. This allow the service to store the subject of the token and use it for authorization require_login_for_reg: true + # set this to true temporarily to use the load testing command with constant tokens. Never use in production. + # anonymize_identity: true logging: severity: INFO style: plain # or ecs (elastic common schema), the default diff --git a/internal/repository/config/config.go b/internal/repository/config/config.go index b1f991c..0b19255 100644 --- a/internal/repository/config/config.go +++ b/internal/repository/config/config.go @@ -15,10 +15,30 @@ func UseEcsLogging() bool { return ecsLogging } -func GenerateCount() uint { +func GeneratorGenerateCount() uint { return generateCount } +func GeneratorParallel() uint { + return parallel +} + +func GeneratorBaseUrl() string { + return baseUrl +} + +func GeneratorCookieDomain() string { + return cookieDomain +} + +func GeneratorIdToken() string { + return idToken +} + +func GeneratorAccessToken() string { + return accessToken +} + func ServerAddr() string { c := Configuration() return fmt.Sprintf("%s:%s", c.Server.Address, c.Server.Port) @@ -269,6 +289,10 @@ func RequireLoginForReg() bool { return Configuration().Security.RequireLogin } +func AnonymizeIdentity() bool { + return Configuration().Security.AnonymizeIdentity +} + func PaymentServiceBaseUrl() string { return Configuration().Service.PaymentService } diff --git a/internal/repository/config/loading.go b/internal/repository/config/loading.go index 29447bf..6c5d611 100644 --- a/internal/repository/config/loading.go +++ b/internal/repository/config/loading.go @@ -19,7 +19,13 @@ var ( configurationFilename string dbMigrate bool ecsLogging bool - generateCount uint + + generateCount uint + parallel uint + baseUrl string + cookieDomain string + idToken string + accessToken string parsedKeySet []*rsa.PublicKey ) @@ -36,7 +42,15 @@ func init() { flag.StringVar(&configurationFilename, "config", "", "config file path") flag.BoolVar(&dbMigrate, "migrate-database", false, "migrate database on startup") flag.BoolVar(&ecsLogging, "ecs-json-logging", false, "switch to structured json logging") - flag.UintVar(&generateCount, "generate-count", 0, "number of fake registrations to generate (separate generator binary only)") +} + +func AdditionalGeneratorCommandLineFlags() { + flag.UintVar(&generateCount, "generate-count", 0, "total number of fake registrations to generate (separate generator/loadtest binaries only)") + flag.UintVar(¶llel, "parallel", 0, "number of parallel goroutines to use (separate generator/loadtest binaries only)") + flag.StringVar(&baseUrl, "base-url", "", "base url of target attendee service (separate generator/loadtest binaries only)") + flag.StringVar(&cookieDomain, "cookie-domain", "", "domain for cookies (separate generator/loadtest binaries only)") + flag.StringVar(&idToken, "id-token", "", "id token to use (separate generator/loadtest binaries only)") + flag.StringVar(&accessToken, "access-token", "", "access token to use (separate generator/loadtest binaries only)") } // ParseCommandLineFlags is exposed separately so you can skip it for tests diff --git a/internal/repository/config/structure.go b/internal/repository/config/structure.go index fccf9c0..12f7300 100644 --- a/internal/repository/config/structure.go +++ b/internal/repository/config/structure.go @@ -71,10 +71,11 @@ type ( // SecurityConfig configures everything related to security SecurityConfig struct { - Fixed FixedTokenConfig `yaml:"fixed_token"` - Oidc OpenIdConnectConfig `yaml:"oidc"` - Cors CorsConfig `yaml:"cors"` - RequireLogin bool `yaml:"require_login_for_reg"` + Fixed FixedTokenConfig `yaml:"fixed_token"` + Oidc OpenIdConnectConfig `yaml:"oidc"` + Cors CorsConfig `yaml:"cors"` + RequireLogin bool `yaml:"require_login_for_reg"` + AnonymizeIdentity bool `yaml:"anonymize_identity"` } FixedTokenConfig struct { diff --git a/internal/repository/selfclient/client.go b/internal/repository/selfclient/client.go new file mode 100644 index 0000000..a697910 --- /dev/null +++ b/internal/repository/selfclient/client.go @@ -0,0 +1,103 @@ +// Package selfclient provides a very simple self API client. +package selfclient + +import ( + "context" + "errors" + "fmt" + aurestclientapi "github.com/StephanHCB/go-autumn-restclient/api" + auresthttpclient "github.com/StephanHCB/go-autumn-restclient/implementation/httpclient" + aurestlogging "github.com/StephanHCB/go-autumn-restclient/implementation/requestlogging" + "github.com/eurofurence/reg-attendee-service/internal/api/v1/attendee" + "github.com/eurofurence/reg-attendee-service/internal/repository/config" + "github.com/go-http-utils/headers" + "net/http" + "strings" + "time" +) + +var ( + client aurestclientapi.Client + baseUrl string + idToken string + accessToken string + cookieDomain string +) + +var ( + UnauthorizedError = errors.New("got unauthorized from userinfo endpoint") + DownstreamError = errors.New("downstream unavailable - see log for details") +) + +func requestManipulator(ctx context.Context, r *http.Request) { + r.AddCookie(&http.Cookie{ + Name: config.OidcIdTokenCookieName(), + Value: idToken, + Domain: cookieDomain, + Expires: time.Now().Add(10 * time.Minute), + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + r.AddCookie(&http.Cookie{ + Name: config.OidcAccessTokenCookieName(), + Value: accessToken, + Domain: cookieDomain, + Expires: time.Now().Add(10 * time.Minute), + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func Setup() error { + httpClient, err := auresthttpclient.New(0, nil, requestManipulator) + if err != nil { + return err + } + + requestLoggingClient := aurestlogging.New(httpClient) + + client = requestLoggingClient + + baseUrl = config.GeneratorBaseUrl() + idToken = config.GeneratorIdToken() + accessToken = config.GeneratorAccessToken() + cookieDomain = config.GeneratorCookieDomain() + + if baseUrl == "" || idToken == "" || accessToken == "" || cookieDomain == "" { + return errors.New("missing parameters for setting up selfclient - cannot run generator") + } + + return nil +} + +func errByStatus(err error, status int) error { + if err != nil { + return err + } + if status == http.StatusUnauthorized { + return UnauthorizedError + } + if status >= 300 { + return DownstreamError + } + return nil +} + +func SendRegistration(ctx context.Context, dto *attendee.AttendeeDto) (string, error) { + url := fmt.Sprintf("%s/api/rest/v1/attendees", baseUrl) + response := aurestclientapi.ParsedResponse{} + err := client.Perform(ctx, http.MethodPost, url, dto, &response) + + // parse location header + loc := "" + if val, ok := response.Header[headers.Location]; ok { + loc = val[0] + } + id := strings.TrimPrefix(loc, url+"/") + + return id, errByStatus(err, response.Status) +} diff --git a/internal/service/attendeesrv/attendeesrv.go b/internal/service/attendeesrv/attendeesrv.go index 3c4e28d..d2bfa02 100644 --- a/internal/service/attendeesrv/attendeesrv.go +++ b/internal/service/attendeesrv/attendeesrv.go @@ -29,8 +29,13 @@ func (s *AttendeeServiceImplData) RegisterNewAttendee(ctx context.Context, atten return 0, errors.New("duplicate attendee data - you are already registered") } - // record which user owns this attendee - attendee.Identity = ctxvalues.Subject(ctx) + if config.AnonymizeIdentity() { + // used for testing with generated fake regs + attendee.Identity = randomString(10, 12, 0) + "_gen" + } else { + // record which user owns this attendee + attendee.Identity = ctxvalues.Subject(ctx) + } if config.RequireLoginForReg() { alreadyHasRegistration, err := userAlreadyHasAnotherRegistration(ctx, attendee.Identity, 0) diff --git a/internal/service/attendeesrv/generate.go b/internal/service/attendeesrv/generate.go index 3f6936a..95d0b5c 100644 --- a/internal/service/attendeesrv/generate.go +++ b/internal/service/attendeesrv/generate.go @@ -4,9 +4,11 @@ import ( "context" "fmt" aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/eurofurence/reg-attendee-service/internal/api/v1/attendee" "github.com/eurofurence/reg-attendee-service/internal/entity" "github.com/eurofurence/reg-attendee-service/internal/repository/config" "github.com/eurofurence/reg-attendee-service/internal/repository/database" + "github.com/eurofurence/reg-attendee-service/internal/repository/selfclient" "math/rand" "strings" "time" @@ -26,6 +28,17 @@ func (s *AttendeeServiceImplData) GenerateFakeRegistrations(ctx context.Context, return nil } +func (s *AttendeeServiceImplData) SendFakeRegistrationToAPI(ctx context.Context) (string, error) { + a := fakeRegistration() + dto := &attendee.AttendeeDto{} + mapAttendeeToDto(a, dto) + + ctxTimeout, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + return selfclient.SendRegistration(ctxTimeout, dto) +} + func fakeRegistration() *entity.Attendee { return &entity.Attendee{ Nickname: randomString(3, 40, 2), @@ -34,7 +47,7 @@ func fakeRegistration() *entity.Attendee { Street: randomString(3, 120, 0), Zip: randomString(3, 15, 0), City: randomString(3, 80, 0), - Country: "", + Country: oneOf(config.AllowedCountries()), Email: "jsquirrel_github_9a6d@packetloss.de", Phone: "12345", Birthday: randomValidBirthday(), @@ -43,9 +56,9 @@ func fakeRegistration() *entity.Attendee { SpokenLanguages: "," + randomSelection(config.AllowedSpokenLanguages(), 1, 5) + ",", RegistrationLanguage: oneOf(config.AllowedRegistrationLanguages()), Flags: ",terms-accepted," + randomSelection([]string{"hc", "anon", "digi-book"}, 1, 3) + ",", - Packages: ",room-none,attendance," + oneOf([]string{"sponsor", "sponsor2", "tshirt"}) + ",", + Packages: ",room-none,attendance,stage," + oneOf([]string{"sponsor", "sponsor2", "tshirt"}) + ",", Options: "," + randomSelection(config.AllowedOptions(), 1, 4) + ",", - UserComments: "", + UserComments: "generated by load test", Identity: randomString(10, 12, 0) + "_gen", } } @@ -132,3 +145,30 @@ func safeRandIntn(len int) int { return 0 } } + +func mapAttendeeToDto(a *entity.Attendee, dto *attendee.AttendeeDto) { + // this cannot fail + dto.Id = a.ID + dto.Nickname = a.Nickname + dto.FirstName = a.FirstName + dto.LastName = a.LastName + dto.Street = a.Street + dto.Zip = a.Zip + dto.City = a.City + dto.Country = a.Country + dto.State = a.State + dto.Email = a.Email + dto.Phone = a.Phone + dto.Telegram = a.Telegram + dto.Partner = a.Partner + dto.Birthday = a.Birthday + dto.Gender = a.Gender + dto.Pronouns = a.Pronouns + dto.TshirtSize = a.TshirtSize + dto.SpokenLanguages = removeWrappingCommas(a.SpokenLanguages) + dto.RegistrationLanguage = removeWrappingCommas(a.RegistrationLanguage) + dto.Flags = removeWrappingCommas(a.Flags) + dto.Packages = removeWrappingCommas(a.Packages) + dto.Options = removeWrappingCommas(a.Options) + dto.UserComments = a.UserComments +} diff --git a/internal/service/attendeesrv/interfaces.go b/internal/service/attendeesrv/interfaces.go index 208168f..067830f 100644 --- a/internal/service/attendeesrv/interfaces.go +++ b/internal/service/attendeesrv/interfaces.go @@ -88,6 +88,13 @@ type AttendeeService interface { // // Only for use on test systems. GenerateFakeRegistrations(ctx context.Context, count uint) error + + // SendFakeRegistrationToAPI sends a fake registration via the API. + // + // Only for use on test systems. + // + // Must configure identity_anonymize on the receiver. + SendFakeRegistrationToAPI(ctx context.Context) (string, error) } var ( diff --git a/internal/web/app/application.go b/internal/web/app/application.go index 7dc42f2..a5aaa35 100644 --- a/internal/web/app/application.go +++ b/internal/web/app/application.go @@ -9,12 +9,16 @@ import ( "github.com/eurofurence/reg-attendee-service/internal/repository/database" "github.com/eurofurence/reg-attendee-service/internal/repository/mailservice" "github.com/eurofurence/reg-attendee-service/internal/repository/paymentservice" + "github.com/eurofurence/reg-attendee-service/internal/repository/selfclient" "github.com/eurofurence/reg-attendee-service/internal/service/attendeesrv" + "sync" + "time" ) type Application interface { Run() int Datagen() int + Loadtest() int } type Impl struct{} @@ -59,6 +63,7 @@ func (i *Impl) Run() int { } func (i *Impl) Datagen() int { + config.AdditionalGeneratorCommandLineFlags() config.ParseCommandLineFlags() setupLogging("attendee-service-datagen", config.UseEcsLogging()) @@ -75,7 +80,7 @@ func (i *Impl) Datagen() int { ctx := auzerolog.AddLoggerToCtx(context.Background()) attendeeService := attendeesrv.New() - count := config.GenerateCount() + count := config.GeneratorGenerateCount() if count == 0 { aulogging.Logger.Ctx(ctx).Error().Print("must specify generate-count option with value > 0. BAILING OUT.") return 1 @@ -94,3 +99,80 @@ func (i *Impl) Datagen() int { return 0 } + +func (i *Impl) Loadtest() int { + config.AdditionalGeneratorCommandLineFlags() + config.ParseCommandLineFlags() + setupLogging("attendee-service-loadtest", config.UseEcsLogging()) + + if err := config.StartupLoadConfiguration(); err != nil { + return 1 + } + setLoglevel(config.LoggingSeverity()) + + ctx := auzerolog.AddLoggerToCtx(context.Background()) + + err := selfclient.Setup() + if err != nil { + aulogging.Logger.Ctx(ctx).Error().Printf("failed to set up client: %s. BAILING OUT.", err.Error()) + return 1 + } + + errorCount := 0 + errorCountMutex := sync.Mutex{} + + attendeeService := attendeesrv.New() + count := config.GeneratorGenerateCount() + parallel := config.GeneratorParallel() + if count == 0 { + aulogging.Logger.Ctx(ctx).Error().Print("must specify generate-count option with value > 0. BAILING OUT.") + return 1 + } + if parallel == 0 { + aulogging.Logger.Ctx(ctx).Error().Print("must specify parallel option with value > 0. BAILING OUT.") + return 1 + } + + var wg sync.WaitGroup + for routine := uint(1); routine <= parallel; routine++ { + wg.Add(1) + thisRoutine := routine + go func() { + defer wg.Done() + errsCount := i.loadtestSingle(attendeeService, thisRoutine, count/parallel) + + errorCountMutex.Lock() + defer errorCountMutex.Unlock() + errorCount += errsCount + }() + } + + wg.Wait() + + aulogging.Logger.Ctx(ctx).Info().Printf("Tried to generate %d registrations, %d successful, %d failed", count, count-uint(errorCount), errorCount) + + aulogging.Logger.Ctx(ctx).Info().Print("Remember to turn off email sending when working with these registrations!") + aulogging.Logger.Ctx(ctx).Info().Print("Done.") + + return 0 +} + +func (i *Impl) loadtestSingle(attsrv attendeesrv.AttendeeService, routine uint, countPerRoutine uint) int { + ctx := auzerolog.AddLoggerToCtx(context.Background()) + + aulogging.Logger.Ctx(ctx).Info().Printf("routine %4d will generate %4d registrations in 5s", routine, countPerRoutine) + time.Sleep(5 * time.Second) + + errorCount := 0 + for count := uint(1); count <= countPerRoutine; count++ { + id, err := attsrv.SendFakeRegistrationToAPI(ctx) + if err != nil { + aulogging.Logger.Ctx(ctx).Error().Printf("routine %4d ERROR: %s", routine, err.Error()) + errorCount++ + } else { + aulogging.Logger.Ctx(ctx).Info().Printf("routine %4d success id %s", routine, id) + } + } + + return errorCount +} diff --git a/internal/web/controller/attendeectl/config_test.go b/internal/web/controller/attendeectl/config_test.go index 0e649e6..6b0fabc 100644 --- a/internal/web/controller/attendeectl/config_test.go +++ b/internal/web/controller/attendeectl/config_test.go @@ -159,6 +159,10 @@ func (s *MockAttendeeService) GenerateFakeRegistrations(ctx context.Context, cou return nil } +func (s *MockAttendeeService) SendFakeRegistrationToAPI(ctx context.Context) (string, error) { + return "", nil +} + func tstSetupServiceMocks() { attendeeService = &MockAttendeeService{} }