Initial QSfera import
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
SHELL := bash
|
||||
NAME := clientlog
|
||||
|
||||
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
|
||||
include ../../.bingo/Variables.mk
|
||||
endif
|
||||
|
||||
include ../../.make/default.mk
|
||||
include ../../.make/go.mk
|
||||
include ../../.make/release.mk
|
||||
include ../../.make/docs.mk
|
||||
@@ -0,0 +1,14 @@
|
||||
# Clientlog Service
|
||||
|
||||
The `clientlog` service is responsible for composing machine readable notifications for clients. Clients are apps and web interfaces.
|
||||
|
||||
## The Log Service Ecosystem
|
||||
|
||||
Log services like the `userlog`, `clientlog` and `sse` are responsible for composing notifications for a certain audience.
|
||||
- The `userlog` service translates and adjusts messages to be human readable.
|
||||
- The `clientlog` service composes machine readable messages, so clients can act without the need to query the server.
|
||||
- The `sse` service is only responsible for sending these messages. It does not care about their form or language.
|
||||
|
||||
## Clientlog Events
|
||||
|
||||
The messages the `clientlog` service sends are intended for the use by clients, not by users. The client might for example be informed that a file has finished post-processing. With that, the client can make the file available to the user without additional server queries.
|
||||
@@ -0,0 +1,19 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Health is the entrypoint for the health command.
|
||||
func Health(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "Check health status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Not implemented
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/clihelper"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GetCommands provides all commands for this service
|
||||
func GetCommands(cfg *config.Config) []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
// start this service
|
||||
Server(cfg),
|
||||
|
||||
// interaction with this service
|
||||
|
||||
// infos about this service
|
||||
Health(cfg),
|
||||
Version(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute is the entry point for the clientlog command.
|
||||
func Execute(cfg *config.Config) error {
|
||||
app := clihelper.DefaultApp(&cobra.Command{
|
||||
Use: "clientlog",
|
||||
Short: "starts clientlog service",
|
||||
})
|
||||
app.AddCommand(GetCommands(cfg)...)
|
||||
app.SetArgs(os.Args[1:])
|
||||
|
||||
return app.ExecuteContext(cfg.Context)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/signal"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/generators"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/runner"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config/parser"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/metrics"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/server/debug"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/service"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events/stream"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// all events we care about
|
||||
var _registeredEvents = []events.Unmarshaller{
|
||||
events.UploadReady{},
|
||||
events.ItemTrashed{},
|
||||
events.ItemRestored{},
|
||||
events.ItemMoved{},
|
||||
events.ContainerCreated{},
|
||||
events.FileLocked{},
|
||||
events.FileUnlocked{},
|
||||
events.FileTouched{},
|
||||
events.SpaceShared{},
|
||||
events.SpaceShareUpdated{},
|
||||
events.SpaceUnshared{},
|
||||
events.ShareCreated{},
|
||||
events.ShareRemoved{},
|
||||
events.ShareUpdated{},
|
||||
events.LinkCreated{},
|
||||
events.LinkUpdated{},
|
||||
events.LinkRemoved{},
|
||||
events.BackchannelLogout{},
|
||||
}
|
||||
|
||||
// Server is the entrypoint for the server command.
|
||||
func Server(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "server",
|
||||
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnFatal(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
tracerProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cancel context.CancelFunc
|
||||
if cfg.Context == nil {
|
||||
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
|
||||
defer cancel()
|
||||
}
|
||||
ctx := cfg.Context
|
||||
|
||||
mtrcs := metrics.New()
|
||||
mtrcs.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
|
||||
s, err := stream.NatsFromConfig(connName, false, stream.NatsConfig(cfg.Events))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tm, err := pool.StringToTLSMode(cfg.GRPCClientTLS.Mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gatewaySelector, err := pool.GatewaySelector(
|
||||
cfg.RevaGateway,
|
||||
pool.WithTLSCACert(cfg.GRPCClientTLS.CACert),
|
||||
pool.WithTLSMode(tm),
|
||||
pool.WithRegistry(registry.GetRegistry()),
|
||||
pool.WithTracerProvider(tracerProvider),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get reva client selector: %s", err)
|
||||
}
|
||||
|
||||
gr := runner.NewGroup()
|
||||
{
|
||||
svc, err := service.NewClientlogService(
|
||||
service.Logger(logger),
|
||||
service.Config(cfg),
|
||||
service.Stream(s),
|
||||
service.GatewaySelector(gatewaySelector),
|
||||
service.RegisteredEvents(_registeredEvents),
|
||||
service.TraceProvider(tracerProvider),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Info().Err(err).Str("transport", "http").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.New(cfg.Service.Name+".svc", func() error {
|
||||
return svc.Run()
|
||||
}, func() {
|
||||
svc.Close()
|
||||
}))
|
||||
}
|
||||
|
||||
{
|
||||
debugServer, err := debug.Server(
|
||||
debug.Logger(logger),
|
||||
debug.Context(ctx),
|
||||
debug.Config(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Info().Err(err).Str("server", "debug").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
|
||||
}
|
||||
|
||||
grResults := gr.Run(ctx)
|
||||
|
||||
// return the first non-nil error found in the results
|
||||
for _, grResult := range grResults {
|
||||
if grResult.RunnerError != nil {
|
||||
return grResult.RunnerError
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version prints the service versions of all running instances.
|
||||
func Version(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "print the version of this binary and the running service instances",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// not implemented
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
)
|
||||
|
||||
// Config combines all available configuration parts.
|
||||
type Config struct {
|
||||
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
|
||||
|
||||
Service Service `yaml:"-"`
|
||||
|
||||
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;CLIENTLOG_USERLOG_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
|
||||
|
||||
Debug Debug `yaml:"debug"`
|
||||
|
||||
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
|
||||
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
|
||||
RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"1.0.0"`
|
||||
Events Events `yaml:"events"`
|
||||
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
|
||||
// Events combines the configuration options for the event bus.
|
||||
type Events struct {
|
||||
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;CLIENTLOG_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture." introductionVersion:"1.0.0"`
|
||||
Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;CLIENTLOG_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Mandatory when using NATS as event system." introductionVersion:"1.0.0"`
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;CLIENTLOG_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
|
||||
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE;CLIENTLOG_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided NOTIFICATIONS_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"1.0.0"`
|
||||
EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS;CLIENTLOG_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
|
||||
AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME;CLIENTLOG_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
|
||||
AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD;CLIENTLOG_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OC_JWT_SECRET;CLIENTLOG_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// ServiceAccount is the configuration for the used service account
|
||||
type ServiceAccount struct {
|
||||
ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;CLIENTLOG_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"1.0.0"`
|
||||
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;CLIENTLOG_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Debug defines the available debug configuration.
|
||||
type Debug struct {
|
||||
Addr string `yaml:"addr" env:"CLIENTLOG_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
|
||||
Token string `yaml:"token" env:"CLIENTLOG_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
|
||||
Pprof bool `yaml:"pprof" env:"CLIENTLOG_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
|
||||
Zpages bool `yaml:"zpages" env:"CLIENTLOG_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/pkg/structs"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config"
|
||||
)
|
||||
|
||||
// FullDefaultConfig returns the full default config
|
||||
func FullDefaultConfig() *config.Config {
|
||||
cfg := DefaultConfig()
|
||||
EnsureDefaults(cfg)
|
||||
Sanitize(cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DefaultConfig return the default configuration
|
||||
func DefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9260",
|
||||
Token: "",
|
||||
Pprof: false,
|
||||
Zpages: false,
|
||||
},
|
||||
Service: config.Service{
|
||||
Name: "clientlog",
|
||||
},
|
||||
Events: config.Events{
|
||||
Endpoint: "127.0.0.1:9233",
|
||||
Cluster: "qsfera-cluster",
|
||||
EnableTLS: false,
|
||||
},
|
||||
RevaGateway: shared.DefaultRevaConfig().Address,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDefaults ensures the config contains default values
|
||||
func EnsureDefaults(cfg *config.Config) {
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "error"
|
||||
}
|
||||
|
||||
if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
|
||||
cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
|
||||
}
|
||||
|
||||
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
|
||||
cfg.TokenManager = &config.TokenManager{
|
||||
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
|
||||
}
|
||||
} else if cfg.TokenManager == nil {
|
||||
cfg.TokenManager = &config.TokenManager{}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Sanitize sanitizes the config
|
||||
func Sanitize(cfg *config.Config) {
|
||||
// sanitize config
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
occfg "github.com/qsfera/server/pkg/config"
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config/defaults"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/envdecode"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
func ParseConfig(cfg *config.Config) error {
|
||||
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.EnsureDefaults(cfg)
|
||||
|
||||
// load all env variables relevant to the config in the current context.
|
||||
if err := envdecode.Decode(cfg); err != nil {
|
||||
// no environment variable set for this config is an expected "error"
|
||||
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defaults.Sanitize(cfg)
|
||||
|
||||
return Validate(cfg)
|
||||
}
|
||||
|
||||
// Validate validates the config
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
|
||||
if cfg.ServiceAccount.ServiceAccountID == "" {
|
||||
return shared.MissingServiceAccountID(cfg.Service.Name)
|
||||
}
|
||||
if cfg.ServiceAccount.ServiceAccountSecret == "" {
|
||||
return shared.MissingServiceAccountSecret(cfg.Service.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// Service defines the available service configuration.
|
||||
type Service struct {
|
||||
Name string `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
var (
|
||||
// Namespace defines the namespace for the defines metrics.
|
||||
Namespace = "qsfera"
|
||||
|
||||
// Subsystem defines the subsystem for the defines metrics.
|
||||
Subsystem = "clientlog"
|
||||
)
|
||||
|
||||
// Metrics defines the available metrics of this service.
|
||||
type Metrics struct {
|
||||
BuildInfo *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
// New initializes the available metrics.
|
||||
func New() *Metrics {
|
||||
m := &Metrics{
|
||||
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "build_info",
|
||||
Help: "Build information",
|
||||
}, []string{"version"}),
|
||||
}
|
||||
|
||||
_ = prometheus.Register(
|
||||
m.BuildInfo,
|
||||
)
|
||||
|
||||
// TODO: implement metrics
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/checks"
|
||||
"github.com/qsfera/server/pkg/handlers"
|
||||
"github.com/qsfera/server/pkg/service/debug"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
)
|
||||
|
||||
// Server initializes the debug service and server.
|
||||
func Server(opts ...Option) (*http.Server, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
readyHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
|
||||
WithLogger(options.Logger).
|
||||
WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Events.Endpoint))
|
||||
|
||||
return debug.NewService(
|
||||
debug.Logger(options.Logger),
|
||||
debug.Name(options.Config.Service.Name),
|
||||
debug.Version(version.GetString()),
|
||||
debug.Address(options.Config.Debug.Addr),
|
||||
debug.Token(options.Config.Debug.Token),
|
||||
debug.Pprof(options.Config.Debug.Pprof),
|
||||
debug.Zpages(options.Config.Debug.Zpages),
|
||||
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
|
||||
), nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package service
|
||||
|
||||
// FileEvent is emitted when a file is uploaded/renamed/deleted/...
|
||||
type FileEvent struct {
|
||||
ParentItemID string `json:"parentitemid"`
|
||||
ItemID string `json:"itemid"`
|
||||
SpaceID string `json:"spaceid"`
|
||||
InitiatorID string `json:"initiatorid"`
|
||||
Etag string `json:"etag"`
|
||||
|
||||
// Only in case of sharing (refactor this into separate struct when more fields are needed)
|
||||
AffectedUserIDs []string `json:"affecteduserids"`
|
||||
}
|
||||
|
||||
// BackchannelLogout is emitted when the callback revived from the identity provider
|
||||
type BackchannelLogout struct {
|
||||
UserID string `json:"userid"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
SessionID string `json:"sessionid"`
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Option for the clientlog service
|
||||
type Option func(*Options)
|
||||
|
||||
// Options for the clientlog service
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Stream events.Stream
|
||||
Config *config.Config
|
||||
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
RegisteredEvents []events.Unmarshaller
|
||||
TraceProvider trace.TracerProvider
|
||||
}
|
||||
|
||||
// Logger configures a logger for the clientlog service
|
||||
func Logger(log log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = log
|
||||
}
|
||||
}
|
||||
|
||||
// Stream configures an event stream for the clientlog service
|
||||
func Stream(s events.Stream) Option {
|
||||
return func(o *Options) {
|
||||
o.Stream = s
|
||||
}
|
||||
}
|
||||
|
||||
// Config adds the config for the clientlog service
|
||||
func Config(c *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = c
|
||||
}
|
||||
}
|
||||
|
||||
// GatewaySelector adds a grpc client selector for the gateway service
|
||||
func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.GatewaySelector = gatewaySelector
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredEvents registers the events the service should listen to
|
||||
func RegisteredEvents(e []events.Unmarshaller) Option {
|
||||
return func(o *Options) {
|
||||
o.RegisteredEvents = e
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider adds a tracer provider for the clientlog service
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = tp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
|
||||
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/clientlog/pkg/config"
|
||||
)
|
||||
|
||||
// ClientlogService is the service responsible for user activities
|
||||
type ClientlogService struct {
|
||||
log log.Logger
|
||||
cfg *config.Config
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
registeredEvents map[string]events.Unmarshaller // ?
|
||||
tp trace.TracerProvider
|
||||
tracer trace.Tracer
|
||||
publisher events.Publisher
|
||||
ch <-chan events.Event
|
||||
stopCh chan struct{}
|
||||
stopped atomic.Bool
|
||||
}
|
||||
|
||||
// NewClientlogService returns a clientlog service
|
||||
func NewClientlogService(opts ...Option) (*ClientlogService, error) {
|
||||
o := &Options{}
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
if o.Stream == nil {
|
||||
return nil, fmt.Errorf("need non nil stream (%v) to work properly", o.Stream)
|
||||
}
|
||||
|
||||
ch, err := events.Consume(o.Stream, "clientlog", o.RegisteredEvents...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cl := &ClientlogService{
|
||||
log: o.Logger,
|
||||
cfg: o.Config,
|
||||
gatewaySelector: o.GatewaySelector,
|
||||
registeredEvents: make(map[string]events.Unmarshaller),
|
||||
tp: o.TraceProvider,
|
||||
tracer: o.TraceProvider.Tracer("github.com/qsfera/server/services/clientlog/pkg/service"),
|
||||
publisher: o.Stream,
|
||||
ch: ch,
|
||||
stopCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
for _, e := range o.RegisteredEvents {
|
||||
typ := reflect.TypeOf(e)
|
||||
cl.registeredEvents[typ.String()] = e
|
||||
}
|
||||
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// Run runs the service
|
||||
func (cl *ClientlogService) Run() error {
|
||||
EventLoop:
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-cl.ch:
|
||||
if !ok {
|
||||
break EventLoop
|
||||
}
|
||||
cl.processEvent(event)
|
||||
|
||||
if cl.stopped.Load() {
|
||||
break EventLoop
|
||||
}
|
||||
case <-cl.stopCh:
|
||||
break EventLoop
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cl *ClientlogService) Close() {
|
||||
if cl.stopped.CompareAndSwap(false, true) {
|
||||
close(cl.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (cl *ClientlogService) processEvent(event events.Event) {
|
||||
gwc, err := cl.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
cl.log.Error().Err(err).Interface("event", event).Msg("error getting gateway client")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, err := utils.GetServiceUserContextWithContext(context.Background(), gwc, cl.cfg.ServiceAccount.ServiceAccountID, cl.cfg.ServiceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
cl.log.Error().Err(err).Interface("event", event).Msg("error authenticating service user")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
users []string
|
||||
evType string
|
||||
data any
|
||||
)
|
||||
|
||||
fileEv := func(typ string, ref *provider.Reference) {
|
||||
evType = typ
|
||||
users, data, err = processFileEvent(ctx, ref, gwc, event.InitiatorID)
|
||||
}
|
||||
|
||||
shareEv := func(typ string, ref *provider.Reference, uid *user.UserId, gid *group.GroupId) {
|
||||
evType = typ
|
||||
users, data, err = processShareEvent(ctx, ref, gwc, event.InitiatorID, uid, gid)
|
||||
}
|
||||
|
||||
switch e := event.Event.(type) {
|
||||
default:
|
||||
err = errors.New("unhandled event")
|
||||
case events.UploadReady:
|
||||
if e.Failed {
|
||||
// we don't inform about failed uploads yet
|
||||
return
|
||||
}
|
||||
fileEv("postprocessing-finished", e.FileRef)
|
||||
case events.ItemTrashed:
|
||||
evType = "item-trashed"
|
||||
users, data, err = processItemTrashedEvent(ctx, e.Ref, gwc, event.InitiatorID, e.ID)
|
||||
case events.ItemRestored:
|
||||
fileEv("item-restored", e.Ref)
|
||||
case events.ContainerCreated:
|
||||
fileEv("folder-created", e.Ref)
|
||||
case events.ItemMoved:
|
||||
// we send a dedicated event in case the item was only renamed
|
||||
if isRename(e.OldReference, e.Ref) {
|
||||
fileEv("item-renamed", e.Ref)
|
||||
} else {
|
||||
fileEv("item-moved", e.Ref)
|
||||
}
|
||||
case events.FileLocked:
|
||||
fileEv("file-locked", e.Ref)
|
||||
case events.FileUnlocked:
|
||||
fileEv("file-unlocked", e.Ref)
|
||||
case events.FileTouched:
|
||||
fileEv("file-touched", e.Ref)
|
||||
case events.SpaceShared:
|
||||
r, _ := storagespace.ParseReference(e.ID.GetOpaqueId())
|
||||
shareEv("space-member-added", &r, e.GranteeUserID, e.GranteeGroupID)
|
||||
case events.SpaceShareUpdated:
|
||||
r, _ := storagespace.ParseReference(e.ID.GetOpaqueId())
|
||||
shareEv("space-share-updated", &r, e.GranteeUserID, e.GranteeGroupID)
|
||||
case events.SpaceUnshared:
|
||||
r, _ := storagespace.ParseReference(e.ID.GetOpaqueId())
|
||||
shareEv("space-member-removed", &r, e.GranteeUserID, e.GranteeGroupID)
|
||||
case events.ShareCreated:
|
||||
shareEv("share-created", &provider.Reference{ResourceId: e.ItemID}, e.GranteeUserID, e.GranteeGroupID)
|
||||
case events.ShareUpdated:
|
||||
shareEv("share-updated", &provider.Reference{ResourceId: e.ItemID}, e.GranteeUserID, e.GranteeGroupID)
|
||||
case events.ShareRemoved:
|
||||
shareEv("share-removed", &provider.Reference{ResourceId: e.ItemID}, e.GranteeUserID, e.GranteeGroupID)
|
||||
case events.LinkCreated:
|
||||
fileEv("link-created", &provider.Reference{ResourceId: e.ItemID})
|
||||
case events.LinkUpdated:
|
||||
fileEv("link-updated", &provider.Reference{ResourceId: e.ItemID})
|
||||
case events.LinkRemoved:
|
||||
fileEv("link-removed", &provider.Reference{ResourceId: e.ItemID})
|
||||
case events.BackchannelLogout:
|
||||
evType, users, data = backchannelLogoutEvent(e)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
cl.log.Error().Err(err).Interface("event", event).Msg("error gathering members for event")
|
||||
return
|
||||
}
|
||||
|
||||
// II) instruct sse service to send the information
|
||||
if err := cl.sendSSE(users, evType, data); err != nil {
|
||||
cl.log.Error().Err(err).Interface("userIDs", users).Str("eventid", event.ID).Msg("failed to store event for user")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (cl *ClientlogService) sendSSE(userIDs []string, evType string, data any) error {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return events.Publish(context.Background(), cl.publisher, events.SendSSE{
|
||||
UserIDs: userIDs,
|
||||
Type: evType,
|
||||
Message: b,
|
||||
})
|
||||
}
|
||||
|
||||
// process file related events
|
||||
func processFileEvent(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient, initiatorid string) ([]string, FileEvent, error) {
|
||||
info, err := utils.GetResource(ctx, ref, gwc)
|
||||
if err != nil {
|
||||
return nil, FileEvent{}, err
|
||||
}
|
||||
|
||||
data := FileEvent{
|
||||
ParentItemID: storagespace.FormatResourceID(info.GetParentId()),
|
||||
ItemID: storagespace.FormatResourceID(info.GetId()),
|
||||
SpaceID: storagespace.FormatStorageID(info.GetSpace().GetRoot().GetStorageId(), info.GetSpace().GetRoot().GetSpaceId()),
|
||||
InitiatorID: initiatorid,
|
||||
Etag: info.GetEtag(),
|
||||
}
|
||||
|
||||
users, err := utils.GetSpaceMembers(ctx, info.GetSpace().GetId().GetOpaqueId(), gwc, utils.ViewerRole)
|
||||
return users, data, err
|
||||
}
|
||||
|
||||
// process share related events
|
||||
func processShareEvent(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient, initiatorid string, shareeID *user.UserId, shareeGroupID *group.GroupId) ([]string, FileEvent, error) {
|
||||
users, data, err := processFileEvent(ctx, ref, gwc, initiatorid)
|
||||
if err != nil {
|
||||
return users, data, err
|
||||
}
|
||||
|
||||
return addShareeData(ctx, gwc, data, users, shareeID, shareeGroupID)
|
||||
}
|
||||
|
||||
// custom logic for item trashed event
|
||||
func processItemTrashedEvent(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient, initiatorid string, itemID *provider.ResourceId) ([]string, FileEvent, error) {
|
||||
data := FileEvent{
|
||||
ItemID: storagespace.FormatResourceID(itemID),
|
||||
// TODO: check with web if parentID is needed
|
||||
// ParentItemID: storagespace.FormatResourceID(*item.GetRef().GetResourceId()),
|
||||
SpaceID: storagespace.FormatStorageID(itemID.GetStorageId(), itemID.GetSpaceId()),
|
||||
InitiatorID: initiatorid,
|
||||
}
|
||||
|
||||
users, err := utils.GetSpaceMembers(ctx, itemID.GetSpaceId(), gwc, utils.ViewerRole)
|
||||
return users, data, err
|
||||
}
|
||||
|
||||
// adds share related data to the FileEvent
|
||||
func addShareeData(ctx context.Context, gwc gateway.GatewayAPIClient, fe FileEvent, users []string, shareeID *user.UserId, shareeGroupID *group.GroupId) ([]string, FileEvent, error) {
|
||||
us, err := resolveID(ctx, gwc, shareeID, shareeGroupID)
|
||||
if err != nil {
|
||||
return users, fe, err
|
||||
}
|
||||
|
||||
fe.AffectedUserIDs = us
|
||||
|
||||
// TODO: this list can get long. Should we add a limit? If yes, how big?
|
||||
for _, u := range us {
|
||||
users = appendUnique(users, u)
|
||||
}
|
||||
return users, fe, nil
|
||||
}
|
||||
|
||||
// returns the user or the members of the affected group
|
||||
func resolveID(ctx context.Context, gwc gateway.GatewayAPIClient, uid *user.UserId, gid *group.GroupId) ([]string, error) {
|
||||
if uid != nil {
|
||||
return []string{uid.GetOpaqueId()}, nil
|
||||
}
|
||||
return utils.GetGroupMembers(ctx, gid.GetOpaqueId(), gwc)
|
||||
}
|
||||
|
||||
// returns users or append(users, user)
|
||||
func appendUnique(users []string, user string) []string {
|
||||
for _, u := range users {
|
||||
if u == user {
|
||||
return users
|
||||
}
|
||||
}
|
||||
return append(users, user)
|
||||
}
|
||||
|
||||
// returns true if this is just a rename
|
||||
func isRename(o, n *provider.Reference) bool {
|
||||
// if resourceids are different we assume it is a move
|
||||
if !utils.ResourceIDEqual(o.GetResourceId(), n.GetResourceId()) {
|
||||
return false
|
||||
}
|
||||
return filepath.Base(o.GetPath()) != filepath.Base(n.GetPath())
|
||||
}
|
||||
|
||||
func backchannelLogoutEvent(e events.BackchannelLogout) (string, []string, BackchannelLogout) {
|
||||
return "backchannel-logout", []string{e.Executant.GetOpaqueId()}, BackchannelLogout{
|
||||
UserID: e.Executant.GetOpaqueId(),
|
||||
Timestamp: e.Timestamp.String(),
|
||||
SessionID: e.SessionId,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user