diff --git a/.gitignore b/.gitignore index 82cb937..ac936b4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,4 @@ development.yml .DS_Store # Our own binary, of course -gort +./gort diff --git a/README.md b/README.md index b43f242..e0dcd04 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,7 @@ long_description: |- permissions: - can_echo -docker: - image: ubuntu - tag: 20.04 +image: ubuntu:20.04 commands: foo: diff --git a/Tiltfile b/Tiltfile index 7e393b1..3626d21 100644 --- a/Tiltfile +++ b/Tiltfile @@ -1,2 +1,68 @@ -docker_compose('docker-compose.yml') -docker_build('getgort/gort', '.') \ No newline at end of file +# Use `tilt up -- --k8s` to run with Kubernetes. +config.define_bool("k8s",args=False,usage="If set, runs resources under Kubernetes. Otherwise, docker-compose is used.") + +args = config.parse() +k8s = "k8s" in args and args["k8s"] + +# Build the container image as needed +docker_build('getgort/gort', '.') + +# Set up resources using docker-compose +def compose(): + docker_compose('docker-compose.yml') + +# Pull relay config from development.yml +def loadRelayConfig(devConfig,relayType,setValues): + if relayType in devConfig.keys(): + for i in range(len(devConfig[relayType])): + discord = devConfig[relayType][i] + for key in discord: + setValues.append("config.{0}[{1}].{2}={3}".format(relayType,i,key,discord[key])) + +def loadDbConfig(devConfig,setValues): + db = devConfig["database"] + for key in db: + value = "config.{0}.{1}={2}".format("database",key,db[key]) + setValues.append(value) + +setValues = [] +if os.path.exists("development.yml"): + devConfig = read_yaml("development.yml") + loadRelayConfig(devConfig,"discord",setValues) + loadRelayConfig(devConfig,"slack",setValues) + loadDbConfig(devConfig,setValues) + +# Set up resources using Kubernetes +def kubernetes(): + k8s_yaml('tilt-datasources.yaml') + k8s_yaml( + helm( + './helm/gort', + set = setValues + ) + ) + k8s_resource('postgres', port_forwards=5432) + k8s_resource('chart-gort', port_forwards=4000) + +## Resources to permit common tasks to be run via the Tilt Web UI. + +# Bootstrapping the server +local_resource( + "Bootstrap", + "go run . bootstrap https://localhost:4000 --allow-insecure", + trigger_mode=TRIGGER_MODE_MANUAL, + auto_init=False +) + +# Clear profiles +local_resource( + "Clear Profiles", + "rm -f ~/.gort/profile", + trigger_mode=TRIGGER_MODE_MANUAL, + auto_init=False +) + +if k8s: + kubernetes() +else: + compose() diff --git a/adapter/adapter.go b/adapter/adapter.go index 537d7c4..3f47a2a 100644 --- a/adapter/adapter.go +++ b/adapter/adapter.go @@ -24,7 +24,6 @@ import ( "time" log "github.com/sirupsen/logrus" - "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -40,6 +39,7 @@ import ( gerrs "github.com/getgort/gort/errors" "github.com/getgort/gort/rules" "github.com/getgort/gort/telemetry" + "github.com/getgort/gort/templates" "github.com/getgort/gort/version" ) @@ -109,13 +109,18 @@ type Adapter interface { // begin relaying back events (including errors) via the returned channel. Listen(ctx context.Context) <-chan *ProviderEvent - // SendErrorMessage sends an error message to a specified channel. - // TODO Create a MessageBuilder at some point to replace this. - SendErrorMessage(channelID string, title string, text string) error + // Send sends the contents of a response envelope to a + // specified channel. If channelID is empty the value of + // envelope.Request.ChannelID will be used. + Send(ctx context.Context, channelID string, elements templates.OutputElements) error + + // SendText sends a simple text message to the specified channel. + SendText(ctx context.Context, channelID string, message string) error - // SendMessage sends a standard output message to a specified channel. - // TODO Create a MessageBuilder at some point to replace this. - SendMessage(channel string, message string) error + // SendError is a break-glass error message function that's used when the + // templating function fails somehow. Obviously, it does not utilize the + // templating engine. + SendError(ctx context.Context, channelID string, title string, err error) error } type RequestorIdentity struct { @@ -207,7 +212,7 @@ func OnConnected(ctx context.Context, event *ProviderEvent, data *ConnectedEvent for _, c := range channels { message := fmt.Sprintf("Gort version %s is online. Hello, %s!", version.Version, c.Name) - err := event.Adapter.SendMessage(c.ID, message) + err := SendMessage(ctx, event.Adapter, c.ID, message) if err != nil { telemetry.Errors().WithError(err).Commit(ctx) addSpanAttributes(ctx, sp, err) @@ -240,7 +245,7 @@ func OnChannelMessage(ctx context.Context, event *ProviderEvent, data *ChannelMe id, err := buildRequestorIdentity(ctx, event.Adapter, data.ChannelID, data.UserID) if err != nil { telemetry.Errors().WithError(err).Commit(ctx) - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Error", unexpectedError) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Error", unexpectedError) return nil, err } @@ -270,7 +275,7 @@ func OnDirectMessage(ctx context.Context, event *ProviderEvent, data *DirectMess id, err := buildRequestorIdentity(ctx, event.Adapter, data.ChannelID, data.UserID) if err != nil { telemetry.Errors().WithError(err).Commit(ctx) - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Error", unexpectedError) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Error", unexpectedError) return nil, err } @@ -282,13 +287,79 @@ func OnDirectMessage(ctx context.Context, event *ProviderEvent, data *DirectMess return TriggerCommand(ctx, rawCommandText, id) } +// SendErrorMessage sends an error message to a specified channel. +func SendErrorMessage(ctx context.Context, a Adapter, channelID string, title, text string) error { + e := data.NewCommandResponseEnvelope(data.CommandRequest{}, data.WithError(title, fmt.Errorf(text), 1)) + return SendEnvelope(ctx, a, channelID, e, data.MessageError) +} + +// SendMessage sends a standard output message to a specified channel. +func SendMessage(ctx context.Context, a Adapter, channelID string, message string) error { + e := data.NewCommandResponseEnvelope(data.CommandRequest{}, data.WithResponseLines([]string{message})) + return SendEnvelope(ctx, a, channelID, e, data.Message) +} + +// Send the contents of a response envelope to a specified channel. If +// channelID is empty the value of envelope.Request.ChannelID will be used. +func SendEnvelope(ctx context.Context, a Adapter, channelID string, envelope data.CommandResponseEnvelope, tt data.TemplateType) error { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "adapter.SendEnvelope") + defer sp.End() + + e := adapterLogEntry(ctx, log.WithContext(ctx), a).WithField("message.type", tt) + + template, err := templates.Get(envelope.Request.Command, envelope.Request.Bundle, tt) + if err != nil { + e.WithError(err).Error("failed to get template") + if err := a.SendError(ctx, channelID, "Failed to Get Template", err); err != nil { + e.WithError(err).Error("break-glass send error failure!") + } + return err + } + + tf, err := templates.Transform(template, envelope) + if err != nil { + e.WithError(err).Error("template engine failed to transform template") + if err := a.SendError(ctx, channelID, "Failed to Transform Template", err); err != nil { + e.WithError(err).Error("break-glass send error failure!") + } + return err + } + + elements, err := templates.EncodeElements(tf) + if err != nil { + e.WithError(err).Error("template engine failed to encode elements") + if err := a.SendError(ctx, channelID, "Failed to Transform Template", err); err != nil { + e.WithError(err).Error("break-glass send error failure!") + } + return err + } + + err = a.Send(ctx, channelID, elements) + if err == nil { + return nil + } + + e.WithError(err).Warn("failed to send rich message to adapter, falling back to alt text") + err = a.SendText(ctx, channelID, elements.Alt()) + if err != nil { + e.WithError(err).Error("failed to send message to adapter") + if err := a.SendError(ctx, channelID, "Failed to Send Message", err); err != nil { + e.WithError(err).Error("break-glass send error failure!") + } + return err + } + + return nil +} + // StartListening instructs all relays to establish connections, receives all // events from all relays, and forwards them to the various On* handler functions. -func StartListening(ctx context.Context) (<-chan data.CommandRequest, chan<- data.CommandResponse, <-chan error) { +func StartListening(ctx context.Context) (<-chan data.CommandRequest, chan<- data.CommandResponseEnvelope, <-chan error) { log.Debug("Instructing relays to establish connections") commandRequests := make(chan data.CommandRequest) - commandResponses := make(chan data.CommandResponse) + commandResponses := make(chan data.CommandResponseEnvelope) allEvents, adapterErrors := startAdapters(ctx) @@ -329,21 +400,24 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity if id.GortUser, autocreated, err = findOrMakeGortUser(ctx, id.Adapter, id.ChatUser); err != nil { switch { case gerrs.Is(err, ErrSelfRegistrationOff): - msg := "I'm terribly sorry, but either I don't " + - "have a Gort account for you, or your Slack chat handle has " + - "not been registered. Currently, only registered users can " + - "interact with me.\n\n\nYou'll need to ask a Gort " + - "administrator to fix this situation and to register your " + - "Slack handle." - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "No Such Account", msg) + msg := "I'm terribly sorry, but either I don't have a Gort " + + "account for you, or your chat handle has not been " + + "registered. Currently, only registered users can " + + "interact with me.\n\nYou'll need a Gort administrator " + + "to map your Gort user to the adapter (%s) and chat " + + "user ID (%s)." + msg = fmt.Sprintf(msg, id.Adapter.GetName(), id.ChatUser.ID) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "No Such Account", msg) + case gerrs.Is(err, ErrGortNotBootstrapped): msg := "Gort doesn't appear to have been bootstrapped yet! Please " + "use `gort bootstrap` to properly bootstrap the Gort " + "environment before proceeding." - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Not Bootstrapped?", msg) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Not Bootstrapped?", msg) + default: msg := "An unexpected error has occurred" - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Error", msg) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Error", msg) } da.RequestError(ctx, request, err) @@ -364,7 +438,7 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity message := fmt.Sprintf("Hello! It's great to meet you! You're the proud "+ "owner of a shiny new Gort account named `%s`!", id.GortUser.Username) - id.Adapter.SendMessage(id.ChatUser.ID, message) + SendMessage(ctx, id.Adapter, id.ChatUser.ID, message) le.Info("Autocreating Gort user") } @@ -376,7 +450,7 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity da.RequestError(ctx, request, err) telemetry.Errors().WithError(err).Commit(ctx) le.WithError(err).Error("Command tokenization error") - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Error", unexpectedError) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Error", unexpectedError) return nil, fmt.Errorf("command tokenization error: %w", err) } @@ -396,7 +470,7 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity da.RequestError(ctx, request, err) telemetry.Errors().WithError(err).Commit(ctx) le.WithError(err).Error("Command parse error") - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Error", unexpectedError) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Error", unexpectedError) return nil, fmt.Errorf("command parse error: %w", err) } @@ -409,15 +483,14 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity msg := fmt.Sprintf("No such bundle is currently installed: %s.\n"+ "If this is not expected, you should contact a Gort administrator.", tokens[0]) - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "No Such Command", msg) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "No Such Command", msg) case gerrs.Is(err, ErrMultipleCommands): msg := fmt.Sprintf("The command %s matches multiple bundles.\n"+ "Please namespace your command using the bundle name: `bundle:command`.", tokens[0]) - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "No Such Command", msg) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "No Such Command", msg) default: - msg := formatCommandInputErrorMessage(cmdInput, tokens, err.Error()) - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Error", msg) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Error", err.Error()) } da.RequestError(ctx, request, err) @@ -435,7 +508,7 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity da.RequestError(ctx, request, err) telemetry.Errors().WithError(err).Commit(ctx) le.WithError(err).Error("Command parse error") - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Error", unexpectedError) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Error", unexpectedError) return nil, fmt.Errorf("command parse error: %w", err) } @@ -458,7 +531,7 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity da.RequestError(ctx, request, err) telemetry.Errors().WithError(err).Commit(ctx) le.WithError(err).Error("User permission load failure") - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Error", unexpectedError) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Error", unexpectedError) return nil, fmt.Errorf("user permission load error: %w", err) } @@ -471,7 +544,7 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity switch { case gerrs.Is(err, auth.ErrRuleLoadError): le.WithError(err).Error("Rule load error") - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Error", unexpectedError) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Error", unexpectedError) return nil, fmt.Errorf("rule load error: %w", err) case gerrs.Is(err, auth.ErrNoRulesDefined): @@ -479,14 +552,14 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity msg := fmt.Sprintf("The command %s:%s doesn't have any associated rules.\n"+ "For a command to be executable, it must have at least one rule.", cmdEntry.Bundle.Name, cmdEntry.Command.Name) - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "No Rules Defined", msg) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "No Rules Defined", msg) return nil, err } } if !allowed { msg := fmt.Sprintf("You do not have the permissions to execute %s:%s.", cmdEntry.Bundle.Name, cmdEntry.Command.Name) - id.Adapter.SendErrorMessage(id.ChatChannel.ID, "Permission Denied", msg) + SendErrorMessage(ctx, id.Adapter, id.ChatChannel.ID, "Permission Denied", msg) err = fmt.Errorf("permission denied") da.RequestError(ctx, request, err) @@ -758,7 +831,7 @@ func buildRequestorIdentity(ctx context.Context, adapter Adapter, channelId, use } } - le.Info("requestor identity built") + le.Info("Requestor identity built") return id, nil } @@ -855,41 +928,6 @@ func findOrMakeGortUser(ctx context.Context, adapter Adapter, info *UserInfo) (* return &user, true, da.UserCreate(ctx, user) } -// TODO Replace this with something resembling a template. Eventually. -func formatCommandEntryErrorMessage(command data.CommandEntry, params []string, output string) string { - rawCommand := fmt.Sprintf( - "%s:%s %s", - command.Bundle.Name, command.Command.Name, strings.Join(params, " ")) - - return fmt.Sprintf( - "%s\n```%s```\n%s\n```%s```", - "The pipeline failed planning the invocation:", - rawCommand, - "The specific error was:", - output, - ) -} - -// TODO Replace this with something resembling a template. Eventually. -func formatCommandInputErrorMessage(cmd command.Command, params []string, output string) string { - rawCommand := fmt.Sprintf( - "%s:%s %v", - cmd.Bundle, cmd.Command, cmd.Parameters) - - return fmt.Sprintf( - "%s\n```%s```\n%s\n```%s```", - "The pipeline failed planning the invocation:", - rawCommand, - "The specific error was:", - output, - ) -} - -// TODO Replace this with something resembling a template. Eventually. -func formatCommandOutput(command data.CommandEntry, params []string, output string) string { - return fmt.Sprintf("```%s```", output) -} - func handleIncomingEvent(event *ProviderEvent, commandRequests chan<- data.CommandRequest, adapterErrors chan<- error) { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) ctx, sp := tr.Start(context.Background(), "adapter.handleIncomingEvent") @@ -951,47 +989,32 @@ func startAdapters(ctx context.Context) (<-chan *ProviderEvent, chan error) { return allEvents, adapterErrors } -func startProviderEventListening(commandRequests chan<- data.CommandRequest, +func startProviderEventListening(requests chan<- data.CommandRequest, allEvents <-chan *ProviderEvent, adapterErrors chan<- error) { for event := range allEvents { - handleIncomingEvent(event, commandRequests, adapterErrors) + handleIncomingEvent(event, requests, adapterErrors) } } -func startRelayResponseListening(commandResponses <-chan data.CommandResponse, +func startRelayResponseListening(responses <-chan data.CommandResponseEnvelope, allEvents <-chan *ProviderEvent, adapterErrors chan<- error) { - for response := range commandResponses { - adapter, err := GetAdapter(response.Command.Adapter) + for envelope := range responses { + adapter, err := GetAdapter(envelope.Request.Adapter) if err != nil { adapterErrors <- err continue } - channelID := response.Command.ChannelID - output := strings.Join(response.Output, "\n") - title := response.Title - - if response.Status != 0 || response.Error != nil { - formatted := formatCommandEntryErrorMessage( - response.Command.CommandEntry, - response.Command.Parameters, - output, - ) - - err = adapter.SendErrorMessage(channelID, title, formatted) - } else { - formatted := formatCommandOutput( - response.Command.CommandEntry, - response.Command.Parameters, - output, - ) - - err = adapter.SendMessage(channelID, formatted) + tt := data.Command + if envelope.Data.ExitCode != 0 { + tt = data.CommandError } - if err != nil { + ctx := context.Background() + channelID := envelope.Request.ChannelID + if err := SendEnvelope(ctx, adapter, channelID, envelope, tt); err != nil { adapterErrors <- err } } diff --git a/adapter/discord/adapter.go b/adapter/discord/adapter.go index dc9f775..1321657 100644 --- a/adapter/discord/adapter.go +++ b/adapter/discord/adapter.go @@ -19,13 +19,19 @@ package discord import ( "context" "fmt" + "strconv" "strings" + "time" - "github.com/bwmarrin/discordgo" "github.com/getgort/gort/adapter" "github.com/getgort/gort/data" + "github.com/getgort/gort/templates" + + "github.com/bwmarrin/discordgo" ) +const ZeroWidthSpace = "\u200b" + // NewAdapter will construct a DiscordAdapter instance for a given provider configuration. func NewAdapter(provider data.DiscordProvider) (adapter.Adapter, error) { // Create a new Discord session using the provided bot token. @@ -61,17 +67,6 @@ func (s *Adapter) GetChannelInfo(channelID string) (*adapter.ChannelInfo, error) return newChannelInfoFromDiscordChannel(channel), nil } -func newChannelInfoFromDiscordChannel(channel *discordgo.Channel) *adapter.ChannelInfo { - out := &adapter.ChannelInfo{ - ID: channel.ID, - Name: channel.Name, - } - for _, r := range channel.Recipients { - out.Members = append(out.Members, r.Username) - } - return out -} - // GetName provides the name of this adapter as per the configuration. func (s *Adapter) GetName() string { return s.provider.Name @@ -103,17 +98,6 @@ func (s *Adapter) GetUserInfo(userID string) (*adapter.UserInfo, error) { return newUserInfoFromDiscordUser(u), nil } -func newUserInfoFromDiscordUser(user *discordgo.User) *adapter.UserInfo { - u := &adapter.UserInfo{} - - u.ID = user.ID - u.Name = user.Username - u.DisplayName = user.Avatar - u.DisplayNameNormalized = user.Avatar - u.Email = user.Email - return u -} - // Listen causes the Adapter to initiate a connection to its provider and // begin relaying back events (including errors) via the returned channel. func (s *Adapter) Listen(ctx context.Context) <-chan *adapter.ProviderEvent { @@ -139,6 +123,149 @@ func (s *Adapter) Listen(ctx context.Context) <-chan *adapter.ProviderEvent { return s.events } +// Send the contents of a response envelope to a specified channel. If +// channelID is empty the value of envelope.Request.ChannelID will be used. +func (s *Adapter) Send(ctx context.Context, channelID string, elements templates.OutputElements) error { + var flattened []templates.OutputElement + + for _, e := range elements.Elements { + if section, ok := e.(*templates.Section); ok { + flattened = append(flattened, section.Fields...) + } else { + flattened = append(flattened, e) + } + } + + var err error + var fields []*discordgo.MessageEmbedField + var textOnly = true + + embed := &discordgo.MessageEmbed{Type: discordgo.EmbedTypeRich} + + for _, e := range flattened { + switch t := e.(type) { + case *templates.Divider: + // Discord dividers are just empty text fields. + fields = append(fields, &discordgo.MessageEmbedField{ + Name: ZeroWidthSpace, Value: ZeroWidthSpace, + }) + + case *templates.Image: + if t.Thumbnail { + img := &discordgo.MessageEmbedThumbnail{URL: t.URL} + if t.Height != 0 { + img.Height = t.Height + } + if t.Width != 0 { + img.Width = t.Width + } + embed.Thumbnail = img + } else { + img := &discordgo.MessageEmbedImage{URL: t.URL} + if t.Height != 0 { + img.Height = t.Height + } + if t.Width != 0 { + img.Width = t.Width + } + embed.Image = img + } + textOnly = false + + case *templates.Header: + elements.Color = strings.TrimPrefix(t.Color, "#") + elements.Title = t.Title + textOnly = false + + case *templates.Section: + // Ignore sections entirely in Discord. + + case *templates.Alt: + // Ignore Alt, only rendered as fallback + + case *templates.Text: + var title = t.Title + var text = t.Text + + if title == "" { + title = ZeroWidthSpace + } + if text == "" { + text = ZeroWidthSpace + } + if t.Monospace { + text = fmt.Sprintf("```%s```", text) + } + + fields = append(fields, &discordgo.MessageEmbedField{ + Name: title, + Value: text, + Inline: t.Inline, + }) + + default: + return fmt.Errorf("%T fields are not yet supported by Gort for Discord", e) + } + } + + if elements.Color == "" && elements.Title == "" && textOnly { + var text string + + if len(fields) > 0 { + text = fields[0].Value + } + + for i := 1; i < len(fields); i++ { + text += "\n" + fields[i].Value + } + + _, err = s.session.ChannelMessageSend(channelID, text) + } else { + var color uint64 + + if elements.Color != "" { + color, err = strconv.ParseUint(strings.Replace(elements.Color, "#", "", 1), 16, 64) + if err != nil { + return fmt.Errorf("badly-formatted color code: %q", elements.Color) + } + } + + embed.Color = int(color) + embed.Title = elements.Title + embed.Fields = fields + + _, err = s.session.ChannelMessageSendEmbed(channelID, embed) + } + + return err +} + +// SendText sends a simple text message to the specified channel. +func (s *Adapter) SendText(ctx context.Context, channelID string, message string) error { + _, err := s.session.ChannelMessageSend(channelID, message) + return err +} + +// SendError is a break-glass error message function that's used when the +// templating function fails somehow. Obviously, it does not utilize the +// templating engine. +func (s *Adapter) SendError(ctx context.Context, channelID string, title string, err error) error { + if title == "" { + title = "Unhandled Error" + } + + embed := &discordgo.MessageEmbed{ + Color: 0xFF0000, + Title: title, + Timestamp: time.Now().Format(time.RFC3339), + Type: discordgo.EmbedTypeRich, + Fields: []*discordgo.MessageEmbedField{{Name: title, Value: err.Error()}}, + } + + _, err = s.session.ChannelMessageSendEmbed(channelID, embed) + return err +} + // This function will be called (due to AddHandler above) every time a new // message is created on any channel that the authenticated bot has access to. func (s *Adapter) messageCreate(sess *discordgo.Session, m *discordgo.MessageCreate) { @@ -217,22 +344,24 @@ func (s *Adapter) wrapEvent(eventType adapter.EventType, data interface{}) *adap } } -// SendErrorMessage sends an error message to a specified channel. -// TODO Create a MessageBuilder at some point to replace this. -func (s *Adapter) SendErrorMessage(channelID string, title string, text string) error { - _, err := s.session.ChannelMessageSend(channelID, fmt.Sprintf("%v\n%v", title, text)) - if err != nil { - return err +func newChannelInfoFromDiscordChannel(channel *discordgo.Channel) *adapter.ChannelInfo { + out := &adapter.ChannelInfo{ + ID: channel.ID, + Name: channel.Name, + } + for _, r := range channel.Recipients { + out.Members = append(out.Members, r.Username) } - return nil + return out } -// SendMessage sends a standard output message to a specified channel. -// TODO Create a MessageBuilder at some point to replace this. -func (s *Adapter) SendMessage(channelID string, message string) error { - _, err := s.session.ChannelMessageSend(channelID, message) - if err != nil { - return err - } - return nil +func newUserInfoFromDiscordUser(user *discordgo.User) *adapter.UserInfo { + u := &adapter.UserInfo{} + + u.ID = user.ID + u.Name = user.Username + u.DisplayName = user.Avatar + u.DisplayNameNormalized = user.Avatar + u.Email = user.Email + return u } diff --git a/adapter/slack/adapter.go b/adapter/slack/adapter.go index 23b10b6..5c4057b 100644 --- a/adapter/slack/adapter.go +++ b/adapter/slack/adapter.go @@ -17,10 +17,14 @@ package slack import ( + "context" + "fmt" "regexp" "github.com/getgort/gort/adapter" "github.com/getgort/gort/data" + "github.com/getgort/gort/templates" + log "github.com/sirupsen/logrus" "github.com/slack-go/slack" "github.com/slack-go/slack/socketmode" @@ -39,7 +43,7 @@ func NewAdapter(provider data.SlackProvider) adapter.Adapter { client := slack.New(provider.APIToken) rtm := client.NewRTM() - return ClassicAdapter{ + return &ClassicAdapter{ client: client, provider: provider, rtm: rtm, @@ -62,16 +66,15 @@ func NewAdapter(provider data.SlackProvider) adapter.Adapter { // ScrubMarkdown removes unnecessary/undesirable Slack markdown (of links, of // example) from text received from Slack. +// TODO(mtitmus) Can this be replaced by using Slack's "verbatim text" option? func ScrubMarkdown(text string) string { // Remove links of the format "" - // if index := linkMarkdownRegexShort.FindStringIndex(text); index != nil { submatch := linkMarkdownRegexShort.FindStringSubmatch(text) text = text[:index[0]] + submatch[1] + text[index[1]:] } // Remove links of the format "" - // if index := linkMarkdownRegexLong.FindStringIndex(text); index != nil { submatch := linkMarkdownRegexLong.FindStringSubmatch(text) text = text[:index[0]] + submatch[1] + text[index[1]:] @@ -79,3 +82,257 @@ func ScrubMarkdown(text string) string { return text } + +// Send the contents of a response envelope to a specified channel. If +// channelID is empty the value of envelope.Request.ChannelID will be used. +func Send(ctx context.Context, client *slack.Client, a adapter.Adapter, channelID string, elements templates.OutputElements) error { + e := log.WithContext(ctx) + + options, err := buildSlackOptions(&elements) + if err != nil { + e.WithError(err).Error("failed to build Slack options") + if err := a.SendError(ctx, channelID, "Slack Option Build Failure", err); err != nil { + e.WithError(err).Error("break-glass send error failure!") + } + return err + } + + _, _, err = client.PostMessage(channelID, options...) + if err != nil { + e.WithError(err).Error("failed to post Slack message") + if err := a.SendError(ctx, channelID, "Slack Message Failure", err); err != nil { + e.WithError(err).Error("break-glass send error failure!") + } + return err + } + + return nil +} + +// SendText sends a text message to a specified channel. +// If channelID is empty the value of envelope.Request.ChannelID will be used. +func SendText(ctx context.Context, client *slack.Client, a adapter.Adapter, channelID string, message string) error { + e := log.WithContext(ctx) + + _, _, err := client.PostMessage(channelID, slack.MsgOptionText(message, false)) + if err != nil { + e.WithError(err).Error("failed to post Slack message") + if err := a.SendError(ctx, channelID, "Slack Message Failure", err); err != nil { + e.WithError(err).Error("break-glass send error failure!") + } + return err + } + + return nil +} + +func SendError(ctx context.Context, client *slack.Client, channelID string, title string, err error) error { + if title == "" { + title = "Unhandled Error" + } + + _, _, e := client.PostMessage( + channelID, + slack.MsgOptionAttachments( + slack.Attachment{ + Title: title, + Text: err.Error(), + Color: "#FF0000", + MarkdownIn: []string{"text"}, + }, + ), + slack.MsgOptionDisableMediaUnfurl(), + slack.MsgOptionDisableMarkdown(), + slack.MsgOptionAsUser(false), + ) + + return e +} + +// buildSlackOptions accepts a templates.OutputElements value produced by +// templates.EncodeElements and produces a roughly equivalent []slack.MsgOption +// value. It's used directly by the two adapter implementations. +func buildSlackOptions(elements *templates.OutputElements) ([]slack.MsgOption, error) { + options := []slack.MsgOption{ + slack.MsgOptionDisableMediaUnfurl(), + slack.MsgOptionAsUser(false), + } + + var blocks []slack.Block + var headerBlock *slack.SectionBlock + var currentSection *slack.SectionBlock + + for _, e := range elements.Elements { + switch t := e.(type) { + case *templates.Divider: + blocks = append(blocks, slack.NewDividerBlock()) + + case *templates.Header: + elements.Color = t.Color + elements.Title = t.Title + + if t.Title != "" { + text := &templates.Text{Markdown: true, Text: fmt.Sprintf("*%s*", t.Title)} + + if textBlock, err := buildTextBlockObject(text); err != nil { + return nil, err + } else { + headerBlock = slack.NewSectionBlock(textBlock, nil, nil) + } + } + + case *templates.Image: + if t.Thumbnail && currentSection == nil { + currentSection = slack.NewSectionBlock(nil, nil, nil) + blocks = append(blocks, currentSection) + } + + if t.Thumbnail { + if currentSection.Accessory == nil { + currentSection.Accessory = &slack.Accessory{} + } + currentSection.Accessory.ImageElement = slack.NewImageBlockElement(t.URL, "alt-text") + } else { + currentSection = nil + blocks = append(blocks, slack.NewImageBlock(t.URL, "alt-text", "", nil)) + } + + case *templates.Section: + currentSection = slack.NewSectionBlock(nil, nil, nil) + blocks = append(blocks, currentSection) + + for _, tf := range t.Fields { + switch t := tf.(type) { + case *templates.Text: + tbo, err := buildTextBlockObject(t) + if err != nil { + return nil, err + } + + if currentSection == nil { + currentSection = slack.NewSectionBlock(nil, nil, nil) + blocks = append(blocks, currentSection) + } + + if t.Inline && len(currentSection.Fields) == 0 { + currentSection.Fields = []*slack.TextBlockObject{ + slack.NewTextBlockObject("mrkdwn", t.Title, false, false), + tbo, + } + } else if t.Inline && len(currentSection.Fields) > 0 { + m := len(currentSection.Fields) / 2 + currentSection.Fields = append(currentSection.Fields[:m+1], currentSection.Fields[m:]...) + currentSection.Fields[m] = slack.NewTextBlockObject("mrkdwn", t.Title, false, false) + currentSection.Fields = append(currentSection.Fields, tbo) + } else if currentSection.Text == nil { + if t.Title != "" { + tbo.Text = fmt.Sprintf("*%s*\n%s", t.Title, tbo.Text) + } + + currentSection.Text = tbo + } else { + currentSection.Text.Text += "\n" + t.Text + } + + case *templates.Image: + if currentSection.Accessory == nil { + currentSection.Accessory = &slack.Accessory{} + } + currentSection.Accessory.ImageElement = slack.NewImageBlockElement(t.URL, "alt-text") + default: + return nil, fmt.Errorf("%T elements are not supported inside a Section for Slack", e) + } + } + + currentSection = nil + + case *templates.Text: + if currentSection == nil { + currentSection = slack.NewSectionBlock(nil, nil, nil) + blocks = append(blocks, currentSection) + } + + tbo, err := buildTextBlockObject(t) + if err != nil { + return nil, err + } + + if t.Inline && len(currentSection.Fields) == 0 { + title := fmt.Sprintf("*%s*", t.Title) + currentSection.Fields = []*slack.TextBlockObject{ + slack.NewTextBlockObject("mrkdwn", title, false, false), tbo, + } + } else if t.Inline && len(currentSection.Fields) > 0 { + title := fmt.Sprintf("*%s*", t.Title) + m := len(currentSection.Fields) / 2 + currentSection.Fields = append(currentSection.Fields[:m+1], currentSection.Fields[m:]...) + currentSection.Fields[m] = slack.NewTextBlockObject("mrkdwn", title, false, false) + currentSection.Fields = append(currentSection.Fields, tbo) + } else if currentSection.Text == nil { + currentSection.Text = tbo + } else { + currentSection.Text.Text += "\n" + t.Text + } + + case *templates.Alt: + // Ignore Alt, only rendered as fallback + + default: + return nil, fmt.Errorf("%T elements are not yet supported by Gort for Slack", e) + } + } + + // Slack attachments are funny. If you try to use a default one you get + // an error. Also if you try to set a title and use blocks you get an + // error. Therefore we only use one if we have + + // If there's no color set, we just use blocks and no attachment options. + + if elements.Color == "" { + if headerBlock != nil { + blocks = append([]slack.Block{headerBlock}, blocks...) + } + options = append(options, slack.MsgOptionBlocks(blocks...)) + + return options, nil + } + + // We CAN use an attachment to set a message color (without a title) and + // still use blocks. Let's do that. + + if headerBlock != nil { + blocks = append([]slack.Block{headerBlock}, blocks...) + } + attachment := slack.Attachment{ + Color: elements.Color, + Blocks: slack.Blocks{BlockSet: blocks}, + } + options = append(options, slack.MsgOptionAttachments(attachment)) + + return options, nil +} + +// buildTextBlockObject accepts a templates.Text value, does some basic error +// correction to satisty the very tempermental Slack API, and returns an +// equivalent slack.TextBlockObject. It produces an error if the resulting +// TextBlockObject is not valid (according to TextBlockObject.Validate()) +func buildTextBlockObject(t *templates.Text) (*slack.TextBlockObject, error) { + var textType string + var emoji = t.Emoji + + if t.Markdown { + textType = "mrkdwn" + emoji = false + } else { + textType = "plain_text" + } + + txt := t.Text + if t.Monospace { + txt = fmt.Sprintf("```%s```", txt) + } + + tbo := slack.NewTextBlockObject(textType, txt, emoji, false) + + return tbo, tbo.Validate() +} diff --git a/adapter/slack/classic_adapter.go b/adapter/slack/classic_adapter.go index 47e8a0d..d33dc7b 100644 --- a/adapter/slack/classic_adapter.go +++ b/adapter/slack/classic_adapter.go @@ -25,6 +25,7 @@ import ( "github.com/getgort/gort/adapter" "github.com/getgort/gort/data" "github.com/getgort/gort/telemetry" + "github.com/getgort/gort/templates" log "github.com/sirupsen/logrus" "github.com/slack-go/slack" ) @@ -83,58 +84,6 @@ func (s ClassicAdapter) GetUserInfo(userID string) (*adapter.UserInfo, error) { return newUserInfoFromSlackUser(u), nil } -func getFields(name string, i interface{}) map[string]interface{} { - merge := func(m, n map[string]interface{}) map[string]interface{} { - for k, v := range n { - m[k] = v - } - return m - } - - value := reflect.ValueOf(i) - - if value.IsZero() { - return map[string]interface{}{} - } - - switch value.Kind() { - case reflect.Interface: - fallthrough - - case reflect.Ptr: - if value.IsNil() { - return map[string]interface{}{} - } - - v := value.Elem() - if !v.CanInterface() { - return map[string]interface{}{} - } - - return getFields(name, v.Interface()) - - case reflect.Struct: - m := map[string]interface{}{} - t := value.Type() - - for i := 0; i < t.NumField(); i++ { - fv := value.Field(i) - fn := t.Field(i).Name - - if !fv.CanInterface() { - continue - } - - m = merge(m, getFields(strings.ToLower(fn), fv.Interface())) - } - - return m - - default: - return map[string]interface{}{name: i} - } -} - // Listen instructs the relay to begin listening to the provider that it's attached to. // It exits immediately, returning a channel that emits ProviderEvents. func (s ClassicAdapter) Listen(ctx context.Context) <-chan *adapter.ProviderEvent { @@ -285,6 +234,24 @@ func (s ClassicAdapter) Listen(ctx context.Context) <-chan *adapter.ProviderEven return events } +// Send the contents of a response envelope to a specified channel. If +// channelID is empty the value of envelope.Request.ChannelID will be used. +func (s *ClassicAdapter) Send(ctx context.Context, channelID string, elements templates.OutputElements) error { + return Send(ctx, s.client, s, channelID, elements) +} + +// SendText sends a simple text message to the specified channel. +func (s *ClassicAdapter) SendText(ctx context.Context, channelID string, message string) error { + return SendText(ctx, s.client, s, channelID, message) +} + +// SendError is a break-glass error message function that's used when the +// templating function fails somehow. Obviously, it does not utilize the +// templating engine. +func (s *ClassicAdapter) SendError(ctx context.Context, channelID string, title string, err error) error { + return SendError(ctx, s.client, channelID, title, err) +} + // onChannelMessage is called when the Slack API emits an MessageEvent for a message in a channel. func (s *ClassicAdapter) onChannelMessage(event *slack.MessageEvent, info *adapter.Info) *adapter.ProviderEvent { return s.wrapEvent( @@ -386,48 +353,6 @@ func (s *ClassicAdapter) onRTMError(event *slack.RTMError, info *adapter.Info) * ) } -// SendMessage will send a message (from the bot) into the specified channel. -func (s ClassicAdapter) SendMessage(channelID string, text string) error { - _, _, err := s.rtm.PostMessage( - channelID, - slack.MsgOptionDisableMediaUnfurl(), - slack.MsgOptionAsUser(false), - slack.MsgOptionUsername(s.provider.BotName), - slack.MsgOptionText(text, false), - slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{ - IconURL: s.provider.IconURL, - Markdown: true, - }), - ) - - return err -} - -// SendErrorMessage will send a message (from the bot) into the specified channel. -func (s ClassicAdapter) SendErrorMessage(channelID string, title string, text string) error { - _, _, err := s.rtm.PostMessage( - channelID, - slack.MsgOptionAttachments( - slack.Attachment{ - Title: title, - Text: text, - Color: "#FF0000", - MarkdownIn: []string{"text"}, - }, - ), - slack.MsgOptionDisableMediaUnfurl(), - slack.MsgOptionDisableMarkdown(), - slack.MsgOptionAsUser(false), - slack.MsgOptionUsername(s.provider.BotName), - slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{ - IconURL: s.provider.IconURL, - Markdown: true, - }), - ) - - return err -} - // wrapEvent creates a new ProviderEvent instance with metadata and the Event data attached. func (s *ClassicAdapter) wrapEvent(eventType adapter.EventType, info *adapter.Info, data interface{}) *adapter.ProviderEvent { return &adapter.ProviderEvent{ @@ -437,3 +362,55 @@ func (s *ClassicAdapter) wrapEvent(eventType adapter.EventType, info *adapter.In Adapter: s, } } + +func getFields(name string, i interface{}) map[string]interface{} { + merge := func(m, n map[string]interface{}) map[string]interface{} { + for k, v := range n { + m[k] = v + } + return m + } + + value := reflect.ValueOf(i) + + if value.IsZero() { + return map[string]interface{}{} + } + + switch value.Kind() { + case reflect.Interface: + fallthrough + + case reflect.Ptr: + if value.IsNil() { + return map[string]interface{}{} + } + + v := value.Elem() + if !v.CanInterface() { + return map[string]interface{}{} + } + + return getFields(name, v.Interface()) + + case reflect.Struct: + m := map[string]interface{}{} + t := value.Type() + + for i := 0; i < t.NumField(); i++ { + fv := value.Field(i) + fn := t.Field(i).Name + + if !fv.CanInterface() { + continue + } + + m = merge(m, getFields(strings.ToLower(fn), fv.Interface())) + } + + return m + + default: + return map[string]interface{}{name: i} + } +} diff --git a/adapter/slack/socketmode_adapter.go b/adapter/slack/socketmode_adapter.go index 10bab8f..c35fca2 100644 --- a/adapter/slack/socketmode_adapter.go +++ b/adapter/slack/socketmode_adapter.go @@ -23,6 +23,8 @@ import ( "github.com/getgort/gort/adapter" "github.com/getgort/gort/data" "github.com/getgort/gort/telemetry" + "github.com/getgort/gort/templates" + log "github.com/sirupsen/logrus" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" @@ -205,6 +207,24 @@ func (s *SocketModeAdapter) Listen(ctx context.Context) <-chan *adapter.Provider return events } +// Send the contents of a response envelope to a specified channel. If +// channelID is empty the value of envelope.Request.ChannelID will be used. +func (s *SocketModeAdapter) Send(ctx context.Context, channelID string, elements templates.OutputElements) error { + return Send(ctx, s.client, s, channelID, elements) +} + +// SendText sends a simple text message to the specified channel. +func (s *SocketModeAdapter) SendText(ctx context.Context, channelID string, message string) error { + return SendText(ctx, s.client, s, channelID, message) +} + +// SendError is a break-glass error message function that's used when the +// templating function fails somehow. Obviously, it does not utilize the +// templating engine. +func (s *SocketModeAdapter) SendError(ctx context.Context, channelID string, title string, err error) error { + return SendError(ctx, s.client, channelID, title, err) +} + // onChannelMessage is called when the Slack API emits an MessageEvent for a message in a channel. func (s *SocketModeAdapter) onChannelMessage(event *slackevents.MessageEvent, info *adapter.Info) *adapter.ProviderEvent { return s.wrapEvent( @@ -278,48 +298,3 @@ func (s *SocketModeAdapter) wrapEvent(eventType adapter.EventType, info *adapter Adapter: s, } } - -// SendErrorMessage sends an error message to a specified channel. -// TODO Create a MessageBuilder at some point to replace this. -func (s *SocketModeAdapter) SendErrorMessage(channelID string, title string, text string) error { - _, _, err := s.client.PostMessage( - channelID, - slack.MsgOptionAttachments( - slack.Attachment{ - Title: title, - Text: text, - Color: "#FF0000", - MarkdownIn: []string{"text"}, - }, - ), - slack.MsgOptionDisableMediaUnfurl(), - slack.MsgOptionDisableMarkdown(), - slack.MsgOptionAsUser(false), - slack.MsgOptionUsername(s.provider.BotName), - slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{ - IconURL: s.provider.IconURL, - Markdown: true, - }), - ) - if err != nil { - return err - } - return nil -} - -// SendMessage sends a standard output message to a specified channel. -// TODO Create a MessageBuilder at some point to replace this. -func (s *SocketModeAdapter) SendMessage(channelID string, message string) error { - _, _, err := s.client.PostMessage(channelID, slack.MsgOptionDisableMediaUnfurl(), - slack.MsgOptionAsUser(false), - slack.MsgOptionUsername(s.provider.BotName), - slack.MsgOptionText(message, false), - slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{ - IconURL: s.provider.IconURL, - Markdown: true, - })) - if err != nil { - return err - } - return nil -} diff --git a/bundles/bundle_test.go b/bundles/bundle_test.go index b075191..9e792ac 100644 --- a/bundles/bundle_test.go +++ b/bundles/bundle_test.go @@ -35,9 +35,14 @@ func TestLoadBundleFromFile(t *testing.T) { assert.Equal(t, "A test bundle.", b.Description) assert.Equal(t, "This is test bundle.\nThere are many like it, but this one is mine.", b.LongDescription) assert.Len(t, b.Permissions, 1) - assert.Equal(t, "ubuntu", b.Docker.Image) - assert.Equal(t, "20.04", b.Docker.Tag) - assert.Len(t, b.Commands, 1) + assert.Equal(t, "ubuntu:20.04", b.Image) + assert.Len(t, b.Commands, 4) + + // Bundle templates + assert.Equal(t, "Template:Bundle:CommandError", b.Templates.CommandError) + assert.Equal(t, "Template:Bundle:Command", b.Templates.Command) + assert.Equal(t, "Template:Bundle:MessageError", b.Templates.MessageError) + assert.Equal(t, "Template:Bundle:Message", b.Templates.Message) cmd := b.Commands["echox"] assert.Equal(t, "echox", cmd.Name) @@ -49,4 +54,10 @@ Usage: assert.Equal(t, []string{"/bin/echo"}, cmd.Executable) assert.Len(t, cmd.Rules, 1) assert.Equal(t, "must have test:echox", cmd.Rules[0]) + + // Command templates + assert.Equal(t, "Template:Command:CommandError", cmd.Templates.CommandError) + assert.Equal(t, "Template:Command:Command", cmd.Templates.Command) + assert.Equal(t, "Template:Command:MessageError", cmd.Templates.MessageError) + assert.Equal(t, "Template:Command:Message", cmd.Templates.Message) } diff --git a/bundles/bundles.go b/bundles/bundles.go index 58ca446..202ab15 100644 --- a/bundles/bundles.go +++ b/bundles/bundles.go @@ -24,6 +24,7 @@ import ( "github.com/getgort/gort/data" gerrs "github.com/getgort/gort/errors" + yaml "gopkg.in/yaml.v3" ) @@ -35,10 +36,10 @@ var ( func LoadBundleFromFile(file string) (data.Bundle, error) { f, err := os.Open(file) - defer f.Close() if err != nil { return data.Bundle{}, err } + defer f.Close() return LoadBundle(f) } diff --git a/bundles/default.yml b/bundles/default.yml index 9cb601a..4ddc991 100644 --- a/bundles/default.yml +++ b/bundles/default.yml @@ -3,6 +3,7 @@ gort_bundle_version: 1 name: gort version: 0.0.1 + author: Matt Titmus homepage: https://guide.getgort.io description: The default command bundle. @@ -17,9 +18,7 @@ permissions: - manage_roles - manage_users -docker: - image: getgort/gort - tag: {{.Version}} +image: getgort/gort:{{.Version}} commands: bundle: @@ -138,3 +137,14 @@ commands: executable: [ "/bin/gort", "hidden", "command" ] rules: - allow + + whoami: + description: "Provides your basic identity and account information" + long_description: |- + Provides your basic identity and account information. + + Usage: + gort:whoami + executable: [ "/bin/gort", "hidden", "whoami" ] + rules: + - allow diff --git a/cli/bootstrap.go b/cli/bootstrap.go index 22325f8..c70c156 100644 --- a/cli/bootstrap.go +++ b/cli/bootstrap.go @@ -43,8 +43,9 @@ bootstrapped. This can be overridden using the -P or --profile flags.` gort bootstrap [flags] [URL] Flags: - -i, --allow-insecure Permit http URLs to be used - -h, --help help for bootstrap + -i, --allow-insecure Permit http URLs to be used + -F, --force-overwrite Overwrite the profile if it already exists + -h, --help help for bootstrap Global Flags: -P, --profile string The Gort profile within the config file to use @@ -52,7 +53,8 @@ Global Flags: ) var ( - flagBootstrapAllowInsecure bool + flagBootstrapAllowInsecure bool + flagBootstrapOverwriteProfile bool ) // GetBootstrapCmd bootstrap @@ -66,6 +68,7 @@ func GetBootstrapCmd() *cobra.Command { } cmd.Flags().BoolVarP(&flagBootstrapAllowInsecure, "allow-insecure", "i", false, "Permit http URLs to be used") + cmd.Flags().BoolVarP(&flagBootstrapOverwriteProfile, "force-overwrite", "F", false, "Overwrite the profile if it already exists") cmd.SetUsageTemplate(bootstrapUsage) @@ -86,12 +89,12 @@ func bootstrapCmd(cmd *cobra.Command, args []string) error { // Client Bootstrap will create the gort config if necessary, and append // the new credentials to it. - user, err := gortClient.Bootstrap() + user, err := gortClient.Bootstrap(flagBootstrapOverwriteProfile) if err != nil { return err } - fmt.Printf("User %q created and credentials appended to gort config.\n", user.Username) + fmt.Printf("User %q created and credentials appended to Gort config.\n", user.Username) return nil } diff --git a/cli/bundle-enable.go b/cli/bundle-enable.go index 422e7a2..1775fb5 100644 --- a/cli/bundle-enable.go +++ b/cli/bundle-enable.go @@ -20,6 +20,7 @@ import ( "fmt" "github.com/getgort/gort/client" + "github.com/spf13/cobra" ) @@ -51,7 +52,7 @@ func GetBundleEnableCmd() *cobra.Command { Short: bundleEnableShort, Long: bundleEnableLong, RunE: bundleEnableCmd, - Args: cobra.ExactArgs(2), + Args: cobra.RangeArgs(1, 2), } cmd.SetUsageTemplate(bundleEnableUsage) @@ -60,14 +61,23 @@ func GetBundleEnableCmd() *cobra.Command { } func bundleEnableCmd(cmd *cobra.Command, args []string) error { - bundleName := args[0] - bundleVersion := args[1] + var bundleName = args[0] + var bundleVersion string c, err := client.Connect(FlagGortProfile) if err != nil { return err } + if len(args) > 1 { + bundleVersion = args[1] + } else { + bundleVersion, err = findLatestVersion(c, bundleName) + if err != nil { + return err + } + } + err = c.BundleEnable(bundleName, bundleVersion) if err != nil { return err @@ -77,3 +87,16 @@ func bundleEnableCmd(cmd *cobra.Command, args []string) error { return nil } + +func findLatestVersion(c *client.GortClient, bundleName string) (string, error) { + bb, err := c.BundleListVersions(bundleName) + if err != nil { + return "", err + } + + if len(bb) == 0 { + return "", nil + } + + return bb[len(bb)-1].Version, nil +} diff --git a/cli/bundle-info.go b/cli/bundle-info.go index 8432a47..8b3ec89 100644 --- a/cli/bundle-info.go +++ b/cli/bundle-info.go @@ -18,6 +18,7 @@ package cli import ( "fmt" + "sort" "strings" "github.com/getgort/gort/client" @@ -85,30 +86,33 @@ func doBundleInfoAll(name string) error { return err } - var enabled *data.Bundle - var versions = make([]string, 0) + var enabled data.Bundle + var versions []string for _, bundle := range bundles { versions = append(versions, bundle.Version) if bundle.Enabled { - enabled = &bundle + enabled = bundle } } fmt.Printf("Name: %s\n", name) fmt.Printf("Versions: %s\n", strings.Join(versions, ", ")) - if enabled != nil { + if enabled.Version != "" { fmt.Println("Status: Enabled") fmt.Printf("Enabled Version: %s\n", enabled.Version) - commands := make([]string, 0) + var commands []string for name := range enabled.Commands { commands = append(commands, name) } + sort.Strings(commands) fmt.Printf("Commands: %s\n", strings.Join(commands, ", ")) + + sort.Strings(enabled.Permissions) fmt.Printf("Permissions: %s\n", strings.Join(enabled.Permissions, ", ")) } else { fmt.Println("Status: Disabled") @@ -134,7 +138,7 @@ func doBundleInfoVersion(name, version string) error { if bundle.Enabled { fmt.Println("Status: Enabled") } else { - fmt.Println("Status: Enabled") + fmt.Println("Status: Not Enabled") } commands := make([]string, 0) diff --git a/cli/hidden-command.go b/cli/hidden-command.go index 7200e71..126a456 100644 --- a/cli/hidden-command.go +++ b/cli/hidden-command.go @@ -86,8 +86,7 @@ func detailCommand(gortClient *client.GortClient, command string) error { } else if len(ss) == 1 { cmdName = ss[0] } else { - fmt.Println("Invalid command syntax: expected or .") - return nil + return fmt.Errorf("invalid command syntax: expected or ") } var found bool @@ -125,8 +124,7 @@ func detailCommand(gortClient *client.GortClient, command string) error { } if !found { - fmt.Printf("Command not found: %v\n", command) - return nil + return fmt.Errorf("command not found: %v", command) } return nil diff --git a/cli/hidden-whoami.go b/cli/hidden-whoami.go new file mode 100644 index 0000000..9845d99 --- /dev/null +++ b/cli/hidden-whoami.go @@ -0,0 +1,79 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +const ( + hiddenWhoamiUse = "whoami" + hiddenWhoamiShort = "Provides your basic identity and account information" + hiddenWhoamiLong = `Provides your basic identity and account information.` + hiddenWhoamiUsage = `Usage: + !gort:whoami + + Flags: + -h, --help Show this message and exit + ` +) + +// GetHiddenWhoamiCmd is a command +func GetHiddenWhoamiCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: hiddenWhoamiUse, + Short: hiddenWhoamiShort, + Long: hiddenWhoamiLong, + RunE: hiddenWhoamiCmd, + SilenceUsage: true, + } + + cmd.SetUsageTemplate(hiddenWhoamiUsage) + + return cmd +} + +func hiddenWhoamiCmd(cmd *cobra.Command, args []string) error { + if _, ok := os.LookupEnv("GORT_SERVICE_TOKEN"); !ok { + return fmt.Errorf("whoami can only be run from chat") + } + + var adapter, chatUserID, gortUser string + + if adapter = os.Getenv("GORT_ADAPTER"); adapter == "" { + adapter = "*UNDEFINED!*" + } + + if chatUserID = os.Getenv("GORT_CHAT_ID"); chatUserID == "" { + chatUserID = "*UNDEFINED!*" + } + + if gortUser = os.Getenv("GORT_USER"); gortUser == "" { + gortUser = "*UNDEFINED!*" + } + + tmpl := `Adapter: %s +User ID: %s +Mapped to: %s +` + + fmt.Printf(tmpl, adapter, chatUserID, gortUser) + return nil +} diff --git a/cli/hidden.go b/cli/hidden.go index e3129af..6dc0279 100644 --- a/cli/hidden.go +++ b/cli/hidden.go @@ -36,6 +36,7 @@ func GetHiddenCmd() *cobra.Command { } cmd.AddCommand(GetHiddenCommandCmd()) + cmd.AddCommand(GetHiddenWhoamiCmd()) return cmd } diff --git a/cli/user-info.go b/cli/user-info.go index f4d0d20..ac82b6f 100644 --- a/cli/user-info.go +++ b/cli/user-info.go @@ -96,7 +96,7 @@ Groups %s "user to one or more chat provider IDs.") } else { var keys []string - for k, _ := range user.Mappings { + for k := range user.Mappings { keys = append(keys, k) } sort.Strings(keys) diff --git a/client/client-auth.go b/client/client-auth.go index ee41a03..1446c8e 100644 --- a/client/client-auth.go +++ b/client/client-auth.go @@ -23,6 +23,7 @@ import ( "io/ioutil" "net/http" "os" + "strings" "time" "github.com/getgort/gort/data/rest" @@ -51,8 +52,12 @@ func (c *GortClient) Authenticate() (rest.Token, error) { return rest.Token{}, gerrs.Wrap(gerrs.ErrMarshal, err) } - resp, err := http.Post(endpointURL, "application/json", bytes.NewBuffer(postBytes)) - if err != nil { + resp, err := c.client.Post(endpointURL, "application/json", bytes.NewBuffer(postBytes)) + switch { + case err == nil: + case strings.Contains(err.Error(), "certificate"): + return rest.Token{}, fmt.Errorf("self-signed certificate detected: use --allow-insecure to proceed (not recommended)") + default: return rest.Token{}, gerrs.Wrap(ErrConnectionFailed, err) } @@ -116,7 +121,7 @@ func (c *GortClient) Authenticated() (bool, error) { } // Bootstrap calls the POST /v2/bootstrap endpoint. -func (c *GortClient) Bootstrap() (rest.User, error) { +func (c *GortClient) Bootstrap(overwrite bool) (rest.User, error) { endpointURL := fmt.Sprintf("%s/v2/bootstrap", c.profile.URL) // Get profile data so we can update it afterwards @@ -125,7 +130,7 @@ func (c *GortClient) Bootstrap() (rest.User, error) { return rest.User{}, err } - if _, exists := profile.Profiles[c.profile.Name]; exists { + if _, exists := profile.Profiles[c.profile.Name]; exists && !overwrite { return rest.User{}, fmt.Errorf("profile %s already exists", c.profile.Name) } @@ -134,8 +139,12 @@ func (c *GortClient) Bootstrap() (rest.User, error) { return rest.User{}, gerrs.Wrap(gerrs.ErrMarshal, err) } - resp, err := http.Post(endpointURL, "application/json", bytes.NewBuffer(postBytes)) - if err != nil { + resp, err := c.client.Post(endpointURL, "application/json", bytes.NewBuffer(postBytes)) + switch { + case err == nil: + case strings.Contains(err.Error(), "certificate"): + return rest.User{}, fmt.Errorf("self-signed certificate detected: use --allow-insecure to proceed (not recommended)") + default: return rest.User{}, gerrs.Wrap(ErrConnectionFailed, err) } defer resp.Body.Close() @@ -179,6 +188,11 @@ func (c *GortClient) Bootstrap() (rest.User, error) { return user, err } + // Delete any old tokens that may be laying around + if err := c.deleteHostToken(); err != nil { + return user, nil + } + return user, nil } diff --git a/client/client-bundle.go b/client/client-bundle.go index 2660f91..b9e2e96 100644 --- a/client/client-bundle.go +++ b/client/client-bundle.go @@ -21,6 +21,7 @@ import ( "fmt" "io/ioutil" "net/http" + "sort" "github.com/getgort/gort/data" ) @@ -139,6 +140,9 @@ func (c *GortClient) BundleListVersions(bundlename string) ([]data.Bundle, error return []data.Bundle{}, err } + // Always sort by version + sort.Slice(bundles, func(i, j int) bool { return bundles[i].Semver().LessThan(bundles[j].Semver()) }) + return bundles, nil } diff --git a/client/client.go b/client/client.go index 50a1960..f925802 100644 --- a/client/client.go +++ b/client/client.go @@ -18,6 +18,7 @@ package client import ( "bytes" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -64,6 +65,7 @@ var ( // GortClient comments to be written... type GortClient struct { + client *http.Client profile ProfileEntry token *rest.Token } @@ -100,14 +102,16 @@ func (c Error) Status() uint { func Connect(profileName string) (*GortClient, error) { // If the GORT_SERVICE_TOKEN envvar is set, use that first. if te, exists := os.LookupEnv("GORT_SERVICE_TOKEN"); exists { - entry := ProfileEntry{URLString: os.Getenv("GORT_SERVICES_ROOT")} + entry := ProfileEntry{ + URLString: os.Getenv("GORT_SERVICES_ROOT"), + AllowInsecure: true, // TODO(mtitmus) Fine for now, but maybe fix this later? + } url, err := parseHostURL(entry.URLString) if err != nil { return nil, err } - entry.AllowInsecure = url.Scheme == "http" entry.URL = url client, err := NewClient(entry) @@ -176,11 +180,39 @@ func NewClient(entry ProfileEntry) (*GortClient, error) { return nil, ErrInsecureURL } + client := &http.Client{ + Timeout: time.Second * 10, + } + + if entry.AllowInsecure { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + } + return &GortClient{ + client: client, profile: entry, }, nil } +// deleteHostToken attempts to delete an existing token file. +func (c *GortClient) deleteHostToken() error { + tokenFileName, err := c.getGortTokenFilename() + if err != nil { + return gerrs.Wrap(gerrs.ErrIO, err) + } + + // File doesn't exist. Not an error. + if _, err := os.Stat(tokenFileName); err != nil { + return nil + } + + return os.Remove(tokenFileName) +} + func (c *GortClient) doRequest(method string, url string, body []byte) (*http.Response, error) { token, err := c.Token() if err != nil { @@ -193,9 +225,12 @@ func (c *GortClient) doRequest(method string, url string, body []byte) (*http.Re } req.Header.Add("X-Session-Token", token.Token) - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { + resp, err := c.client.Do(req) + switch { + case err == nil: + case strings.Contains(err.Error(), "certificate"): + return nil, fmt.Errorf("self-signed certificate detected: use --allow-insecure to proceed (not recommended)") + default: return nil, gerrs.Wrap(ErrConnectionFailed, err) } @@ -323,7 +358,7 @@ func parseHostURL(serverURLArg string) (*url.URL, error) { return nil, gerrs.Wrap(gerrs.ErrIO, err) } if !matches { - serverURLString = "http://" + serverURLString + serverURLString = "https://" + serverURLString } // Parse the resulting URL diff --git a/config.yml b/config.yml index c4d07e3..3843338 100644 --- a/config.yml +++ b/config.yml @@ -18,7 +18,7 @@ gort: # scheme (either http or https), a host, an optional port (defaulting to 80 # for http and 443 for https), and an optional path. # Defaults to localhost - api_url_base: localhost + api_url_base: https://gort:4000 # Enables development mode. Currently this only affects log output format. # Defaults to false @@ -93,33 +93,31 @@ docker: # The name of a Docker network. If set, any worker containers will be # attached to this network. This can be used to allow workers to communicate # with a containerized Gort controller. - # network: gort_gort + network: gort_gort -jaeger: - # The URL for the Jaeger collector that spans are sent to. If not set then - # no exporter will be created. - endpoint: http://jaeger:14268/api/traces +kubernetes: + # The selectors for Gort's endpoint resource. Used to dynamically find the + # API endpoint. If both are omitted the label selector "app=gort" is used. + endpoint_label_selector: "app=gort,release=gort" + endpoint_field_selector: - # The username to be used in the authorization header sent for all requests - # to the collector. If not set no username will be passed. - username: gort - - # The password to be used in the authorization header sent for all requests - # to the collector. - password: veryKleverPassw0rd! + # The selectors for Gort's pod resource. Used to dynamically find the + # API endpoint. If both are omitted the label selector "app=gort" is used. + pod_field_selector: "app=gort,release=gort" + pod_label_selector: # List of Discord adapters. Delete this section if not using Discord. discord: - # An arbitrary name for human labelling purposes. name: MyWorkspace - # Bot User OAuth Access Token - bot_token: INSERT BOT TOKEN HERE - # The name of the bot, as it appears in Discord. Defaults to the name used # when the bot was added to the account. bot_name: Gort + # Bot User OAuth Access Token + bot_token: INSERT BOT TOKEN HERE + # List of Slack adapters. Delete this section if not using Slack. slack: - # An arbitrary name for human labelling purposes. @@ -139,3 +137,16 @@ slack: # The name of the bot, as it appears in Slack. Defaults to the name used # when the bot was added to the account. bot_name: Gort + +jaeger: + # The URL for the Jaeger collector that spans are sent to. If not set then + # no exporter will be created. + endpoint: http://jaeger:14268/api/traces + + # The username to be used in the authorization header sent for all requests + # to the collector. If not set no username will be passed. + username: gort + + # The password to be used in the authorization header sent for all requests + # to the collector. + password: veryKleverPassw0rd! diff --git a/config/config.go b/config/config.go index 241be3f..7a2ca6b 100644 --- a/config/config.go +++ b/config/config.go @@ -112,6 +112,14 @@ func GetDatabaseConfigs() data.DatabaseConfigs { return config.DatabaseConfigs } +// GetDiscordProviders returns the data wrapper for the "discord" config section. +func GetDiscordProviders() []data.DiscordProvider { + configMutex.RLock() + defer configMutex.RUnlock() + + return config.DiscordProviders +} + // GetDockerConfigs returns the data wrapper for the "docker" config section. func GetDockerConfigs() data.DockerConfigs { configMutex.RLock() @@ -120,6 +128,14 @@ func GetDockerConfigs() data.DockerConfigs { return config.DockerConfigs } +// GetGlobalConfigs returns the data wrapper for the "global" config section. +func GetGlobalConfigs() data.GlobalConfigs { + configMutex.RLock() + defer configMutex.RUnlock() + + return config.GlobalConfigs +} + // GetGortServerConfigs returns the data wrapper for the "gort" config section. func GetGortServerConfigs() data.GortServerConfigs { configMutex.RLock() @@ -136,12 +152,12 @@ func GetJaegerConfigs() data.JaegerConfigs { return config.JaegerConfigs } -// GetGlobalConfigs returns the data wrapper for the "global" config section. -func GetGlobalConfigs() data.GlobalConfigs { +// GetKubernetesConfigs returns the data wrapper for the "jaeger" config section. +func GetKubernetesConfigs() data.KubernetesConfigs { configMutex.RLock() defer configMutex.RUnlock() - return config.GlobalConfigs + return config.KubernetesConfigs } // GetSlackProviders returns the data wrapper for the "slack" config section. @@ -152,12 +168,12 @@ func GetSlackProviders() []data.SlackProvider { return config.SlackProviders } -// GetDiscordProviders returns the data wrapper for the "discord" config section. -func GetDiscordProviders() []data.DiscordProvider { +// GetTemplates returns the deployment-scoped template overrides. +func GetTemplates() data.Templates { configMutex.RLock() defer configMutex.RUnlock() - return config.DiscordProviders + return config.Templates } // Initialize is called by main() to trigger creation of the config singleton. @@ -181,8 +197,50 @@ func IsUndefined(c interface{}) bool { return true } - v := reflect.ValueOf(c) - return v.IsZero() + return reflect.ValueOf(c).IsZero() +} + +// Reload is called by Initialize() to determine whether the config file has +// changed (or is new) and reload if it has. +func Reload() error { + configMutex.Lock() + defer configMutex.Unlock() + + sum, err := getMd5Sum(configFile) + if err != nil { + log.WithField("file", configFile).WithError(err).Error(ErrHashFailure.Error()) + + return gerrs.Wrap(ErrHashFailure, err) + } + + if !slicesAreEqual(sum, md5sum) { + cp, err := load(configFile) + if err != nil { + // If we're already initialized, keep the original config. + // If not, set the state to 'error'. + if CurrentState() == StateConfigUninitialized { + updateConfigState(StateConfigError) + } + + log.WithField("file", configFile).WithError(err).Error(ErrConfigUnloadable.Error()) + + return gerrs.Wrap(ErrConfigUnloadable, err) + } + + md5sum = sum + config = cp + + setLogFormatter() + + // Properly load the database configs. + standardizeDatabaseConfig(&cp.DatabaseConfigs) + + updateConfigState(StateConfigInitialized) + + log.WithField("file", configFile).Info("Loaded configuration file") + } + + return nil } // Updates returns a channel that emits a message whenever the underlying @@ -237,48 +295,6 @@ func load(file string) (*data.GortConfig, error) { return &config, nil } -// Reload is called by Initialize() to determine whether the config file has changed (or is new) and reload if it has. -func Reload() error { - configMutex.Lock() - defer configMutex.Unlock() - - sum, err := getMd5Sum(configFile) - if err != nil { - log.WithField("file", configFile).WithError(err).Error(ErrHashFailure.Error()) - - return gerrs.Wrap(ErrHashFailure, err) - } - - if !slicesAreEqual(sum, md5sum) { - cp, err := load(configFile) - if err != nil { - // If we're already initialized, keep the original config. - // If not, set the state to 'error'. - if CurrentState() == StateConfigUninitialized { - updateConfigState(StateConfigError) - } - - log.WithField("file", configFile).WithError(err).Error(ErrConfigUnloadable.Error()) - - return gerrs.Wrap(ErrConfigUnloadable, err) - } - - md5sum = sum - config = cp - - setLogFormatter() - - // Properly load the database configs. - standardizeDatabaseConfig(&cp.DatabaseConfigs) - - updateConfigState(StateConfigInitialized) - - log.WithField("file", configFile).Info("Loaded configuration file") - } - - return nil -} - func setLogFormatter() { dev := config.GortServerConfigs.DevelopmentMode diff --git a/data/bundle.go b/data/bundle.go index d3fa9d5..02dd357 100644 --- a/data/bundle.go +++ b/data/bundle.go @@ -17,10 +17,10 @@ package data import ( - "context" - "fmt" "strings" "time" + + "github.com/coreos/go-semver/semver" ) // BundleInfo wraps a minimal amount of data about a bundle. @@ -34,81 +34,135 @@ type BundleInfo struct { // Bundle represents a bundle as defined in the "bundles" section of the // config. type Bundle struct { - GortBundleVersion int `yaml:"gort_bundle_version,omitempty" json:"gort_bundle_version,omitempty"` - Name string `yaml:",omitempty" json:"name,omitempty"` - Version string `yaml:",omitempty" json:"version,omitempty"` - Enabled bool `yaml:",omitempty" json:"enabled"` - Author string `yaml:",omitempty" json:"author,omitempty"` - Homepage string `yaml:",omitempty" json:"homepage,omitempty"` - Description string `yaml:",omitempty" json:"description,omitempty"` - InstalledOn time.Time `yaml:"-" json:"installed_on,omitempty"` - InstalledBy string `yaml:",omitempty" json:"installed_by,omitempty"` - LongDescription string `yaml:"long_description,omitempty" json:"long_description,omitempty"` - Docker BundleDocker `yaml:",omitempty" json:"docker,omitempty"` - Permissions []string `yaml:",omitempty" json:"permissions,omitempty"` - Commands map[string]*BundleCommand `yaml:",omitempty" json:"commands,omitempty"` - Default bool `yaml:"-" json:"default,omitempty"` + GortBundleVersion int `yaml:"gort_bundle_version,omitempty" json:",omitempty"` + Name string `yaml:",omitempty" json:",omitempty"` + Version string `yaml:",omitempty" json:",omitempty"` + Enabled bool `yaml:",omitempty" json:",omitempty"` + Author string `yaml:",omitempty" json:",omitempty"` + Homepage string `yaml:",omitempty" json:",omitempty"` + Description string `yaml:",omitempty" json:",omitempty"` + Image string `yaml:",omitempty" json:",omitempty"` + InstalledOn time.Time `yaml:"-" json:",omitempty"` + InstalledBy string `yaml:",omitempty" json:",omitempty"` + LongDescription string `yaml:"long_description,omitempty" json:",omitempty"` + Kubernetes BundleKubernetes `yaml:",omitempty" json:",omitempty"` + Permissions []string `yaml:",omitempty" json:",omitempty"` + Commands map[string]*BundleCommand `yaml:",omitempty" json:",omitempty"` + Default bool `yaml:"-" json:",omitempty"` + Templates Templates `yaml:",omitempty" json:",omitempty"` } -// BundleDocker represents the "bundles/docker" subsection of the config doc -type BundleDocker struct { - Image string `yaml:",omitempty" json:"image,omitempty"` - Tag string `yaml:",omitempty" json:"tag,omitempty"` +// ImageFull returns the full image name, consisting of a repository and tag. +func (b Bundle) ImageFull() string { + if repo, tag := b.ImageFullParts(); repo != "" { + return repo + ":" + tag + } + + return "" +} + +// ImageFullParts returns the image repository and tag. If the tag isn't +// specified in b.Image, the returned tag will be "latest". +func (b Bundle) ImageFullParts() (repository, tag string) { + if b.Image == "" { + return + } + + ss := strings.SplitN(b.Image, ":", 2) + + repository = ss[0] + + if len(ss) > 1 { + tag = ss[1] + } else { + tag = "latest" + } + + return +} + +// Semver returns b.Version as a semver.Version value, which makes it easier +// to compare and sort version numbers. If b.Version == "", a zero-value +// Version{} is returned. If b.Version isn't valid per Semantic Versioning +// 2.0.0 (https://semver.org), it will attempt to coerce it into a correct +// semantic version (since users be crazy). If it fails, a zero-value +// Version{} is returned. +func (b Bundle) Semver() semver.Version { + if v, err := semver.NewVersion(CoerceVersionToSemver(b.Version)); err != nil { + return semver.Version{} + } else { + return *v + } } // BundleCommand represents a bundle command, as defined in the "bundles/commands" // section of the config. type BundleCommand struct { - Description string `yaml:",omitempty" json:"description,omitempty"` - Executable []string `yaml:",omitempty,flow" json:"executable,omitempty"` - LongDescription string `yaml:"long_description,omitempty" json:"long_description,omitempty"` - Name string `yaml:"-" json:"-"` - Rules []string `yaml:",omitempty" json:"rules,omitempty"` + Description string `yaml:",omitempty" json:"description,omitempty"` + Executable []string `yaml:",omitempty,flow" json:"executable,omitempty"` + LongDescription string `yaml:"long_description,omitempty" json:"long_description,omitempty"` + Name string `yaml:"-" json:"-"` + Rules []string `yaml:",omitempty" json:"rules,omitempty"` + Templates Templates `yaml:",omitempty" json:"templates,omitempty"` } -// CommandEntry conveniently wraps a bundle and one command within that bundle. -type CommandEntry struct { - Bundle Bundle - Command BundleCommand +// BundleKubernetes represents the "bundles/kubernetes" subsection of the config doc +type BundleKubernetes struct { + ServiceAccountName string `yaml:"serviceAccountName,omitempty" json:"serviceAccountName,omitempty"` } -// CommandRequest represents a user command request as triggered in (probably) -// a chat provider. -type CommandRequest struct { - CommandEntry - Adapter string // The name of the adapter this request originated from. - ChannelID string // The provider ID of the channel that the request originated in. - Context context.Context // The request context - Parameters []string // Tokenized command parameters - RequestID int64 // A unique requestID - Timestamp time.Time // The time this request was triggered - UserID string // The provider ID of user making this request. - UserEmail string // The email address associated with the user making the request - UserName string // The gort username of the user making the request -} +// CoerceVersionToSemver takes a version number and attempts to coerce it +// into a semver-compliant dotted-tri format. It also understands semver +// pre-release and metadata decorations. +func CoerceVersionToSemver(version string) string { + version = strings.TrimSpace(version) -// CommandString is a convenience method that outputs the normalized command -// string, more or less as the user typed it. -func (r CommandRequest) CommandString() string { - return fmt.Sprintf( - "%s:%s %s", - r.Bundle.Name, - r.Command.Name, - strings.Join(r.Parameters, " ")) -} + if version == "" { + return "0.0.0" + } + + if strings.ToLower(version)[0] == 'v' { + version = version[1:] + } + + v := version + + var metadata, preRelease string + var ss []string + var dotParts = make([]string, 3) + + ss = strings.SplitN(v, "+", 2) + if len(ss) > 1 { + v = ss[0] + metadata = ss[1] + } + + ss = strings.SplitN(v, "-", 2) + if len(ss) > 1 { + v = ss[0] + preRelease = ss[1] + } + + // If it turns out to be in dotted-tri format, return the original + ss = strings.SplitN(v, ".", 4) + for i := 0; i < len(ss) && i < 3; i++ { + dotParts[i] = ss[i] + } + for i := 0; i < len(dotParts); i++ { + if dotParts[i] == "" { + dotParts[i] = "0" + } + } + + v = strings.Join(dotParts, ".") + + if preRelease != "" { + v += "-" + preRelease + } + + if metadata != "" { + v += "+" + metadata + } -// CommandResponse is returned by a relay to indicate that a command has been executed. -// It includes the original CommandRequest, the command's exit status code, and -// the commands entire stdout as a slice of lines. Title can be used to build -// a user output message, and generally contains a short description of the result. -// -// TODO Add a request ID that correcponds with the request, so that we can more -// directly link it back to its user and adapter of origin. -type CommandResponse struct { - Command CommandRequest - Duration time.Duration - Status int64 - Title string // Command Error - Output []string // Contents of the commands stdout. - Error error + return v } diff --git a/data/bundle_test.go b/data/bundle_test.go new file mode 100644 index 0000000..4082607 --- /dev/null +++ b/data/bundle_test.go @@ -0,0 +1,139 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package data + +import ( + "testing" + + "github.com/coreos/go-semver/semver" + "github.com/stretchr/testify/assert" +) + +func TestBundleImageFullParts(t *testing.T) { + tests := []struct { + Image string + ExpectedRepo string + ExpectedTag string + }{ + {"", "", ""}, + {"ubuntu", "ubuntu", "latest"}, + {"ubuntu:20.04", "ubuntu", "20.04"}, + {"linux:ubuntu:20.04", "linux", "ubuntu:20.04"}, + } + + for _, test := range tests { + b := Bundle{Image: test.Image} + repo, tag := b.ImageFullParts() + + assert.Equal(t, test.ExpectedRepo, repo) + assert.Equal(t, test.ExpectedTag, tag) + } +} + +func TestCoerceVersionToSemver(t *testing.T) { + tests := []struct { + Version string + Expected string + }{ + // Zero value + {"", "0.0.0"}, + + // Malicious? edge cases + {"v", "0.0.0"}, + {" v1 ", "1.0.0"}, + + // Examples from semver.org + {"1.0.0-alpha", "1.0.0-alpha"}, + {"v1.0.0-alpha", "1.0.0-alpha"}, + {"1.0.0-alpha.1", "1.0.0-alpha.1"}, + {"v1.0.0-alpha.1", "1.0.0-alpha.1"}, + {"1.0.0-0.3.7", "1.0.0-0.3.7"}, + {"v1.0.0-0.3.7", "1.0.0-0.3.7"}, + {"1.0.0-x.7.z.92", "1.0.0-x.7.z.92"}, + {"v1.0.0-x.7.z.92", "1.0.0-x.7.z.92"}, + {"1.0.0-alpha+001", "1.0.0-alpha+001"}, + {"v1.0.0-alpha+001", "1.0.0-alpha+001"}, + {"1.0.0+20130313144700", "1.0.0+20130313144700"}, + {"v1.0.0+20130313144700", "1.0.0+20130313144700"}, + {"1.0.0-beta+exp.sha.5114f85", "1.0.0-beta+exp.sha.5114f85"}, + {"v1.0.0-beta+exp.sha.5114f85", "1.0.0-beta+exp.sha.5114f85"}, + + // Version coercion + {"1", "1.0.0"}, + {"v1", "1.0.0"}, + {"1.2", "1.2.0"}, + {"v1.2", "1.2.0"}, + {"1.2.3", "1.2.3"}, + {"v1.2.3", "1.2.3"}, + {"1.2.3.4", "1.2.3"}, // Truncate to 3 parts. + {"v1.2.3.4", "1.2.3"}, + } + + for _, test := range tests { + result := CoerceVersionToSemver(test.Version) + assert.Equal(t, test.Expected, result, "Test case: %q", test.Version) + + _, err := semver.NewVersion(result) + assert.NoError(t, err, "Test case: %q", test.Version) + } +} + +func TestSemver(t *testing.T) { + tests := []struct { + Version string + Expected semver.Version + }{ + // Zero value + {"", semver.Version{}}, + + // Malicious? edge cases + {"v", semver.Version{}}, + {" v1 ", *semver.New("1.0.0")}, + + // Examples from *semver.org + {"1.0.0-alpha", *semver.New("1.0.0-alpha")}, + {"v1.0.0-alpha", *semver.New("1.0.0-alpha")}, + {"1.0.0-alpha.1", *semver.New("1.0.0-alpha.1")}, + {"v1.0.0-alpha.1", *semver.New("1.0.0-alpha.1")}, + {"1.0.0-0.3.7", *semver.New("1.0.0-0.3.7")}, + {"v1.0.0-0.3.7", *semver.New("1.0.0-0.3.7")}, + {"1.0.0-x.7.z.92", *semver.New("1.0.0-x.7.z.92")}, + {"v1.0.0-x.7.z.92", *semver.New("1.0.0-x.7.z.92")}, + {"1.0.0-alpha+001", *semver.New("1.0.0-alpha+001")}, + {"v1.0.0-alpha+001", *semver.New("1.0.0-alpha+001")}, + {"1.0.0+20130313144700", *semver.New("1.0.0+20130313144700")}, + {"v1.0.0+20130313144700", *semver.New("1.0.0+20130313144700")}, + {"1.0.0-beta+exp.sha.5114f85", *semver.New("1.0.0-beta+exp.sha.5114f85")}, + {"v1.0.0-beta+exp.sha.5114f85", *semver.New("1.0.0-beta+exp.sha.5114f85")}, + + // Version coercion + {"1", *semver.New("1.0.0")}, + {"v1", *semver.New("1.0.0")}, + {"1.2", *semver.New("1.2.0")}, + {"v1.2", *semver.New("1.2.0")}, + {"1.2.3", *semver.New("1.2.3")}, + {"v1.2.3", *semver.New("1.2.3")}, + {"1.2.3.4", *semver.New("1.2.3")}, + {"v1.2.3.4", *semver.New("1.2.3")}, + } + + for _, test := range tests { + b := Bundle{Version: test.Version} + result := b.Semver() + assert.Equal(t, test.Expected, result, "Test case: %q", test.Version) + } +} diff --git a/data/command.go b/data/command.go new file mode 100644 index 0000000..391c267 --- /dev/null +++ b/data/command.go @@ -0,0 +1,187 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package data + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// CommandEntry conveniently wraps a bundle and one command within that bundle. +type CommandEntry struct { + Bundle Bundle + Command BundleCommand +} + +type CommandParameters []string + +func (c CommandParameters) String() string { + return strings.Join(c, " ") +} + +// CommandRequest represents a user command request as triggered in (probably) +// a chat provider. +type CommandRequest struct { + CommandEntry + Adapter string // The name of the adapter this request originated from + ChannelID string // The provider ID of the channel that the request originated in + Context context.Context // The request context + Parameters CommandParameters // Tokenized command parameters + RequestID int64 // A unique requestID + Timestamp time.Time // The time this request was triggered + UserID string // The provider ID of user making this request + UserEmail string // The email address associated with the user making the request + UserName string // The gort username of the user making the request +} + +// String is a convenience method that outputs the normalized command +// string more or less as the user typed it. +func (r CommandRequest) String() string { + return fmt.Sprintf("%s:%s %s", r.Bundle.Name, r.Command.Name, r.Parameters) +} + +// CommandResponse wraps the response text emitted by an executed command. +type CommandResponse struct { + // Lines contains the command output (from both stdout and stderr) as + // a string slice, delimitted along newlines. + Lines []string + + // Out The command output as a single block of text, with lines joined + // with newlines. + Out string + + // Structured is true if the command output is valid JSON. If so, then it + // also be unmarshalled as Payload; else Payload will be a string (equal + // to Out). + Structured bool + + // Title includes a title. Usually only set by the relay for certain + // internally-detected errors. It can be used to build a user output + // message, and generally contains a short description of the result. + Title string +} + +// CommandResponseData contains about a command execution, including its +// duration and exit code. If the relay set an an explicit error, it will +// be here as well. +type CommandResponseData struct { + // Duration is how long the command required to execute. + // TODO(mtitmus) What are the start and endpoints? Do we want to track + // multiple durations for "framework time" and "command time" and whatever + // else? + Duration time.Duration + + // ExitCode is the exit code reported by the command. + ExitCode int16 + + // Error is set by the relay under certain internal error conditions. + Error error +} + +// CommandResponseEnvelope encapsulates the data and metadata around a command +// execution and response. It's returned by a relay when a command has been +// executed. It is passed directly into the templating engine where it can be +// accessed by the Go templates that describe the response formats. +type CommandResponseEnvelope struct { + // Request is the original request used to execute the command. It contains + // the original CommandEntry value as well as the user and adapter data. + Request CommandRequest + + // Response contains the + Response CommandResponse + + // Data contains about the command execution, including its duration and exit code. + // If the relay set an an explicit error, it will be here as well. + Data CommandResponseData + + // Payload includes the command output. If the output is structured JSON, + // it will be unmarshalled and placed here where it can be accessible to + // Go templates. If it's not, this will be a string equal to Out. + Payload interface{} +} + +// NewCommandResponseEnvelope can be used to generate a new +// CommandResponseEnvelope value with the provided options. +func NewCommandResponseEnvelope(request CommandRequest, opts ...CommandResponseEnvelopeOption) CommandResponseEnvelope { + envelope := CommandResponseEnvelope{ + Request: request, + Response: CommandResponse{Lines: []string{}}, + } + + for _, o := range opts { + o(&envelope) + } + + return envelope +} + +// CommandResponseEnvelopeOption is returned by the various WithX functions +// and accepted by NewCommandResponseEnvelope. +type CommandResponseEnvelopeOption func(e *CommandResponseEnvelope) + +// WithExitCode sets Data.ExitCode. It does NOT set +func WithExitCode(code int16) CommandResponseEnvelopeOption { + return func(e *CommandResponseEnvelope) { + e.Data.ExitCode = code + } +} + +// WithError sets Data.Error, Data.ExitCode, Response.Lines, Response.Out, +// Response.Structured, Response.Title, and Payload (as err.Error). +func WithError(title string, err error, code int16) CommandResponseEnvelopeOption { + return func(e *CommandResponseEnvelope) { + e.Data.Error = err + e.Data.ExitCode = code + e.Response.Lines = []string{err.Error()} + e.Response.Out = err.Error() + e.Response.Title = title + e.Payload, e.Response.Structured = unmarshalResponsePayload(e.Response.Out) + } +} + +// WithResponseLines sets Response.Lines, Response.Out, Response.Structured, +// and Payload. +func WithResponseLines(r []string) CommandResponseEnvelopeOption { + return func(e *CommandResponseEnvelope) { + e.Response.Lines = r + e.Response.Out = strings.Join(r, "\n") + e.Payload, e.Response.Structured = unmarshalResponsePayload(e.Response.Out) + } +} + +// unmarshalResponsePayload examines the string parameter to determine +// whether it contains valid JSON. If it does it will unmarshal the contents +// and return the result and true; else it will return the original string +// and false. +func unmarshalResponsePayload(s string) (interface{}, bool) { + b := []byte(s) + + if !json.Valid(b) { + return s, false + } + + var i interface{} + + if err := json.Unmarshal(b, &i); err != nil { + return s, false + } + + return i, true +} diff --git a/data/command_test.go b/data/command_test.go new file mode 100644 index 0000000..4e6dc62 --- /dev/null +++ b/data/command_test.go @@ -0,0 +1,91 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package data + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +var request = CommandRequest{ + RequestID: 1, + UserName: "user", +} + +func TestNewCommandResponseEnvelope(t *testing.T) { + e := NewCommandResponseEnvelope(request) + + assert.Equal(t, int64(1), e.Request.RequestID) + assert.Equal(t, "user", e.Request.UserName) +} + +func TestNewCommandResponseEnvelope_WithExitCode0(t *testing.T) { + e := NewCommandResponseEnvelope(request, WithExitCode(0)) + + assert.Equal(t, int16(0), e.Data.ExitCode) +} + +func TestNewCommandResponseEnvelope_WithExitCode1(t *testing.T) { + e := NewCommandResponseEnvelope(request, WithExitCode(1)) + + assert.Equal(t, int16(1), e.Data.ExitCode) +} + +func TestNewCommandResponseEnvelope_WithError(t *testing.T) { + const title = "Error" + const msg = "this is an error" + const code int16 = 1 + err := fmt.Errorf(msg) + + e := NewCommandResponseEnvelope(request, WithError(title, err, code)) + + assert.Equal(t, e.Data.Error, err) + assert.Equal(t, e.Data.ExitCode, code) + assert.Equal(t, e.Response.Lines, []string{msg}) + assert.Equal(t, e.Response.Out, msg) + assert.Equal(t, e.Response.Title, title) + assert.Equal(t, e.Payload, msg) +} + +func TestNewCommandResponseEnvelope_WithResponseLines(t *testing.T) { + message := "this is a\ntwo-line message" + lines := []string{"this is a", "two-line message"} + + e := NewCommandResponseEnvelope(request, WithResponseLines(lines)) + + assert.Equal(t, e.Response.Lines, lines) + assert.Equal(t, e.Response.Out, message) + assert.False(t, e.Response.Structured) + assert.Equal(t, e.Payload, message) +} + +func TestNewCommandResponseEnvelope_WithStructuredResponseLines(t *testing.T) { + message := `{ "Name": "Matt" }` + lines := []string{message} + + e := NewCommandResponseEnvelope(request, WithResponseLines(lines)) + + assert.Equal(t, e.Response.Lines, lines) + assert.Equal(t, e.Response.Out, message) + assert.True(t, e.Response.Structured) + + p, ok := e.Payload.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "Matt", p["Name"]) +} diff --git a/data/config.go b/data/config.go index 9f88026..96be9d6 100644 --- a/data/config.go +++ b/data/config.go @@ -25,8 +25,10 @@ type GortConfig struct { DatabaseConfigs DatabaseConfigs `yaml:"database,omitempty"` DockerConfigs DockerConfigs `yaml:"docker,omitempty"` JaegerConfigs JaegerConfigs `yaml:"jaeger,omitempty"` + KubernetesConfigs KubernetesConfigs `yaml:"kubernetes,omitempty"` SlackProviders []SlackProvider `yaml:"slack,omitempty"` DiscordProviders []DiscordProvider `yaml:"discord,omitempty"` + Templates Templates `yaml:"templates,omitempty"` } // GortServerConfigs is the data wrapper for the "gort" section. @@ -60,7 +62,6 @@ type DatabaseConfigs struct { } // DockerConfigs is the data wrapper for the "docker" section. -// This will move into the relay config(s) eventually. type DockerConfigs struct { DockerHost string `yaml:"host,omitempty"` Network string `yaml:"network,omitempty"` @@ -72,3 +73,12 @@ type JaegerConfigs struct { Password string `yaml:"password,omitempty"` Username string `yaml:"username,omitempty"` } + +// KubernetesConfigs is the data wrapper for the "kubernetes" section. +type KubernetesConfigs struct { + Namespace string `yaml:"namespace,omitempty"` + EndpointFieldSelector string `yaml:"endpoint_field_selector,omitempty"` + EndpointLabelSelector string `yaml:"endpoint_label_selector,omitempty"` + PodFieldSelector string `yaml:"pod_field_selector,omitempty"` + PodLabelSelector string `yaml:"pod_label_selector,omitempty"` +} diff --git a/data/template.go b/data/template.go new file mode 100644 index 0000000..4999035 --- /dev/null +++ b/data/template.go @@ -0,0 +1,79 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package data + +import ( + "fmt" +) + +type TemplateType string + +const ( + // Command templates are used to format the outputs from successfully + // executed commands. + Command TemplateType = "command" + + // CommandError templates are used to format the error messages produced + // by commands that return with a non-zero status. + CommandError TemplateType = "command_error" + + // Message templates are used to format standard informative (non-error) + // messages from the Gort system (not commands). + Message TemplateType = "message" + + // MessageError templates are used to format error messages from the Gor + // system (not commands). + MessageError TemplateType = "message_error" +) + +// Templates describes (or not) a set of templates that can be used to format +// command output. It is used in several places, including bundles, bundle +// commands, and the application config. +type Templates struct { + // Command templates are used to format the outputs from successfully + // executed commands. + Command string `yaml:"command,omitempty" json:"command,omitempty"` + + // CommandError templates are used to format the error messages produced + // by commands that return with a non-zero status. + CommandError string `yaml:"command_error,omitempty" json:"command_error,omitempty"` + + // Message templates are used to format standard informative (non-error) + // messages from the Gort system (not commands). + Message string `yaml:"message,omitempty" json:"message,omitempty"` + + // MessageError templates are used to format error messages from the Gort + // system (not commands). + MessageError string `yaml:"message_error,omitempty" json:"message_error,omitempty"` +} + +// Get returns a template string. If no template is defined for the given +// name/type, an empty string is returned. An invalid type returns an error. +func (t Templates) Get(tt TemplateType) (string, error) { + switch tt { + case Command: + return t.Command, nil + case CommandError: + return t.CommandError, nil + case Message: + return t.Message, nil + case MessageError: + return t.MessageError, nil + default: + return "", fmt.Errorf("invalid template type %q", string(tt)) + } +} diff --git a/dataaccess/dataaccess.go b/dataaccess/dataaccess.go index ce02cca..6be1a3d 100644 --- a/dataaccess/dataaccess.go +++ b/dataaccess/dataaccess.go @@ -35,7 +35,7 @@ type DataAccess interface { RequestBegin(ctx context.Context, request *data.CommandRequest) error RequestUpdate(ctx context.Context, request data.CommandRequest) error RequestError(ctx context.Context, request data.CommandRequest, err error) error - RequestClose(ctx context.Context, result data.CommandResponse) error + RequestClose(ctx context.Context, result data.CommandResponseEnvelope) error BundleCreate(ctx context.Context, bundle data.Bundle) error BundleDelete(ctx context.Context, name string, version string) error diff --git a/dataaccess/memory/base_test.go b/dataaccess/memory/base_test.go index d1b2333..2e9e9e3 100644 --- a/dataaccess/memory/base_test.go +++ b/dataaccess/memory/base_test.go @@ -21,6 +21,7 @@ import ( "testing" "time" + "github.com/getgort/gort/dataaccess/tests" "github.com/stretchr/testify/assert" ) @@ -45,10 +46,7 @@ func testInitialize(t *testing.T) { func TestMemoryDataAccessMain(t *testing.T) { t.Run("testInitialize", testInitialize) - t.Run("testUserAccess", testUserAccess) - t.Run("testGroupAccess", testGroupAccess) - t.Run("testTokenAccess", testTokenAccess) - t.Run("testBundleAccess", testBundleAccess) - t.Run("testRoleAccess", testRoleAccess) - t.Run("testRequestAccess", testRequestAccess) + + dat := tests.NewDataAccessTester(ctx, cancel, da) + t.Run("RunAllTests", dat.RunAllTests) } diff --git a/dataaccess/memory/bundle-access.go b/dataaccess/memory/bundle-access.go index fe3b67e..989f0cc 100644 --- a/dataaccess/memory/bundle-access.go +++ b/dataaccess/memory/bundle-access.go @@ -42,6 +42,8 @@ func (da *InMemoryDataAccess) BundleCreate(ctx context.Context, bundle data.Bund return errs.ErrBundleExists } + bundle.Image = bundle.ImageFull() + da.bundles[bundleKey(bundle.Name, bundle.Version)] = &bundle return nil diff --git a/dataaccess/memory/bundle-access_test.go b/dataaccess/memory/bundle-access_test.go deleted file mode 100644 index a8c60b9..0000000 --- a/dataaccess/memory/bundle-access_test.go +++ /dev/null @@ -1,455 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package memory - -import ( - "testing" - - "github.com/getgort/gort/bundles" - "github.com/getgort/gort/data" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func testBundleAccess(t *testing.T) { - t.Run("testLoadTestData", testLoadTestData) - t.Run("testBundleCreate", testBundleCreate) - t.Run("testBundleCreateMissingRequired", testBundleCreateMissingRequired) - t.Run("testBundleEnable", testBundleEnable) - t.Run("testBundleEnableTwo", testBundleEnableTwo) - t.Run("testBundleExists", testBundleExists) - t.Run("testBundleDelete", testBundleDelete) - t.Run("testBundleDeleteDoesntDisable", testBundleDeleteDoesntDisable) - t.Run("testBundleGet", testBundleGet) - t.Run("testBundleList", testBundleList) - t.Run("testBundleVersionList", testBundleVersionList) - t.Run("testFindCommandEntry", testFindCommandEntry) -} - -// Fail-fast: can the test bundle be loaded? -func testLoadTestData(t *testing.T) { - b, err := getTestBundle() - assert.NoError(t, err) - - assert.NotEmpty(t, b.Commands) - assert.NotEmpty(t, b.Commands["echox"].Description) - assert.NotEmpty(t, b.Commands["echox"].Executable) - assert.NotEmpty(t, b.Commands["echox"].LongDescription) - assert.NotEmpty(t, b.Commands["echox"].Name) - assert.NotEmpty(t, b.Commands["echox"].Rules) -} - -func testBundleCreate(t *testing.T) { - // Expect an error - err := da.BundleCreate(ctx, data.Bundle{}) - if !assert.Error(t, err, errs.ErrEmptyBundleName) { - t.FailNow() - } - - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-create" - - // Expect no error - err = da.BundleCreate(ctx, bundle) - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - // Expect an error - err = da.BundleCreate(ctx, bundle) - if !assert.Error(t, err, errs.ErrBundleExists) { - t.FailNow() - } -} - -func testBundleCreateMissingRequired(t *testing.T) { - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-missing-required" - - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - - // GortBundleVersion - originalGortBundleVersion := bundle.GortBundleVersion - bundle.GortBundleVersion = 0 - err = da.BundleCreate(ctx, bundle) - if !assert.Error(t, err, errs.ErrFieldRequired) { - t.FailNow() - } - bundle.GortBundleVersion = originalGortBundleVersion - - // Description - originalDescription := bundle.Description - bundle.Description = "" - err = da.BundleCreate(ctx, bundle) - if !assert.Error(t, err, errs.ErrFieldRequired) { - t.FailNow() - } - bundle.Description = originalDescription -} - -func testBundleEnable(t *testing.T) { - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-enable" - - err = da.BundleCreate(ctx, bundle) - assert.NoError(t, err) - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - - // No version should be enabled - enabled, err := da.BundleEnabledVersion(ctx, bundle.Name) - assert.NoError(t, err) - if enabled != "" { - t.Error("Expected no version to be enabled") - t.FailNow() - } - - // Reload and verify enabled value is false - bundle, err = da.BundleGet(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - assert.False(t, bundle.Enabled) - - // Enable and verify - err = da.BundleEnable(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - enabled, err = da.BundleEnabledVersion(ctx, bundle.Name) - assert.NoError(t, err) - if enabled != bundle.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundle.Version, enabled) - t.FailNow() - } - - bundle, err = da.BundleGet(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - assert.True(t, bundle.Enabled) - - // Should now delete cleanly - err = da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) -} - -func testBundleEnableTwo(t *testing.T) { - bundleA, err := getTestBundle() - assert.NoError(t, err) - bundleA.Name = "test-enable-2" - bundleA.Version = "0.0.1" - - err = da.BundleCreate(ctx, bundleA) - assert.NoError(t, err) - defer da.BundleDelete(ctx, bundleA.Name, bundleA.Version) - - // Enable and verify - err = da.BundleEnable(ctx, bundleA.Name, bundleA.Version) - assert.NoError(t, err) - - enabled, err := da.BundleEnabledVersion(ctx, bundleA.Name) - assert.NoError(t, err) - - if enabled != bundleA.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundleA.Version, enabled) - t.FailNow() - } - - // Create a new version of the same bundle - - bundleB, err := getTestBundle() - assert.NoError(t, err) - bundleB.Name = bundleA.Name - bundleB.Version = "0.0.2" - - err = da.BundleCreate(ctx, bundleB) - assert.NoError(t, err) - defer da.BundleDelete(ctx, bundleB.Name, bundleB.Version) - - // BundleA should still be enabled - - enabled, err = da.BundleEnabledVersion(ctx, bundleA.Name) - assert.NoError(t, err) - - if enabled != bundleA.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundleA.Version, enabled) - t.FailNow() - } - - enabled, err = da.BundleEnabledVersion(ctx, bundleA.Name) - assert.NoError(t, err) - - if enabled != bundleA.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundleA.Version, enabled) - t.FailNow() - } - - // Enable and verify - err = da.BundleEnable(ctx, bundleB.Name, bundleB.Version) - assert.NoError(t, err) - - enabled, err = da.BundleEnabledVersion(ctx, bundleB.Name) - assert.NoError(t, err) - - if enabled != bundleB.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundleB.Version, enabled) - t.FailNow() - } -} - -func testBundleExists(t *testing.T) { - var exists bool - - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-exists" - - exists, _ = da.BundleExists(ctx, bundle.Name, bundle.Version) - if exists { - t.Error("Bundle should not exist now") - t.FailNow() - } - - err = da.BundleCreate(ctx, bundle) - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - exists, _ = da.BundleExists(ctx, bundle.Name, bundle.Version) - if !exists { - t.Error("Bundle should exist now") - t.FailNow() - } -} - -func testBundleDelete(t *testing.T) { - // Delete blank bundle - err := da.BundleDelete(ctx, "", "0.0.1") - if !assert.Error(t, err, errs.ErrEmptyBundleName) { - t.FailNow() - } - - // Delete blank bundle - err = da.BundleDelete(ctx, "foo", "") - if !assert.Error(t, err, errs.ErrEmptyBundleVersion) { - t.FailNow() - } - - // Delete bundle that doesn't exist - err = da.BundleDelete(ctx, "no-such-bundle", "0.0.1") - if !assert.Error(t, err, errs.ErrNoSuchBundle) { - t.FailNow() - } - - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-delete" - - err = da.BundleCreate(ctx, bundle) // This has its own test - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - err = da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - exists, _ := da.BundleExists(ctx, bundle.Name, bundle.Version) - if exists { - t.Error("Shouldn't exist anymore!") - t.FailNow() - } -} - -func testBundleDeleteDoesntDisable(t *testing.T) { - var err error - - bundle, _ := getTestBundle() - bundle.Name = "test-delete2" - bundle.Version = "0.0.1" - err = da.BundleCreate(ctx, bundle) - require.NoError(t, err) - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - - bundle2, _ := getTestBundle() - bundle2.Name = "test-delete2" - bundle2.Version = "0.0.2" - err = da.BundleCreate(ctx, bundle2) - require.NoError(t, err) - defer da.BundleDelete(ctx, bundle2.Name, bundle2.Version) - - err = da.BundleEnable(ctx, bundle2.Name, bundle2.Version) - require.NoError(t, err) - - err = da.BundleDelete(ctx, bundle.Name, bundle.Version) - require.NoError(t, err) - - bundle2, err = da.BundleGet(ctx, bundle2.Name, bundle2.Version) - require.NoError(t, err) - assert.True(t, bundle2.Enabled) -} - -func testBundleGet(t *testing.T) { - var err error - - // Empty bundle name. Expect a ErrEmptyBundleName. - _, err = da.BundleGet(ctx, "", "0.0.1") - if !assert.Error(t, err, errs.ErrEmptyBundleName) { - t.FailNow() - } - - // Empty bundle name. Expect a ErrEmptyBundleVersion. - _, err = da.BundleGet(ctx, "test-get", "") - if !assert.Error(t, err, errs.ErrEmptyBundleVersion) { - t.FailNow() - } - - // Bundle that doesn't exist. Expect a ErrNoSuchBundle. - _, err = da.BundleGet(ctx, "test-get", "0.0.1") - if !assert.Error(t, err, errs.ErrNoSuchBundle) { - t.FailNow() - } - - // Get the test bundle. Expect no error. - bundleCreate, err := getTestBundle() - assert.NoError(t, err) - - // Set some values to non-defaults - bundleCreate.Name = "test-get" - // bundleCreate.Enabled = true - - // Save the test bundle. Expect no error. - err = da.BundleCreate(ctx, bundleCreate) - defer da.BundleDelete(ctx, bundleCreate.Name, bundleCreate.Version) - assert.NoError(t, err) - - // Test bundle should now exist in the data store. - exists, _ := da.BundleExists(ctx, bundleCreate.Name, bundleCreate.Version) - if !exists { - t.Error("Bundle should exist now, but it doesn't") - t.FailNow() - } - - // Load the bundle from the data store. Expect no error - bundleGet, err := da.BundleGet(ctx, bundleCreate.Name, bundleCreate.Version) - assert.NoError(t, err) - - // This is set automatically on save, so we copy it here for the sake of the tests. - bundleCreate.InstalledOn = bundleGet.InstalledOn - - assert.Equal(t, bundleCreate.Docker, bundleGet.Docker) - assert.ElementsMatch(t, bundleCreate.Permissions, bundleGet.Permissions) - assert.Equal(t, bundleCreate.Commands, bundleGet.Commands) - - // Compare everything for good measure - assert.Equal(t, bundleCreate, bundleGet) -} - -func testBundleList(t *testing.T) { - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.0", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-0", "0.0") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.1", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-0", "0.1") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.0", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-1", "0.0") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.1", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-1", "0.1") - - bundles, err := da.BundleList(ctx) - assert.NoError(t, err) - - if len(bundles) != 4 { - for i, u := range bundles { - t.Logf("Bundle %d: %v\n", i+1, u) - } - - t.Errorf("Expected len(bundles) = 4; got %d", len(bundles)) - t.FailNow() - } -} - -func testBundleVersionList(t *testing.T) { - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.0", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-0", "0.0") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.1", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-0", "0.1") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.0", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-1", "0.0") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.1", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-1", "0.1") - - bundles, err := da.BundleVersionList(ctx, "test-list-0") - assert.NoError(t, err) - - if len(bundles) != 2 { - for i, u := range bundles { - t.Logf("Bundle %d: %v\n", i+1, u) - } - - t.Errorf("Expected len(bundles) = 2; got %d", len(bundles)) - t.FailNow() - } -} - -func testFindCommandEntry(t *testing.T) { - const BundleName = "test" - const BundleVersion = "0.0.1" - const CommandName = "echox" - - tb, err := getTestBundle() - assert.NoError(t, err) - - // Save to data store - err = da.BundleCreate(ctx, tb) - assert.NoError(t, err) - - // Load back from the data store - tb, err = da.BundleGet(ctx, tb.Name, tb.Version) - assert.NoError(t, err) - - // Sanity testing. Has the test case changed? - assert.Equal(t, BundleName, tb.Name) - assert.Equal(t, BundleVersion, tb.Version) - assert.NotNil(t, tb.Commands[CommandName]) - - // Not yet enabled. Should find nothing. - ce, err := da.FindCommandEntry(ctx, BundleName, CommandName) - assert.NoError(t, err) - assert.Len(t, ce, 0) - - err = da.BundleEnable(ctx, BundleName, BundleVersion) - assert.NoError(t, err) - - // Reload to capture enabled status - tb, err = da.BundleGet(ctx, tb.Name, tb.Version) - assert.NoError(t, err) - - // Enabled. Should find commands. - ce, err = da.FindCommandEntry(ctx, BundleName, CommandName) - assert.NoError(t, err) - assert.Len(t, ce, 1) - - // Is the loaded bundle correct? - assert.Equal(t, tb, ce[0].Bundle) - - tc := tb.Commands[CommandName] - cmd := ce[0].Command - assert.Equal(t, tc.Description, cmd.Description) - assert.Equal(t, tc.LongDescription, cmd.LongDescription) - assert.Equal(t, tc.Executable, cmd.Executable) - assert.Equal(t, tc.Name, cmd.Name) - assert.Equal(t, tc.Rules, cmd.Rules) -} - -func getTestBundle() (data.Bundle, error) { - return bundles.LoadBundleFromFile("../../testing/test-bundle.yml") -} diff --git a/dataaccess/memory/group-access_test.go b/dataaccess/memory/group-access_test.go deleted file mode 100644 index df2a688..0000000 --- a/dataaccess/memory/group-access_test.go +++ /dev/null @@ -1,409 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package memory - -import ( - "testing" - - "github.com/getgort/gort/data/rest" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" -) - -func testGroupAccess(t *testing.T) { - t.Run("testGroupUserAdd", testGroupUserAdd) - t.Run("testGroupUserList", testGroupUserList) - t.Run("testGroupCreate", testGroupCreate) - t.Run("testGroupDelete", testGroupDelete) - t.Run("testGroupExists", testGroupExists) - t.Run("testGroupGet", testGroupGet) - t.Run("testGroupRoleAdd", testGroupRoleAdd) - t.Run("testGroupPermissionList", testGroupPermissionList) - t.Run("testGroupList", testGroupList) - t.Run("testGroupRoleList", testGroupRoleList) - t.Run("testGroupUserDelete", testGroupUserDelete) -} - -func testGroupUserAdd(t *testing.T) { - var ( - groupname = "group-test-group-user-add" - username = "user-test-group-user-add" - useremail = "user@foo.bar" - ) - - err := da.GroupUserAdd(ctx, groupname, username) - assert.Error(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - err = da.GroupUserAdd(ctx, groupname, username) - assert.Error(t, err, errs.ErrNoSuchUser) - - da.UserCreate(ctx, rest.User{Username: username, Email: useremail}) - defer da.UserDelete(ctx, username) - - err = da.GroupUserAdd(ctx, groupname, username) - assert.NoError(t, err) - - group, _ := da.GroupGet(ctx, groupname) - - if !assert.Len(t, group.Users, 1) { - t.FailNow() - } - - assert.Equal(t, group.Users[0].Username, username) - assert.Equal(t, group.Users[0].Email, useremail) -} - -func testGroupUserList(t *testing.T) { - var ( - groupname = "group-test-group-user-list" - expected = []rest.User{ - {Username: "user-test-group-user-list-0", Email: "user-test-group-user-list-0@email.com", Mappings: map[string]string{}}, - {Username: "user-test-group-user-list-1", Email: "user-test-group-user-list-1@email.com", Mappings: map[string]string{}}, - } - ) - - _, err := da.GroupUserList(ctx, groupname) - assert.Error(t, err) - assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - da.UserCreate(ctx, expected[0]) - defer da.UserDelete(ctx, expected[0].Username) - da.UserCreate(ctx, expected[1]) - defer da.UserDelete(ctx, expected[1].Username) - - da.GroupUserAdd(ctx, groupname, expected[0].Username) - da.GroupUserAdd(ctx, groupname, expected[1].Username) - - actual, err := da.GroupUserList(ctx, groupname) - if !assert.NoError(t, err) { - t.FailNow() - } - - if !assert.Len(t, actual, 2) { - t.FailNow() - } - - assert.Equal(t, expected, actual) -} - -func testGroupCreate(t *testing.T) { - var err error - var group rest.Group - - // Expect an error - err = da.GroupCreate(ctx, group) - assert.Error(t, err, errs.ErrEmptyGroupName) - - // Expect no error - err = da.GroupCreate(ctx, rest.Group{Name: "test-create"}) - defer da.GroupDelete(ctx, "test-create") - assert.NoError(t, err) - - // Expect an error - err = da.GroupCreate(ctx, rest.Group{Name: "test-create"}) - assert.Error(t, err, errs.ErrGroupExists) -} - -func testGroupDelete(t *testing.T) { - // Delete blank group - err := da.GroupDelete(ctx, "") - assert.Error(t, err, errs.ErrEmptyGroupName) - - // Delete group that doesn't exist - err = da.GroupDelete(ctx, "no-such-group") - assert.Error(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: "test-delete"}) // This has its own test - defer da.GroupDelete(ctx, "test-delete") - - err = da.GroupDelete(ctx, "test-delete") - assert.NoError(t, err) - - exists, _ := da.GroupExists(ctx, "test-delete") - if exists { - t.Error("Shouldn't exist anymore!") - t.FailNow() - } -} - -func testGroupExists(t *testing.T) { - var exists bool - - exists, _ = da.GroupExists(ctx, "test-exists") - if exists { - t.Error("Group should not exist now") - t.FailNow() - } - - // Now we add a group to find. - da.GroupCreate(ctx, rest.Group{Name: "test-exists"}) - defer da.GroupDelete(ctx, "test-exists") - - exists, _ = da.GroupExists(ctx, "test-exists") - if !exists { - t.Error("Group should exist now") - t.FailNow() - } -} - -func testGroupGet(t *testing.T) { - groupname := "group-test-group-get" - - var err error - var group rest.Group - - // Expect an error - _, err = da.GroupGet(ctx, "") - assert.ErrorIs(t, err, errs.ErrEmptyGroupName) - - // Expect an error - _, err = da.GroupGet(ctx, groupname) - assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - // da.Group ctx, should exist now - exists, _ := da.GroupExists(ctx, groupname) - if !assert.True(t, exists) { - t.FailNow() - } - - // Expect no error - group, err = da.GroupGet(ctx, groupname) - if !assert.NoError(t, err) { - t.FailNow() - } - if !assert.Equal(t, groupname, group.Name) { - t.FailNow() - } -} - -func testGroupPermissionList(t *testing.T) { - const ( - groupname = "group-test-group-permission-list" - rolename = "role-test-group-permission-list" - bundlename = "test" - ) - - var expected = rest.RolePermissionList{ - {BundleName: bundlename, Permission: "role-test-group-permission-list-1"}, - {BundleName: bundlename, Permission: "role-test-group-permission-list-2"}, - {BundleName: bundlename, Permission: "role-test-group-permission-list-3"}, - } - - var err error - - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - err = da.GroupRoleAdd(ctx, groupname, rolename) - if !assert.NoError(t, err) { - t.FailNow() - } - - da.RolePermissionAdd(ctx, rolename, expected[0].BundleName, expected[0].Permission) - da.RolePermissionAdd(ctx, rolename, expected[1].BundleName, expected[1].Permission) - da.RolePermissionAdd(ctx, rolename, expected[2].BundleName, expected[2].Permission) - - actual, err := da.GroupPermissionList(ctx, groupname) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expected, actual) -} - -func testGroupRoleAdd(t *testing.T) { - var err error - - groupName := "group-group-grant-role" - roleName := "role-group-grant-role" - bundleName := "bundle-group-grant-role" - permissionName := "perm-group-grant-role" - - da.GroupCreate(ctx, rest.Group{Name: groupName}) - defer da.GroupDelete(ctx, groupName) - - err = da.RoleCreate(ctx, roleName) - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RoleDelete(ctx, roleName) - - err = da.RolePermissionAdd(ctx, roleName, bundleName, permissionName) - if !assert.NoError(t, err) { - t.FailNow() - } - - err = da.GroupRoleAdd(ctx, groupName, roleName) - if !assert.NoError(t, err) { - t.FailNow() - } - - expectedRoles := []rest.Role{ - { - Name: roleName, - Permissions: []rest.RolePermission{{BundleName: bundleName, Permission: permissionName}}, - }, - } - - roles, err := da.GroupRoleList(ctx, groupName) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expectedRoles, roles) - - err = da.GroupRoleDelete(ctx, groupName, roleName) - if !assert.NoError(t, err) { - t.FailNow() - } - - expectedRoles = []rest.Role{} - - roles, err = da.GroupRoleList(ctx, groupName) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expectedRoles, roles) -} - -func testGroupList(t *testing.T) { - da.GroupCreate(ctx, rest.Group{Name: "test-list-0"}) - defer da.GroupDelete(ctx, "test-list-0") - da.GroupCreate(ctx, rest.Group{Name: "test-list-1"}) - defer da.GroupDelete(ctx, "test-list-1") - da.GroupCreate(ctx, rest.Group{Name: "test-list-2"}) - defer da.GroupDelete(ctx, "test-list-2") - da.GroupCreate(ctx, rest.Group{Name: "test-list-3"}) - defer da.GroupDelete(ctx, "test-list-3") - - groups, err := da.GroupList(ctx) - assert.NoError(t, err) - - if len(groups) != 4 { - t.Errorf("Expected len(groups) = 4; got %d", len(groups)) - t.FailNow() - } - - for _, u := range groups { - if u.Name == "" { - t.Error("Expected non-empty name") - t.FailNow() - } - } -} - -func testGroupRoleList(t *testing.T) { - var ( - groupname = "group-test-group-list-roles" - rolenames = []string{ - "role-test-group-list-roles-0", - "role-test-group-list-roles-1", - "role-test-group-list-roles-2", - } - ) - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - da.RoleCreate(ctx, rolenames[1]) - defer da.RoleDelete(ctx, rolenames[1]) - - da.RoleCreate(ctx, rolenames[0]) - defer da.RoleDelete(ctx, rolenames[0]) - - da.RoleCreate(ctx, rolenames[2]) - defer da.RoleDelete(ctx, rolenames[2]) - - roles, err := da.GroupRoleList(ctx, groupname) - if !assert.NoError(t, err) && !assert.Empty(t, roles) { - t.FailNow() - } - - err = da.GroupRoleAdd(ctx, groupname, rolenames[1]) - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.GroupRoleAdd(ctx, groupname, rolenames[0]) - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.GroupRoleAdd(ctx, groupname, rolenames[2]) - if !assert.NoError(t, err) { - t.FailNow() - } - - // Note: alphabetically sorted! - expected := []rest.Role{ - {Name: rolenames[0], Permissions: []rest.RolePermission{}}, - {Name: rolenames[1], Permissions: []rest.RolePermission{}}, - {Name: rolenames[2], Permissions: []rest.RolePermission{}}, - } - - actual, err := da.GroupRoleList(ctx, groupname) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expected, actual) -} - -func testGroupUserDelete(t *testing.T) { - da.GroupCreate(ctx, rest.Group{Name: "foo"}) - defer da.GroupDelete(ctx, "foo") - - da.UserCreate(ctx, rest.User{Username: "bat"}) - defer da.UserDelete(ctx, "bat") - - err := da.GroupUserAdd(ctx, "foo", "bat") - assert.NoError(t, err) - - group, err := da.GroupGet(ctx, "foo") - assert.NoError(t, err) - - if len(group.Users) != 1 { - t.Error("Users list empty") - t.FailNow() - } - - if len(group.Users) > 0 && group.Users[0].Username != "bat" { - t.Error("Wrong user!") - t.FailNow() - } - - err = da.GroupUserDelete(ctx, "foo", "bat") - assert.NoError(t, err) - - group, err = da.GroupGet(ctx, "foo") - assert.NoError(t, err) - - if len(group.Users) != 0 { - t.Error("User not removed") - t.FailNow() - } -} diff --git a/dataaccess/memory/request-access.go b/dataaccess/memory/request-access.go index 7b82573..9515a5f 100644 --- a/dataaccess/memory/request-access.go +++ b/dataaccess/memory/request-access.go @@ -67,12 +67,12 @@ func (da *InMemoryDataAccess) RequestUpdate(ctx context.Context, result data.Com } // Will not implement -func (da *InMemoryDataAccess) RequestClose(ctx context.Context, result data.CommandResponse) error { +func (da *InMemoryDataAccess) RequestClose(ctx context.Context, envelope data.CommandResponseEnvelope) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) _, sp := tr.Start(ctx, "memory.RequestClose") defer sp.End() - if result.Command.RequestID == 0 { + if envelope.Request.RequestID == 0 { return fmt.Errorf("command request ID unset") } diff --git a/dataaccess/memory/request-access_test.go b/dataaccess/memory/request-access_test.go deleted file mode 100644 index 5f1a0ab..0000000 --- a/dataaccess/memory/request-access_test.go +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package memory - -import ( - "fmt" - "testing" - "time" - - "github.com/getgort/gort/data" - "github.com/stretchr/testify/assert" -) - -func testRequestAccess(t *testing.T) { - t.Run("testRequestBegin", testRequestBegin) - t.Run("testRequestUpdate", testRequestUpdate) - t.Run("testRequestClose", testRequestClose) -} - -func testRequestBegin(t *testing.T) { - bundle, err := getTestBundle() - assert.NoError(t, err) - - entry := data.CommandEntry{ - Bundle: bundle, - Command: *bundle.Commands["echox"], - } - - req := data.CommandRequest{ - CommandEntry: entry, - Adapter: "testAdapter", - ChannelID: "testChannelID", - Parameters: []string{"foo", "bar"}, - Timestamp: time.Now(), - UserID: "testUserID ", - UserEmail: "testUserEmail", - UserName: "testUserName ", - } - - assert.Zero(t, req.RequestID) - - err = da.RequestBegin(ctx, &req) - assert.NoError(t, err) - - assert.NotZero(t, req.RequestID) - - err = da.RequestBegin(ctx, &req) - assert.Error(t, err) -} - -func testRequestUpdate(t *testing.T) { - bundle, err := getTestBundle() - assert.NoError(t, err) - - entry := data.CommandEntry{ - Bundle: bundle, - Command: *bundle.Commands["echox"], - } - - req := data.CommandRequest{ - CommandEntry: entry, - Adapter: "testAdapter", - ChannelID: "testChannelID", - Parameters: []string{"foo", "bar"}, - Timestamp: time.Now(), - UserID: "testUserID ", - UserEmail: "testUserEmail", - UserName: "testUserName ", - } - - err = da.RequestUpdate(ctx, req) - assert.Error(t, err) - - req.RequestID = 1 - - err = da.RequestUpdate(ctx, req) - assert.NoError(t, err) -} - -func testRequestClose(t *testing.T) { - bundle, err := getTestBundle() - assert.NoError(t, err) - - entry := data.CommandEntry{ - Bundle: bundle, - Command: *bundle.Commands["echox"], - } - - req := data.CommandRequest{ - CommandEntry: entry, - Adapter: "testAdapter", - ChannelID: "testChannelID", - Parameters: []string{"foo", "bar"}, - RequestID: 1, - Timestamp: time.Now(), - UserID: "testUserID ", - UserEmail: "testUserEmail", - UserName: "testUserName ", - } - - res := data.CommandResponse{ - Command: req, - Duration: time.Second, - Status: 1, - Error: fmt.Errorf("Fake error"), - } - - err = da.RequestClose(ctx, res) - assert.NoError(t, err) -} diff --git a/dataaccess/memory/role-access_test.go b/dataaccess/memory/role-access_test.go deleted file mode 100644 index ff413b9..0000000 --- a/dataaccess/memory/role-access_test.go +++ /dev/null @@ -1,417 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package memory - -import ( - "testing" - - "github.com/getgort/gort/data/rest" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" -) - -func testRoleAccess(t *testing.T) { - t.Run("testRoleCreate", testRoleCreate) - t.Run("testRoleList", testRoleList) - t.Run("testRoleExists", testRoleExists) - t.Run("testRoleDelete", testRoleDelete) - t.Run("testRoleGet", testRoleGet) - t.Run("testRoleGroupAdd", testRoleGroupAdd) - t.Run("testRoleGroupDelete", testRoleGroupDelete) - t.Run("testRoleGroupExists", testRoleGroupExists) - t.Run("testRoleGroupList", testRoleGroupList) - t.Run("testRolePermissionExists", testRolePermissionExists) - t.Run("testRolePermissionAdd", testRolePermissionAdd) - t.Run("testRolePermissionList", testRolePermissionList) -} - -func testRoleCreate(t *testing.T) { - var err error - - // Expect an error - err = da.RoleCreate(ctx, "") - assert.Error(t, err, errs.ErrEmptyRoleName) - - // Expect no error - err = da.RoleCreate(ctx, "test-create") - defer da.RoleDelete(ctx, "test-create") - assert.NoError(t, err) - - // Expect an error - err = da.RoleCreate(ctx, "test-create") - assert.Error(t, err, errs.ErrRoleExists) -} - -func testRoleList(t *testing.T) { - var err error - - // Get initial set of roles - roles, err := da.RoleList(ctx) - assert.NoError(t, err) - startingRoles := len(roles) - - // Create and populate role - rolename := "test-role-list" - bundle := "test-bundle-list" - permission := "test-permission-list" - err = da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - assert.NoError(t, err) - - err = da.RolePermissionAdd(ctx, rolename, bundle, permission) - assert.NoError(t, err) - - // Expect 1 new role - roles, err = da.RoleList(ctx) - assert.NoError(t, err) - if assert.Equal(t, startingRoles+1, len(roles)) { - assert.Equal(t, rolename, roles[startingRoles].Name) - assert.Equal(t, bundle, roles[startingRoles].Permissions[0].BundleName) - assert.Equal(t, permission, roles[startingRoles].Permissions[0].Permission) - } -} - -func testRoleDelete(t *testing.T) { - // Delete blank group - err := da.RoleDelete(ctx, "") - assert.Error(t, err, errs.ErrEmptyRoleName) - - // Delete group that doesn't exist - err = da.RoleDelete(ctx, "no-such-group") - assert.Error(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, "test-delete") // This has its own test - defer da.RoleDelete(ctx, "test-delete") - - err = da.RoleDelete(ctx, "test-delete") - assert.NoError(t, err) - - exists, _ := da.RoleExists(ctx, "test-delete") - if exists { - t.Error("Shouldn't exist anymore!") - t.FailNow() - } -} - -func testRoleExists(t *testing.T) { - var exists bool - - exists, _ = da.RoleExists(ctx, "test-exists") - if exists { - t.Error("Role should not exist now") - t.FailNow() - } - - // Now we add a group to find. - da.RoleCreate(ctx, "test-exists") - defer da.RoleDelete(ctx, "test-exists") - - exists, _ = da.RoleExists(ctx, "test-exists") - if !exists { - t.Error("Role should exist now") - t.FailNow() - } -} - -func testRoleGet(t *testing.T) { - var err error - var role rest.Role - - // Expect an error - _, err = da.RoleGet(ctx, "") - assert.Error(t, err, errs.ErrEmptyRoleName) - - // Expect an error - _, err = da.RoleGet(ctx, "test-get") - assert.Error(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, "test-get") - defer da.RoleDelete(ctx, "test-get") - - // da.Role ctx, should exist now - exists, _ := da.RoleExists(ctx, "test-get") - if !exists { - t.Error("Role should exist now") - t.FailNow() - } - - err = da.RolePermissionAdd(ctx, "test-get", "foo", "bar") - assert.NoError(t, err) - - err = da.RolePermissionAdd(ctx, "test-get", "foo", "bat") - assert.NoError(t, err) - - err = da.RolePermissionDelete(ctx, "test-get", "foo", "bat") - assert.NoError(t, err) - - expected := rest.Role{ - Name: "test-get", - Permissions: []rest.RolePermission{{BundleName: "foo", Permission: "bar"}}, - } - - // Expect no error - role, err = da.RoleGet(ctx, "test-get") - assert.NoError(t, err) - assert.Equal(t, expected, role) -} - -func testRoleGroupAdd(t *testing.T) { - var err error - - rolename := "role-test-role-group-add" - groupnames := []string{ - "perm-test-role-group-add-0", - "perm-test-role-group-add-1", - } - - // No such group yet - err = da.RoleGroupAdd(ctx, rolename, groupnames[1]) - assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) - defer da.GroupDelete(ctx, groupnames[0]) - da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) - defer da.GroupDelete(ctx, groupnames[1]) - - // Groups exist now, but the role doesn't - err = da.RoleGroupAdd(ctx, rolename, groupnames[1]) - assert.ErrorIs(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - for _, groupname := range groupnames { - err = da.RoleGroupAdd(ctx, rolename, groupname) - assert.NoError(t, err) - } - - for _, groupname := range groupnames { - exists, _ := da.RoleGroupExists(ctx, rolename, groupname) - assert.True(t, exists, groupname) - } -} - -func testRoleGroupDelete(t *testing.T) { - -} - -func testRoleGroupExists(t *testing.T) { - var err error - - rolename := "role-test-role-group-exists" - groupnames := []string{ - "group-test-role-group-exists-0", - "group-test-role-group-exists-1", - } - groupnull := "group-test-role-group-exists-null" - - // No such role yet - _, err = da.RoleGroupExists(ctx, rolename, groupnames[1]) - assert.ErrorIs(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - // Groups exist now, but the role doesn't - _, err = da.RoleGroupExists(ctx, rolename, groupnames[1]) - assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) - defer da.GroupDelete(ctx, groupnames[0]) - da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) - defer da.GroupDelete(ctx, groupnames[1]) - da.GroupCreate(ctx, rest.Group{Name: groupnull}) - defer da.GroupDelete(ctx, groupnull) - - for _, groupname := range groupnames { - da.RoleGroupAdd(ctx, rolename, groupname) - } - - for _, groupname := range groupnames { - exists, err := da.RoleGroupExists(ctx, rolename, groupname) - assert.NoError(t, err) - assert.True(t, exists) - } - - // Null group should NOT exist on the role - exists, err := da.RoleGroupExists(ctx, rolename, groupnull) - assert.NoError(t, err) - assert.False(t, exists) -} - -func testRoleGroupList(t *testing.T) { - var err error - - rolename := "role-test-role-group-list" - groupnames := []string{ - "group-test-role-group-list-0", - "group-test-role-group-list-1", - } - groupnull := "group-test-role-group-list-null" - - // No such role yet - _, err = da.RoleGroupList(ctx, rolename) - assert.ErrorIs(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - // Groups exist now, but the role doesn't - groups, err := da.RoleGroupList(ctx, rolename) - assert.NoError(t, err) - assert.Empty(t, groups) - - da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) - defer da.GroupDelete(ctx, groupnames[1]) - da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) - defer da.GroupDelete(ctx, groupnames[0]) - da.GroupCreate(ctx, rest.Group{Name: groupnull}) - defer da.GroupDelete(ctx, groupnull) - - for _, groupname := range groupnames { - da.RoleGroupAdd(ctx, rolename, groupname) - } - - // Currently the groups are NOT expected to be fully described (i.e., - // their roles slices don't have to be complete). - groups, err = da.RoleGroupList(ctx, rolename) - assert.NoError(t, err) - assert.Len(t, groups, 2) - - for i, g := range groups { - assert.Equal(t, groupnames[i], g.Name) - } -} - -func testRolePermissionAdd(t *testing.T) { - var exists bool - var err error - - const rolename = "role-test-role-permission-add" - const bundlename = "test" - const permname1 = "perm-test-role-permission-add-0" - const permname2 = "perm-test-role-permission-add-1" - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - role, _ := da.RoleGet(ctx, rolename) - if !assert.Len(t, role.Permissions, 0) { - t.FailNow() - } - - // First permission - err = da.RolePermissionAdd(ctx, rolename, bundlename, permname1) - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, rolename, bundlename, permname1) - - role, _ = da.RoleGet(ctx, rolename) - if !assert.Len(t, role.Permissions, 1) { - t.FailNow() - } - - exists, _ = da.RolePermissionExists(ctx, rolename, bundlename, permname1) - if !assert.True(t, exists) { - t.FailNow() - } - - // Second permission - err = da.RolePermissionAdd(ctx, rolename, bundlename, permname2) - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, rolename, bundlename, permname2) - - role, _ = da.RoleGet(ctx, rolename) - if !assert.Len(t, role.Permissions, 2) { - t.FailNow() - } - - exists, _ = da.RolePermissionExists(ctx, rolename, bundlename, permname2) - if !assert.True(t, exists) { - t.FailNow() - } -} - -func testRolePermissionExists(t *testing.T) { - var err error - - da.RoleCreate(ctx, "role-test-role-has-permission") - defer da.RoleDelete(ctx, "role-test-role-has-permission") - - has, err := da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - if !assert.NoError(t, err) || !assert.False(t, has) { - t.FailNow() - } - - err = da.RolePermissionAdd(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - - has, err = da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - if !assert.NoError(t, err) || !assert.True(t, has) { - t.FailNow() - } - - has, err = da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-2") - if !assert.NoError(t, err) || !assert.False(t, has) { - t.FailNow() - } -} - -func testRolePermissionList(t *testing.T) { - var err error - - da.RoleCreate(ctx, "role-test-role-permission-list") - defer da.RoleDelete(ctx, "role-test-role-permission-list") - - err = da.RolePermissionAdd(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-1") - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-1") - - err = da.RolePermissionAdd(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-3") - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-3") - - err = da.RolePermissionAdd(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-2") - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-2") - - // Expect a sorted list! - expect := rest.RolePermissionList{ - {BundleName: "test", Permission: "permission-test-role-permission-list-1"}, - {BundleName: "test", Permission: "permission-test-role-permission-list-2"}, - {BundleName: "test", Permission: "permission-test-role-permission-list-3"}, - } - - actual, err := da.RolePermissionList(ctx, "role-test-role-permission-list") - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expect, actual) -} diff --git a/dataaccess/memory/token-access_test.go b/dataaccess/memory/token-access_test.go deleted file mode 100644 index aee8adb..0000000 --- a/dataaccess/memory/token-access_test.go +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package memory - -import ( - "testing" - "time" - - "github.com/getgort/gort/data/rest" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" -) - -func testTokenAccess(t *testing.T) { - t.Run("testTokenGenerate", testTokenGenerate) - t.Run("testTokenRetrieveByUser", testTokenRetrieveByUser) - t.Run("testTokenRetrieveByToken", testTokenRetrieveByToken) - t.Run("testTokenExpiry", testTokenExpiry) - t.Run("testTokenInvalidate", testTokenInvalidate) -} - -func testTokenGenerate(t *testing.T) { - err := da.UserCreate(ctx, rest.User{Username: "test_generate"}) - defer da.UserDelete(ctx, "test_generate") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_generate", 10*time.Minute) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - if token.Duration != 10*time.Minute { - t.Errorf("Duration mismatch: %v vs %v\n", token.Duration, 10*time.Minute) - t.FailNow() - } - - if token.User != "test_generate" { - t.Error("User mismatch") - t.FailNow() - } - - if token.ValidFrom.Add(10*time.Minute) != token.ValidUntil { - t.Error("Validity duration mismatch") - t.FailNow() - } -} - -func testTokenRetrieveByUser(t *testing.T) { - _, err := da.TokenRetrieveByUser(ctx, "no-such-user") - assert.Error(t, err, errs.ErrNoSuchToken) - - err = da.UserCreate(ctx, rest.User{Username: "test_uretrieve", Email: "test_uretrieve"}) - defer da.UserDelete(ctx, "test_uretrieve") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_uretrieve", 10*time.Minute) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - rtoken, err := da.TokenRetrieveByUser(ctx, "test_uretrieve") - assert.NoError(t, err) - - if token.Token != rtoken.Token { - t.Error("token mismatch") - t.FailNow() - } -} - -func testTokenRetrieveByToken(t *testing.T) { - _, err := da.TokenRetrieveByToken(ctx, "no-such-token") - assert.Error(t, err, errs.ErrNoSuchToken) - - err = da.UserCreate(ctx, rest.User{Username: "test_tretrieve", Email: "test_tretrieve"}) - defer da.UserDelete(ctx, "test_tretrieve") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_tretrieve", 10*time.Minute) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - rtoken, err := da.TokenRetrieveByToken(ctx, token.Token) - assert.NoError(t, err) - - if token.Token != rtoken.Token { - t.Error("token mismatch") - t.FailNow() - } -} - -func testTokenExpiry(t *testing.T) { - err := da.UserCreate(ctx, rest.User{Username: "test_expires", Email: "test_expires"}) - defer da.UserDelete(ctx, "test_expires") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_expires", 1*time.Second) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - if token.IsExpired() { - t.Error("Expected token to be unexpired") - t.FailNow() - } - - time.Sleep(time.Second) - - if !token.IsExpired() { - t.Error("Expected token to be expired") - t.FailNow() - } -} - -func testTokenInvalidate(t *testing.T) { - err := da.UserCreate(ctx, rest.User{Username: "test_invalidate", Email: "test_invalidate"}) - defer da.UserDelete(ctx, "test_invalidate") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_invalidate", 10*time.Minute) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - if !da.TokenEvaluate(ctx, token.Token) { - t.Error("Expected token to be valid") - t.FailNow() - } - - err = da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - if da.TokenEvaluate(ctx, token.Token) { - t.Error("Expected token to be invalid") - t.FailNow() - } -} diff --git a/dataaccess/memory/user-access_test.go b/dataaccess/memory/user-access_test.go deleted file mode 100644 index 20b756c..0000000 --- a/dataaccess/memory/user-access_test.go +++ /dev/null @@ -1,440 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package memory - -import ( - "testing" - - "github.com/getgort/gort/data/rest" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func testUserAccess(t *testing.T) { - t.Run("testUserAuthenticate", testUserAuthenticate) - t.Run("testUserCreate", testUserCreate) - t.Run("testUserDelete", testUserDelete) - t.Run("testUserExists", testUserExists) - t.Run("testUserGet", testUserGet) - t.Run("testUserGetByEmail", testUserGetByEmail) - t.Run("testUserGetByID", testUserGetByID) - t.Run("testUserGetNoMappings", testUserGetNoMappings) - t.Run("testUserGroupList", testUserGroupList) - t.Run("testUserList", testUserList) - t.Run("testUserNotExists", testUserNotExists) - t.Run("testUserPermissionList", testUserPermissionList) - t.Run("testUserUpdate", testUserUpdate) -} - -func testUserAuthenticate(t *testing.T) { - var err error - - authenticated, err := da.UserAuthenticate(ctx, "test-auth", "no-match") - assert.Error(t, err, errs.ErrNoSuchUser) - if authenticated { - t.Error("Expected false") - t.FailNow() - } - - // Expect no error - err = da.UserCreate(ctx, rest.User{ - Username: "test-auth", - Email: "test-auth@bar.com", - Password: "password", - }) - defer da.UserDelete(ctx, "test-auth") - assert.NoError(t, err) - - authenticated, err = da.UserAuthenticate(ctx, "test-auth", "no-match") - assert.NoError(t, err) - if authenticated { - t.Error("Expected false") - t.FailNow() - } - - authenticated, err = da.UserAuthenticate(ctx, "test-auth", "password") - assert.NoError(t, err) - if !authenticated { - t.Error("Expected true") - t.FailNow() - } -} - -func testUserCreate(t *testing.T) { - var err error - var user rest.User - - // Expect an error - err = da.UserCreate(ctx, user) - assert.Error(t, err, errs.ErrEmptyUserName) - - // Expect no error - err = da.UserCreate(ctx, rest.User{Username: "test-create", Email: "test-create@bar.com"}) - defer da.UserDelete(ctx, "test-create") - assert.NoError(t, err) - - // Expect an error - err = da.UserCreate(ctx, rest.User{Username: "test-create", Email: "test-create@bar.com"}) - assert.Error(t, err, errs.ErrUserExists) -} - -func testUserDelete(t *testing.T) { - // Delete blank user - err := da.UserDelete(ctx, "") - assert.Error(t, err, errs.ErrEmptyUserName) - - // Delete admin user - err = da.UserDelete(ctx, "admin") - assert.Error(t, err, errs.ErrAdminUndeletable) - - // Delete user that doesn't exist - err = da.UserDelete(ctx, "no-such-user") - assert.Error(t, err, errs.ErrNoSuchUser) - - user := rest.User{Username: "test-delete", Email: "foo1.example.com"} - da.UserCreate(ctx, user) // This has its own test - defer da.UserDelete(ctx, "test-delete") - - err = da.UserDelete(ctx, "test-delete") - assert.NoError(t, err) - - exists, _ := da.UserExists(ctx, "test-delete") - if exists { - t.Error("Shouldn't exist anymore!") - t.FailNow() - } -} - -func testUserExists(t *testing.T) { - var exists bool - - exists, _ = da.UserExists(ctx, "test-exists") - if exists { - t.Error("User should not exist now") - t.FailNow() - } - - // Now we add a user to find. - err := da.UserCreate(ctx, rest.User{Username: "test-exists", Email: "test-exists@bar.com"}) - defer da.UserDelete(ctx, "test-exists") - assert.NoError(t, err) - - exists, _ = da.UserExists(ctx, "test-exists") - if !exists { - t.Error("User should exist now") - t.FailNow() - } -} - -func testUserGet(t *testing.T) { - const userName = "test-get" - const userEmail = "test-get@foo.com" - const userAdapter = "slack-get" - const userAdapterID = "U12345-get" - - var err error - var user rest.User - - // Expect an error - _, err = da.UserGet(ctx, "") - assert.EqualError(t, err, errs.ErrEmptyUserName.Error()) - - // Expect an error - _, err = da.UserGet(ctx, userName) - assert.EqualError(t, err, errs.ErrNoSuchUser.Error()) - - // Create the test user - err = da.UserCreate(ctx, rest.User{ - Username: userName, - Email: userEmail, - Mappings: map[string]string{userAdapter: userAdapterID}, - }) - defer da.UserDelete(ctx, userName) - require.NoError(t, err) - - // User should exist now - exists, err := da.UserExists(ctx, userName) - require.NoError(t, err) - require.True(t, exists) - - // Expect no error - user, err = da.UserGet(ctx, userName) - require.NoError(t, err) - require.Equal(t, user.Username, userName) - require.Equal(t, user.Email, userEmail) - require.NotNil(t, user.Mappings) - require.Equal(t, userAdapterID, user.Mappings[userAdapter]) -} - -func testUserGetNoMappings(t *testing.T) { - const userName = "test-get-no-mappings" - const userEmail = "test-get-no-mappings@foo.com" - - var err error - var user rest.User - - // Create the test user - err = da.UserCreate(ctx, rest.User{Username: userName, Email: userEmail}) - defer da.UserDelete(ctx, userName) - require.NoError(t, err) - - // Expect no error - user, err = da.UserGet(ctx, userName) - require.NoError(t, err) - require.Equal(t, user.Username, userName) - require.Equal(t, user.Email, userEmail) - require.NotNil(t, user.Mappings) -} - -func testUserGetByEmail(t *testing.T) { - const userName = "test-get-by-email" - const userEmail = "test-get-by-email@foo.com" - const userAdapter = "slack-get-by-email" - const userAdapterID = "U12345-get-by-email" - - var err error - var user rest.User - - // Expect an error - _, err = da.UserGetByEmail(ctx, "") - assert.EqualError(t, err, errs.ErrEmptyUserEmail.Error()) - - // Expect an error - _, err = da.UserGetByEmail(ctx, userEmail) - assert.EqualError(t, err, errs.ErrNoSuchUser.Error()) - - // Create the test user - err = da.UserCreate(ctx, rest.User{ - Username: userName, - Email: userEmail, - Mappings: map[string]string{userAdapter: userAdapterID}, - }) - defer da.UserDelete(ctx, userName) - require.NoError(t, err) - - // User should exist now - exists, err := da.UserExists(ctx, userName) - require.NoError(t, err) - require.True(t, exists) - - // Expect no error - user, err = da.UserGetByEmail(ctx, userEmail) - require.NoError(t, err) - require.Equal(t, user.Username, userName) - require.Equal(t, user.Email, userEmail) - require.NotNil(t, user.Mappings) - require.Equal(t, userAdapterID, user.Mappings[userAdapter]) -} - -func testUserGetByID(t *testing.T) { - const userName = "test-get-by-id" - const userEmail = "test-get-by-id@foo.com" - const userAdapter = "slack-get-by-id" - const userAdapterID = "U12345-get-by-id" - - var err error - var user rest.User - - // Expect errors - _, err = da.UserGetByID(ctx, "", userAdapterID) - assert.EqualError(t, err, errs.ErrEmptyUserAdapter.Error()) - - _, err = da.UserGetByID(ctx, userAdapter, "") - assert.EqualError(t, err, errs.ErrEmptyUserID.Error()) - - _, err = da.UserGetByID(ctx, userAdapter, userAdapterID) - assert.EqualError(t, err, errs.ErrNoSuchUser.Error()) - - // Create the test user - err = da.UserCreate(ctx, rest.User{ - Username: userName, - Email: userEmail, - Mappings: map[string]string{userAdapter: userAdapterID}, - }) - defer da.UserDelete(ctx, userName) - require.NoError(t, err) - - // User should exist now - exists, err := da.UserExists(ctx, userName) - require.NoError(t, err) - require.True(t, exists) - - // Expect no error - user, err = da.UserGetByID(ctx, userAdapter, userAdapterID) - require.NoError(t, err) - require.Equal(t, user.Username, userName) - require.Equal(t, user.Email, userEmail) - require.NotNil(t, user.Mappings) - require.Equal(t, userAdapterID, user.Mappings[userAdapter]) -} - -func testUserGroupList(t *testing.T) { - da.GroupCreate(ctx, rest.Group{Name: "group-test-user-group-list-0"}) - defer da.GroupDelete(ctx, "group-test-user-group-list-0") - - da.GroupCreate(ctx, rest.Group{Name: "group-test-user-group-list-1"}) - defer da.GroupDelete(ctx, "group-test-user-group-list-1") - - da.UserCreate(ctx, rest.User{Username: "user-test-user-group-list"}) - defer da.UserDelete(ctx, "user-test-user-group-list") - - da.GroupUserAdd(ctx, "group-test-user-group-list-0", "user-test-user-group-list") - - expected := []rest.Group{{Name: "group-test-user-group-list-0", Users: nil}} - - actual, err := da.UserGroupList(ctx, "user-test-user-group-list") - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expected, actual) -} - -func testUserList(t *testing.T) { - da.UserCreate(ctx, rest.User{Username: "test-list-0", Password: "password0!", Email: "test-list-0"}) - defer da.UserDelete(ctx, "test-list-0") - da.UserCreate(ctx, rest.User{Username: "test-list-1", Password: "password1!", Email: "test-list-1"}) - defer da.UserDelete(ctx, "test-list-1") - da.UserCreate(ctx, rest.User{Username: "test-list-2", Password: "password2!", Email: "test-list-2"}) - defer da.UserDelete(ctx, "test-list-2") - da.UserCreate(ctx, rest.User{Username: "test-list-3", Password: "password3!", Email: "test-list-3"}) - defer da.UserDelete(ctx, "test-list-3") - - users, err := da.UserList(ctx) - assert.NoError(t, err) - - if len(users) != 4 { - for i, u := range users { - t.Logf("User %d: %v\n", i+1, u) - } - - t.Errorf("Expected len(users) = 4; got %d", len(users)) - t.FailNow() - } - - for _, u := range users { - if u.Password != "" { - t.Error("Expected empty password") - t.FailNow() - } - - if u.Username == "" { - t.Error("Expected non-empty username") - t.FailNow() - } - } -} - -func testUserNotExists(t *testing.T) { - var exists bool - - err := da.Initialize(ctx) - assert.NoError(t, err) - - exists, _ = da.UserExists(ctx, "test-not-exists") - if exists { - t.Error("User should not exist now") - t.FailNow() - } -} - -func testUserPermissionList(t *testing.T) { - var err error - - err = da.GroupCreate(ctx, rest.Group{Name: "test-perms"}) - defer da.GroupDelete(ctx, "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - - err = da.UserCreate(ctx, rest.User{Username: "test-perms", Password: "password0!", Email: "test-perms"}) - defer da.UserDelete(ctx, "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.GroupUserAdd(ctx, "test-perms", "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - - da.RoleCreate(ctx, "test-perms") - defer da.RoleDelete(ctx, "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.GroupRoleAdd(ctx, "test-perms", "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - - err = da.RolePermissionAdd(ctx, "test-perms", "test", "test-perms-1") - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.RolePermissionAdd(ctx, "test-perms", "test", "test-perms-2") - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.RolePermissionAdd(ctx, "test-perms", "test", "test-perms-0") - if !assert.NoError(t, err) { - t.FailNow() - } - - // Expected: a sorted list of strings - expected := []string{"test:test-perms-0", "test:test-perms-1", "test:test-perms-2"} - - actual, err := da.UserPermissionList(ctx, "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expected, actual.Strings()) -} - -func testUserUpdate(t *testing.T) { - // Update blank user - err := da.UserUpdate(ctx, rest.User{}) - assert.Error(t, err, errs.ErrEmptyUserName) - - // Update user that doesn't exist - err = da.UserUpdate(ctx, rest.User{Username: "no-such-user"}) - assert.Error(t, err, errs.ErrNoSuchUser) - - userA := rest.User{Username: "test-update", Email: "foo1.example.com"} - da.UserCreate(ctx, userA) - defer da.UserDelete(ctx, "test-update") - - // Get the user we just added. Emails should match. - user1, _ := da.UserGet(ctx, "test-update") - if userA.Email != user1.Email { - t.Errorf("Email mismatch: %q vs %q", userA.Email, user1.Email) - t.FailNow() - } - - // Do the update - userB := rest.User{Username: "test-update", Email: "foo2.example.com"} - err = da.UserUpdate(ctx, userB) - assert.NoError(t, err) - - // Get the user we just updated. Emails should match. - user2, _ := da.UserGet(ctx, "test-update") - if userB.Email != user2.Email { - t.Errorf("Email mismatch: %q vs %q", userB.Email, user2.Email) - t.FailNow() - } -} diff --git a/dataaccess/postgres/base_test.go b/dataaccess/postgres/base_test.go index a9d4356..48cf7b2 100644 --- a/dataaccess/postgres/base_test.go +++ b/dataaccess/postgres/base_test.go @@ -25,11 +25,13 @@ import ( "testing" "time" + "github.com/getgort/gort/data" + "github.com/getgort/gort/dataaccess/tests" + "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" - "github.com/getgort/gort/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -54,7 +56,7 @@ var DoNotCleanUpDatabase = false func TestPostgresDataAccessMain(t *testing.T) { ctx, cancel = context.WithTimeout(context.Background(), time.Minute) - // defer cancel() + defer cancel() if testing.Short() { t.Skip("skipping test in short mode.") @@ -70,12 +72,9 @@ func TestPostgresDataAccessMain(t *testing.T) { require.NoError(t, err, "failed to start database container") t.Run("testInitialize", testInitialize) - t.Run("testUserAccess", testUserAccess) - t.Run("testGroupAccess", testGroupAccess) - t.Run("testTokenAccess", testTokenAccess) - t.Run("testBundleAccess", testBundleAccess) - t.Run("testRoleAccess", testRoleAccess) - t.Run("testRequestAccess", testRequestAccess) + + dat := tests.NewDataAccessTester(ctx, cancel, da) + t.Run("RunAllTests", dat.RunAllTests) } func startDatabaseContainer(ctx context.Context, t *testing.T) (func(), error) { diff --git a/dataaccess/postgres/bundle-access.go b/dataaccess/postgres/bundle-access.go index 4192c9f..6f138f7 100644 --- a/dataaccess/postgres/bundle-access.go +++ b/dataaccess/postgres/bundle-access.go @@ -94,6 +94,20 @@ func (da PostgresDataAccess) BundleCreate(ctx context.Context, bundle data.Bundl return err } + // Save templates + err = da.doBundleInsertTemplates(ctx, tx, bundle) + if err != nil { + tx.Rollback() + return err + } + + // Save kubernetes config + err = da.doBundleInsertKubernetes(ctx, tx, bundle) + if err != nil { + tx.Rollback() + return err + } + err = tx.Commit() if err != nil { tx.Rollback() @@ -493,12 +507,18 @@ func (da PostgresDataAccess) FindCommandEntry(ctx context.Context, bundleName, c } func (da PostgresDataAccess) doBundleDelete(ctx context.Context, tx *sql.Tx, name string, version string) error { - query := "DELETE FROM bundle_command_rules WHERE bundle_name=$1 AND bundle_version=$2;" + query := "DELETE FROM bundle_kubernetes WHERE bundle_name=$1 AND bundle_version=$2;" _, err := tx.ExecContext(ctx, query, name, version) if err != nil { return gerr.Wrap(errs.ErrDataAccess, err) } + query = "DELETE FROM bundle_command_rules WHERE bundle_name=$1 AND bundle_version=$2;" + _, err = tx.ExecContext(ctx, query, name, version) + if err != nil { + return gerr.Wrap(errs.ErrDataAccess, err) + } + query = "DELETE FROM bundle_permissions WHERE bundle_name=$1 AND bundle_version=$2;" _, err = tx.ExecContext(ctx, query, name, version) if err != nil { @@ -589,23 +609,69 @@ func (da PostgresDataAccess) doBundleExists(ctx context.Context, tx *sql.Tx, nam return exists, nil } +// doFindCommandEntry returns all command entries for any enabled bundle +// matching the specified bundle and command names. The bundle parameter may be +// empty, in which case it will match all bundles. +func (da PostgresDataAccess) doFindCommandEntry(ctx context.Context, tx *sql.Tx, bundle, command string) ([]data.CommandEntry, error) { + bcd, err := da.doBundleGetCommandsData(ctx, tx, bundle, "", command, true) + if err != nil { + return nil, gerr.Wrap(errs.ErrDataAccess, err) + } + + entries := make([]data.CommandEntry, 0) + + for _, cd := range bcd { + entry := data.CommandEntry{} + + // Load the appropriate bundle + entry.Bundle, err = da.doBundleGet(ctx, tx, cd.BundleName, cd.BundleVersion) + if err != nil { + return nil, gerr.Wrap(errs.ErrDataAccess, err) + } + + // Load the relevant bundle command (there should be exactly one) + commands, err := da.doBundleGetCommands(ctx, tx, cd.BundleName, cd.BundleVersion, cd.Name) + if err != nil { + return nil, gerr.Wrap(errs.ErrDataAccess, err) + } + if len(commands) != 1 { + return nil, gerr.Wrap(errs.ErrDataAccess, fmt.Errorf("unexpected commands count: %d", len(commands))) + } + entry.Command = *commands[0] + + entries = append(entries, entry) + } + + return entries, nil +} + func (da PostgresDataAccess) doBundleGet(ctx context.Context, tx *sql.Tx, name string, version string) (data.Bundle, error) { query := `SELECT gort_bundle_version, name, version, author, homepage, - description, long_description, docker_image, docker_tag, + description, long_description, image_repository, image_tag, install_timestamp, install_user FROM bundles WHERE name=$1 AND version=$2` + var repository, tag string + bundle := data.Bundle{} row := tx.QueryRowContext(ctx, query, name, version) err := row.Scan(&bundle.GortBundleVersion, &bundle.Name, &bundle.Version, &bundle.Author, &bundle.Homepage, &bundle.Description, - &bundle.LongDescription, &bundle.Docker.Image, &bundle.Docker.Tag, + &bundle.LongDescription, &repository, &tag, &bundle.InstalledOn, &bundle.InstalledBy) if err != nil { return bundle, gerr.Wrap(errs.ErrNoSuchBundle, err) } + if repository != "" { + if tag == "" { + tag = "latest" + } + + bundle.Image = repository + ":" + tag + } + enabledVersion, err := da.doBundleEnabledVersion(ctx, tx, name) if err != nil { return bundle, gerr.Wrap(fmt.Errorf("failed to get bundle enabled version"), err) @@ -613,30 +679,39 @@ func (da PostgresDataAccess) doBundleGet(ctx context.Context, tx *sql.Tx, name s bundle.Enabled = (bundle.Version == enabledVersion) // Load bundle permissions - bundle.Permissions, err = da.doBundlePermissionsGet(ctx, tx, name, version) + bundle.Permissions, err = da.doBundleGetPermissions(ctx, tx, name, version) if err != nil { return bundle, gerr.Wrap(fmt.Errorf("failed to get bundle permissions"), err) } // Load all commands (and their rules) for this bundle - commandSlice, err := da.doBundleCommandsGet(ctx, tx, name, version, "") + commandSlice, err := da.doBundleGetCommands(ctx, tx, name, version, "") if err != nil { return bundle, gerr.Wrap(fmt.Errorf("failed to get bundle commands"), err) } bundle.Commands = make(map[string]*data.BundleCommand) - for _, command := range commandSlice { bundle.Commands[command.Name] = command } + bundle.Templates, err = da.doBundleGetTemplates(ctx, tx, name, version) + if err != nil { + return bundle, gerr.Wrap(fmt.Errorf("failed to get bundle templates"), err) + } + + bundle.Kubernetes, err = da.doBundleGetKubernetes(ctx, tx, name, version) + if err != nil { + return bundle, gerr.Wrap(fmt.Errorf("failed to get bundle kubernetes config"), err) + } + return bundle, nil } -// doBundleCommandsDataGet is a helper method that retrieves zero or more +// doBundleGetCommandsData is a helper method that retrieves zero or more // commands for the specified bundle name+version, along with the owning // bundle's name and version. Empty string parameters are treated as wildcards. -func (da PostgresDataAccess) doBundleCommandsDataGet(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion, commandName string, enabledOnly bool) ([]bundleCommandData, error) { +func (da PostgresDataAccess) doBundleGetCommandsData(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion, commandName string, enabledOnly bool) ([]bundleCommandData, error) { var query string if bundleName == "" { @@ -684,9 +759,9 @@ func (da PostgresDataAccess) doBundleCommandsDataGet(ctx context.Context, tx *sq return commands, nil } -// doBundleCommandGet empty strings become wildcards -func (da PostgresDataAccess) doBundleCommandsGet(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion, commandName string) ([]*data.BundleCommand, error) { - bcd, err := da.doBundleCommandsDataGet(ctx, tx, bundleName, bundleVersion, commandName, false) +// doBundleGetCommands empty strings become wildcards +func (da PostgresDataAccess) doBundleGetCommands(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion, commandName string) ([]*data.BundleCommand, error) { + bcd, err := da.doBundleGetCommandsData(ctx, tx, bundleName, bundleVersion, commandName, false) if err != nil { return nil, gerr.Wrap(errs.ErrDataAccess, err) } @@ -694,55 +769,24 @@ func (da PostgresDataAccess) doBundleCommandsGet(ctx context.Context, tx *sql.Tx commands := make([]*data.BundleCommand, 0) for _, bc := range bcd { - bc.BundleCommand.Rules, err = da.doBundleCommandRulesGet(ctx, tx, bundleName, bundleVersion, bc.Name) + bc.BundleCommand.Rules, err = da.doBundleGetCommandRules(ctx, tx, bundleName, bundleVersion, bc.Name) if err != nil { return nil, gerr.Wrap(fmt.Errorf("failed to get bundle command rules"), err) } - command := bc.BundleCommand - commands = append(commands, &command) - } - - return commands, nil -} - -// doFindCommandEntry returns all command entries for any enabled bundle -// matching the specified bundle and command names. The bundle parameter may be -// empty, in which case it will match all bundles. -func (da PostgresDataAccess) doFindCommandEntry(ctx context.Context, tx *sql.Tx, bundle, command string) ([]data.CommandEntry, error) { - bcd, err := da.doBundleCommandsDataGet(ctx, tx, bundle, "", command, true) - if err != nil { - return nil, gerr.Wrap(errs.ErrDataAccess, err) - } - - entries := make([]data.CommandEntry, 0) - - for _, cd := range bcd { - entry := data.CommandEntry{} - - // Load the appropriate bundle - entry.Bundle, err = da.doBundleGet(ctx, tx, cd.BundleName, cd.BundleVersion) + bc.BundleCommand.Templates, err = da.doBundleGetCommandTemplates(ctx, tx, bundleName, bundleVersion, bc.Name) if err != nil { - return nil, gerr.Wrap(errs.ErrDataAccess, err) + return nil, gerr.Wrap(fmt.Errorf("failed to get bundle command templates"), err) } - // Load the relevant bundle command (there should be exactly one) - commands, err := da.doBundleCommandsGet(ctx, tx, cd.BundleName, cd.BundleVersion, cd.Name) - if err != nil { - return nil, gerr.Wrap(errs.ErrDataAccess, err) - } - if len(commands) != 1 { - return nil, gerr.Wrap(errs.ErrDataAccess, fmt.Errorf("unexpected commands count: %d", len(commands))) - } - entry.Command = *commands[0] - - entries = append(entries, entry) + command := bc.BundleCommand + commands = append(commands, &command) } - return entries, nil + return commands, nil } -func (da PostgresDataAccess) doBundleCommandRulesGet(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion, commandName string) ([]string, error) { +func (da PostgresDataAccess) doBundleGetCommandRules(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion, commandName string) ([]string, error) { cmdQuery := `SELECT rule FROM bundle_command_rules WHERE bundle_name=$1 AND bundle_version=$2 AND command_name=$3` @@ -768,7 +812,47 @@ func (da PostgresDataAccess) doBundleCommandRulesGet(ctx context.Context, tx *sq return rules, nil } -func (da PostgresDataAccess) doBundlePermissionsGet(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion string) ([]string, error) { +func (da PostgresDataAccess) doBundleGetCommandTemplates(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion, commandName string) (data.Templates, error) { + query := `SELECT command, command_error, message, message_error + FROM bundle_command_templates + WHERE bundle_name=$1 AND bundle_version=$2 AND command_name=$3` + + var templates data.Templates + + err := tx.QueryRowContext(ctx, query, bundleName, bundleVersion, commandName). + Scan(&templates.Command, &templates.CommandError, &templates.Message, &templates.MessageError) + + switch { + case err == sql.ErrNoRows: + return data.Templates{}, errs.ErrNoSuchUser + case err != nil: + return data.Templates{}, gerr.Wrap(errs.ErrDataAccess, err) + } + + return templates, nil +} + +func (da PostgresDataAccess) doBundleGetKubernetes(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion string) (data.BundleKubernetes, error) { + query := `SELECT service_account_name + FROM bundle_kubernetes + WHERE bundle_name=$1 AND bundle_version=$2` + + var kubernetes data.BundleKubernetes + + err := tx.QueryRowContext(ctx, query, bundleName, bundleVersion). + Scan(&kubernetes.ServiceAccountName) + + switch { + case err == sql.ErrNoRows: + return data.BundleKubernetes{}, errs.ErrNoSuchUser + case err != nil: + return data.BundleKubernetes{}, gerr.Wrap(errs.ErrDataAccess, err) + } + + return kubernetes, nil +} + +func (da PostgresDataAccess) doBundleGetPermissions(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion string) ([]string, error) { // Load permissions query := `SELECT permission FROM bundle_permissions @@ -798,15 +882,36 @@ func (da PostgresDataAccess) doBundlePermissionsGet(ctx context.Context, tx *sql return permissions, nil } +func (da PostgresDataAccess) doBundleGetTemplates(ctx context.Context, tx *sql.Tx, bundleName, bundleVersion string) (data.Templates, error) { + query := `SELECT command, command_error, message, message_error FROM bundle_templates + WHERE bundle_name=$1 AND bundle_version=$2` + + var templates data.Templates + + err := tx.QueryRowContext(ctx, query, bundleName, bundleVersion). + Scan(&templates.Command, &templates.CommandError, &templates.Message, &templates.MessageError) + + switch { + case err == sql.ErrNoRows: + return data.Templates{}, errs.ErrNoSuchUser + case err != nil: + return data.Templates{}, gerr.Wrap(errs.ErrDataAccess, err) + } + + return templates, nil +} + func (da PostgresDataAccess) doBundleInsert(ctx context.Context, tx *sql.Tx, bundle data.Bundle) error { query := `INSERT INTO bundles (gort_bundle_version, name, version, author, - homepage, description, long_description, docker_image, - docker_tag, install_user) + homepage, description, long_description, image_repository, image_tag, + install_user) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);` + repository, tag := bundle.ImageFullParts() + _, err := tx.ExecContext(ctx, query, bundle.GortBundleVersion, bundle.Name, bundle.Version, bundle.Author, bundle.Homepage, bundle.Description, bundle.LongDescription, - bundle.Docker.Image, bundle.Docker.Tag, bundle.InstalledBy) + repository, tag, bundle.InstalledBy) if err != nil { if strings.Contains(err.Error(), "violates") { @@ -844,6 +949,30 @@ func (da PostgresDataAccess) doBundleInsertCommandRules(ctx context.Context, return nil } +func (da PostgresDataAccess) doBundleInsertCommandTemplates(ctx context.Context, + tx *sql.Tx, bundle data.Bundle, command *data.BundleCommand) error { + + query := `INSERT INTO bundle_command_templates + (bundle_name, bundle_version, command_name, command, command_error, message, message_error) + VALUES ($1, $2, $3, $4, $5, $6, $7);` + + _, err := tx.ExecContext(ctx, query, bundle.Name, bundle.Version, command.Name, + command.Templates.Command, command.Templates.CommandError, + command.Templates.Message, command.Templates.MessageError) + + if err != nil { + if strings.Contains(err.Error(), "violates") { + err = gerr.Wrap(errs.ErrFieldRequired, err) + } else { + err = gerr.Wrap(errs.ErrDataAccess, err) + } + + return err + } + + return nil +} + func (da PostgresDataAccess) doBundleInsertCommands(ctx context.Context, tx *sql.Tx, bundle data.Bundle) error { query := `INSERT INTO bundle_commands (bundle_name, bundle_version, name, description, executable, long_description) @@ -871,6 +1000,11 @@ func (da PostgresDataAccess) doBundleInsertCommands(ctx context.Context, tx *sql if err != nil { return err } + + err = da.doBundleInsertCommandTemplates(ctx, tx, bundle, cmd) + if err != nil { + return err + } } return nil @@ -897,6 +1031,49 @@ func (da PostgresDataAccess) doBundleInsertPermissions(ctx context.Context, tx * return nil } +func (da PostgresDataAccess) doBundleInsertTemplates(ctx context.Context, tx *sql.Tx, bundle data.Bundle) error { + query := `INSERT INTO bundle_templates + (bundle_name, bundle_version, command, command_error, message, message_error) + VALUES ($1, $2, $3, $4, $5, $6);` + + _, err := tx.ExecContext(ctx, query, bundle.Name, bundle.Version, + bundle.Templates.Command, bundle.Templates.CommandError, + bundle.Templates.Message, bundle.Templates.MessageError) + + if err != nil { + if strings.Contains(err.Error(), "violates") { + err = gerr.Wrap(errs.ErrFieldRequired, err) + } else { + err = gerr.Wrap(errs.ErrDataAccess, err) + } + + return err + } + + return nil +} + +func (da PostgresDataAccess) doBundleInsertKubernetes(ctx context.Context, tx *sql.Tx, bundle data.Bundle) error { + query := `INSERT INTO bundle_kubernetes + (bundle_name, bundle_version, service_account_name) + VALUES ($1, $2, $3);` + + _, err := tx.ExecContext(ctx, query, bundle.Name, bundle.Version, + bundle.Kubernetes.ServiceAccountName) + + if err != nil { + if strings.Contains(err.Error(), "violates") { + err = gerr.Wrap(errs.ErrFieldRequired, err) + } else { + err = gerr.Wrap(errs.ErrDataAccess, err) + } + + return err + } + + return nil +} + func decodeStringSlice(str string) []string { if str == "" { return []string{} diff --git a/dataaccess/postgres/bundle-access_test.go b/dataaccess/postgres/bundle-access_test.go deleted file mode 100644 index 5b97bf3..0000000 --- a/dataaccess/postgres/bundle-access_test.go +++ /dev/null @@ -1,455 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package postgres - -import ( - "testing" - - "github.com/getgort/gort/bundles" - "github.com/getgort/gort/data" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func testBundleAccess(t *testing.T) { - t.Run("testLoadTestData", testLoadTestData) - t.Run("testBundleCreate", testBundleCreate) - t.Run("testBundleCreateMissingRequired", testBundleCreateMissingRequired) - t.Run("testBundleEnable", testBundleEnable) - t.Run("testBundleEnableTwo", testBundleEnableTwo) - t.Run("testBundleExists", testBundleExists) - t.Run("testBundleDelete", testBundleDelete) - t.Run("testBundleDeleteDoesntDisable", testBundleDeleteDoesntDisable) - t.Run("testBundleGet", testBundleGet) - t.Run("testBundleList", testBundleList) - t.Run("testBundleVersionList", testBundleVersionList) - t.Run("testFindCommandEntry", testFindCommandEntry) -} - -// Fail-fast: can the test bundle be loaded? -func testLoadTestData(t *testing.T) { - b, err := getTestBundle() - assert.NoError(t, err) - - assert.NotEmpty(t, b.Commands) - assert.NotEmpty(t, b.Commands["echox"].Description) - assert.NotEmpty(t, b.Commands["echox"].Executable) - assert.NotEmpty(t, b.Commands["echox"].LongDescription) - assert.NotEmpty(t, b.Commands["echox"].Name) - assert.NotEmpty(t, b.Commands["echox"].Rules) -} - -func testBundleCreate(t *testing.T) { - // Expect an error - err := da.BundleCreate(ctx, data.Bundle{}) - if !assert.Error(t, err, errs.ErrEmptyBundleName) { - t.FailNow() - } - - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-create" - - // Expect no error - err = da.BundleCreate(ctx, bundle) - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - // Expect an error - err = da.BundleCreate(ctx, bundle) - if !assert.Error(t, err, errs.ErrBundleExists) { - t.FailNow() - } -} - -func testBundleCreateMissingRequired(t *testing.T) { - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-missing-required" - - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - - // GortBundleVersion - originalGortBundleVersion := bundle.GortBundleVersion - bundle.GortBundleVersion = 0 - err = da.BundleCreate(ctx, bundle) - if !assert.Error(t, err, errs.ErrFieldRequired) { - t.FailNow() - } - bundle.GortBundleVersion = originalGortBundleVersion - - // Description - originalDescription := bundle.Description - bundle.Description = "" - err = da.BundleCreate(ctx, bundle) - if !assert.Error(t, err, errs.ErrFieldRequired) { - t.FailNow() - } - bundle.Description = originalDescription -} - -func testBundleEnable(t *testing.T) { - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-enable" - - err = da.BundleCreate(ctx, bundle) - assert.NoError(t, err) - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - - // No version should be enabled - enabled, err := da.BundleEnabledVersion(ctx, bundle.Name) - assert.NoError(t, err) - if enabled != "" { - t.Error("Expected no version to be enabled") - t.FailNow() - } - - // Reload and verify enabled value is false - bundle, err = da.BundleGet(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - assert.False(t, bundle.Enabled) - - // Enable and verify - err = da.BundleEnable(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - enabled, err = da.BundleEnabledVersion(ctx, bundle.Name) - assert.NoError(t, err) - if enabled != bundle.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundle.Version, enabled) - t.FailNow() - } - - bundle, err = da.BundleGet(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - assert.True(t, bundle.Enabled) - - // Should now delete cleanly - err = da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) -} - -func testBundleEnableTwo(t *testing.T) { - bundleA, err := getTestBundle() - assert.NoError(t, err) - bundleA.Name = "test-enable-2" - bundleA.Version = "0.0.1" - - err = da.BundleCreate(ctx, bundleA) - assert.NoError(t, err) - defer da.BundleDelete(ctx, bundleA.Name, bundleA.Version) - - // Enable and verify - err = da.BundleEnable(ctx, bundleA.Name, bundleA.Version) - assert.NoError(t, err) - - enabled, err := da.BundleEnabledVersion(ctx, bundleA.Name) - assert.NoError(t, err) - - if enabled != bundleA.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundleA.Version, enabled) - t.FailNow() - } - - // Create a new version of the same bundle - - bundleB, err := getTestBundle() - assert.NoError(t, err) - bundleB.Name = bundleA.Name - bundleB.Version = "0.0.2" - - err = da.BundleCreate(ctx, bundleB) - assert.NoError(t, err) - defer da.BundleDelete(ctx, bundleB.Name, bundleB.Version) - - // BundleA should still be enabled - - enabled, err = da.BundleEnabledVersion(ctx, bundleA.Name) - assert.NoError(t, err) - - if enabled != bundleA.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundleA.Version, enabled) - t.FailNow() - } - - enabled, err = da.BundleEnabledVersion(ctx, bundleA.Name) - assert.NoError(t, err) - - if enabled != bundleA.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundleA.Version, enabled) - t.FailNow() - } - - // Enable and verify - err = da.BundleEnable(ctx, bundleB.Name, bundleB.Version) - assert.NoError(t, err) - - enabled, err = da.BundleEnabledVersion(ctx, bundleB.Name) - assert.NoError(t, err) - - if enabled != bundleB.Version { - t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundleB.Version, enabled) - t.FailNow() - } -} - -func testBundleExists(t *testing.T) { - var exists bool - - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-exists" - - exists, _ = da.BundleExists(ctx, bundle.Name, bundle.Version) - if exists { - t.Error("Bundle should not exist now") - t.FailNow() - } - - err = da.BundleCreate(ctx, bundle) - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - exists, _ = da.BundleExists(ctx, bundle.Name, bundle.Version) - if !exists { - t.Error("Bundle should exist now") - t.FailNow() - } -} - -func testBundleDelete(t *testing.T) { - // Delete blank bundle - err := da.BundleDelete(ctx, "", "0.0.1") - if !assert.Error(t, err, errs.ErrEmptyBundleName) { - t.FailNow() - } - - // Delete blank bundle - err = da.BundleDelete(ctx, "foo", "") - if !assert.Error(t, err, errs.ErrEmptyBundleVersion) { - t.FailNow() - } - - // Delete bundle that doesn't exist - err = da.BundleDelete(ctx, "no-such-bundle", "0.0.1") - if !assert.Error(t, err, errs.ErrNoSuchBundle) { - t.FailNow() - } - - bundle, err := getTestBundle() - assert.NoError(t, err) - bundle.Name = "test-delete" - - err = da.BundleCreate(ctx, bundle) // This has its own test - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - err = da.BundleDelete(ctx, bundle.Name, bundle.Version) - assert.NoError(t, err) - - exists, _ := da.BundleExists(ctx, bundle.Name, bundle.Version) - if exists { - t.Error("Shouldn't exist anymore!") - t.FailNow() - } -} - -func testBundleDeleteDoesntDisable(t *testing.T) { - var err error - - bundle, _ := getTestBundle() - bundle.Name = "test-delete2" - bundle.Version = "0.0.1" - err = da.BundleCreate(ctx, bundle) - require.NoError(t, err) - defer da.BundleDelete(ctx, bundle.Name, bundle.Version) - - bundle2, _ := getTestBundle() - bundle2.Name = "test-delete2" - bundle2.Version = "0.0.2" - err = da.BundleCreate(ctx, bundle2) - require.NoError(t, err) - defer da.BundleDelete(ctx, bundle2.Name, bundle2.Version) - - err = da.BundleEnable(ctx, bundle2.Name, bundle2.Version) - require.NoError(t, err) - - err = da.BundleDelete(ctx, bundle.Name, bundle.Version) - require.NoError(t, err) - - bundle2, err = da.BundleGet(ctx, bundle2.Name, bundle2.Version) - require.NoError(t, err) - assert.True(t, bundle2.Enabled) -} - -func testBundleGet(t *testing.T) { - var err error - - // Empty bundle name. Expect a ErrEmptyBundleName. - _, err = da.BundleGet(ctx, "", "0.0.1") - if !assert.Error(t, err, errs.ErrEmptyBundleName) { - t.FailNow() - } - - // Empty bundle name. Expect a ErrEmptyBundleVersion. - _, err = da.BundleGet(ctx, "test-get", "") - if !assert.Error(t, err, errs.ErrEmptyBundleVersion) { - t.FailNow() - } - - // Bundle that doesn't exist. Expect a ErrNoSuchBundle. - _, err = da.BundleGet(ctx, "test-get", "0.0.1") - if !assert.Error(t, err, errs.ErrNoSuchBundle) { - t.FailNow() - } - - // Get the test bundle. Expect no error. - bundleCreate, err := getTestBundle() - assert.NoError(t, err) - - // Set some values to non-defaults - bundleCreate.Name = "test-get" - // bundleCreate.Enabled = true - - // Save the test bundle. Expect no error. - err = da.BundleCreate(ctx, bundleCreate) - defer da.BundleDelete(ctx, bundleCreate.Name, bundleCreate.Version) - assert.NoError(t, err) - - // Test bundle should now exist in the data store. - exists, _ := da.BundleExists(ctx, bundleCreate.Name, bundleCreate.Version) - if !exists { - t.Error("Bundle should exist now, but it doesn't") - t.FailNow() - } - - // Load the bundle from the data store. Expect no error - bundleGet, err := da.BundleGet(ctx, bundleCreate.Name, bundleCreate.Version) - assert.NoError(t, err) - - // This is set automatically on save, so we copy it here for the sake of the tests. - bundleCreate.InstalledOn = bundleGet.InstalledOn - - assert.Equal(t, bundleCreate.Docker, bundleGet.Docker) - assert.ElementsMatch(t, bundleCreate.Permissions, bundleGet.Permissions) - assert.Equal(t, bundleCreate.Commands, bundleGet.Commands) - - // Compare everything for good measure - assert.Equal(t, bundleCreate, bundleGet) -} - -func testBundleList(t *testing.T) { - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.0", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-0", "0.0") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.1", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-0", "0.1") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.0", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-1", "0.0") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.1", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-1", "0.1") - - bundles, err := da.BundleList(ctx) - assert.NoError(t, err) - - if len(bundles) != 4 { - for i, u := range bundles { - t.Logf("Bundle %d: %v\n", i+1, u) - } - - t.Errorf("Expected len(bundles) = 4; got %d", len(bundles)) - t.FailNow() - } -} - -func testBundleVersionList(t *testing.T) { - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.0", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-0", "0.0") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.1", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-0", "0.1") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.0", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-1", "0.0") - da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.1", Description: "foo"}) - defer da.BundleDelete(ctx, "test-list-1", "0.1") - - bundles, err := da.BundleVersionList(ctx, "test-list-0") - assert.NoError(t, err) - - if len(bundles) != 2 { - for i, u := range bundles { - t.Logf("Bundle %d: %v\n", i+1, u) - } - - t.Errorf("Expected len(bundles) = 2; got %d", len(bundles)) - t.FailNow() - } -} - -func testFindCommandEntry(t *testing.T) { - const BundleName = "test" - const BundleVersion = "0.0.1" - const CommandName = "echox" - - tb, err := getTestBundle() - assert.NoError(t, err) - - // Save to data store - err = da.BundleCreate(ctx, tb) - assert.NoError(t, err) - - // Load back from the data store - tb, err = da.BundleGet(ctx, tb.Name, tb.Version) - assert.NoError(t, err) - - // Sanity testing. Has the test case changed? - assert.Equal(t, BundleName, tb.Name) - assert.Equal(t, BundleVersion, tb.Version) - assert.NotNil(t, tb.Commands[CommandName]) - - // Not yet enabled. Should find nothing. - ce, err := da.FindCommandEntry(ctx, BundleName, CommandName) - assert.NoError(t, err) - assert.Len(t, ce, 0) - - err = da.BundleEnable(ctx, BundleName, BundleVersion) - assert.NoError(t, err) - - // Reload to capture enabled status - tb, err = da.BundleGet(ctx, tb.Name, tb.Version) - assert.NoError(t, err) - - // Enabled. Should find commands. - ce, err = da.FindCommandEntry(ctx, BundleName, CommandName) - assert.NoError(t, err) - assert.Len(t, ce, 1) - - // Is the loaded bundle correct? - assert.Equal(t, tb, ce[0].Bundle) - - tc := tb.Commands[CommandName] - cmd := ce[0].Command - assert.Equal(t, tc.Description, cmd.Description) - assert.Equal(t, tc.LongDescription, cmd.LongDescription) - assert.Equal(t, tc.Executable, cmd.Executable) - assert.Equal(t, tc.Name, cmd.Name) - assert.Equal(t, tc.Rules, cmd.Rules) -} - -func getTestBundle() (data.Bundle, error) { - return bundles.LoadBundleFromFile("../../testing/test-bundle.yml") -} diff --git a/dataaccess/postgres/group-access_test.go b/dataaccess/postgres/group-access_test.go deleted file mode 100644 index 6a9ed8d..0000000 --- a/dataaccess/postgres/group-access_test.go +++ /dev/null @@ -1,409 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package postgres - -import ( - "testing" - - "github.com/getgort/gort/data/rest" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" -) - -func testGroupAccess(t *testing.T) { - t.Run("testGroupUserAdd", testGroupUserAdd) - t.Run("testGroupUserList", testGroupUserList) - t.Run("testGroupCreate", testGroupCreate) - t.Run("testGroupDelete", testGroupDelete) - t.Run("testGroupExists", testGroupExists) - t.Run("testGroupGet", testGroupGet) - t.Run("testGroupRoleAdd", testGroupRoleAdd) - t.Run("testGroupPermissionList", testGroupPermissionList) - t.Run("testGroupList", testGroupList) - t.Run("testGroupRoleList", testGroupRoleList) - t.Run("testGroupUserDelete", testGroupUserDelete) -} - -func testGroupUserAdd(t *testing.T) { - var ( - groupname = "group-test-group-user-add" - username = "user-test-group-user-add" - useremail = "user@foo.bar" - ) - - err := da.GroupUserAdd(ctx, groupname, username) - assert.Error(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - err = da.GroupUserAdd(ctx, groupname, username) - assert.Error(t, err, errs.ErrNoSuchUser) - - da.UserCreate(ctx, rest.User{Username: username, Email: useremail}) - defer da.UserDelete(ctx, username) - - err = da.GroupUserAdd(ctx, groupname, username) - assert.NoError(t, err) - - group, _ := da.GroupGet(ctx, groupname) - - if !assert.Len(t, group.Users, 1) { - t.FailNow() - } - - assert.Equal(t, group.Users[0].Username, username) - assert.Equal(t, group.Users[0].Email, useremail) -} - -func testGroupUserList(t *testing.T) { - var ( - groupname = "group-test-group-user-list" - expected = []rest.User{ - {Username: "user-test-group-user-list-0", Email: "user-test-group-user-list-0@email.com", Mappings: map[string]string{}}, - {Username: "user-test-group-user-list-1", Email: "user-test-group-user-list-1@email.com", Mappings: map[string]string{}}, - } - ) - - _, err := da.GroupUserList(ctx, groupname) - assert.Error(t, err) - assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - da.UserCreate(ctx, expected[0]) - defer da.UserDelete(ctx, expected[0].Username) - da.UserCreate(ctx, expected[1]) - defer da.UserDelete(ctx, expected[1].Username) - - da.GroupUserAdd(ctx, groupname, expected[0].Username) - da.GroupUserAdd(ctx, groupname, expected[1].Username) - - actual, err := da.GroupUserList(ctx, groupname) - if !assert.NoError(t, err) { - t.FailNow() - } - - if !assert.Len(t, actual, 2) { - t.FailNow() - } - - assert.Equal(t, expected, actual) -} - -func testGroupCreate(t *testing.T) { - var err error - var group rest.Group - - // Expect an error - err = da.GroupCreate(ctx, group) - assert.Error(t, err, errs.ErrEmptyGroupName) - - // Expect no error - err = da.GroupCreate(ctx, rest.Group{Name: "test-create"}) - defer da.GroupDelete(ctx, "test-create") - assert.NoError(t, err) - - // Expect an error - err = da.GroupCreate(ctx, rest.Group{Name: "test-create"}) - assert.Error(t, err, errs.ErrGroupExists) -} - -func testGroupDelete(t *testing.T) { - // Delete blank group - err := da.GroupDelete(ctx, "") - assert.Error(t, err, errs.ErrEmptyGroupName) - - // Delete group that doesn't exist - err = da.GroupDelete(ctx, "no-such-group") - assert.Error(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: "test-delete"}) // This has its own test - defer da.GroupDelete(ctx, "test-delete") - - err = da.GroupDelete(ctx, "test-delete") - assert.NoError(t, err) - - exists, _ := da.GroupExists(ctx, "test-delete") - if exists { - t.Error("Shouldn't exist anymore!") - t.FailNow() - } -} - -func testGroupExists(t *testing.T) { - var exists bool - - exists, _ = da.GroupExists(ctx, "test-exists") - if exists { - t.Error("Group should not exist now") - t.FailNow() - } - - // Now we add a group to find. - da.GroupCreate(ctx, rest.Group{Name: "test-exists"}) - defer da.GroupDelete(ctx, "test-exists") - - exists, _ = da.GroupExists(ctx, "test-exists") - if !exists { - t.Error("Group should exist now") - t.FailNow() - } -} - -func testGroupGet(t *testing.T) { - groupname := "group-test-group-get" - - var err error - var group rest.Group - - // Expect an error - _, err = da.GroupGet(ctx, "") - assert.ErrorIs(t, err, errs.ErrEmptyGroupName) - - // Expect an error - _, err = da.GroupGet(ctx, groupname) - assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - // da.Group ctx, should exist now - exists, _ := da.GroupExists(ctx, groupname) - if !assert.True(t, exists) { - t.FailNow() - } - - // Expect no error - group, err = da.GroupGet(ctx, groupname) - if !assert.NoError(t, err) { - t.FailNow() - } - if !assert.Equal(t, groupname, group.Name) { - t.FailNow() - } -} - -func testGroupPermissionList(t *testing.T) { - const ( - groupname = "group-test-group-permission-list" - rolename = "role-test-group-permission-list" - bundlename = "test" - ) - - var expected = rest.RolePermissionList{ - {BundleName: bundlename, Permission: "role-test-group-permission-list-1"}, - {BundleName: bundlename, Permission: "role-test-group-permission-list-2"}, - {BundleName: bundlename, Permission: "role-test-group-permission-list-3"}, - } - - var err error - - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - err = da.GroupRoleAdd(ctx, groupname, rolename) - if !assert.NoError(t, err) { - t.FailNow() - } - - da.RolePermissionAdd(ctx, rolename, expected[0].BundleName, expected[0].Permission) - da.RolePermissionAdd(ctx, rolename, expected[1].BundleName, expected[1].Permission) - da.RolePermissionAdd(ctx, rolename, expected[2].BundleName, expected[2].Permission) - - actual, err := da.GroupPermissionList(ctx, groupname) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expected, actual) -} - -func testGroupRoleAdd(t *testing.T) { - var err error - - groupName := "group-group-grant-role" - roleName := "role-group-grant-role" - bundleName := "bundle-group-grant-role" - permissionName := "perm-group-grant-role" - - da.GroupCreate(ctx, rest.Group{Name: groupName}) - defer da.GroupDelete(ctx, groupName) - - err = da.RoleCreate(ctx, roleName) - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RoleDelete(ctx, roleName) - - err = da.RolePermissionAdd(ctx, roleName, bundleName, permissionName) - if !assert.NoError(t, err) { - t.FailNow() - } - - err = da.GroupRoleAdd(ctx, groupName, roleName) - if !assert.NoError(t, err) { - t.FailNow() - } - - expectedRoles := []rest.Role{ - { - Name: roleName, - Permissions: []rest.RolePermission{{BundleName: bundleName, Permission: permissionName}}, - }, - } - - roles, err := da.GroupRoleList(ctx, groupName) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expectedRoles, roles) - - err = da.GroupRoleDelete(ctx, groupName, roleName) - if !assert.NoError(t, err) { - t.FailNow() - } - - expectedRoles = []rest.Role{} - - roles, err = da.GroupRoleList(ctx, groupName) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expectedRoles, roles) -} - -func testGroupList(t *testing.T) { - da.GroupCreate(ctx, rest.Group{Name: "test-list-0"}) - defer da.GroupDelete(ctx, "test-list-0") - da.GroupCreate(ctx, rest.Group{Name: "test-list-1"}) - defer da.GroupDelete(ctx, "test-list-1") - da.GroupCreate(ctx, rest.Group{Name: "test-list-2"}) - defer da.GroupDelete(ctx, "test-list-2") - da.GroupCreate(ctx, rest.Group{Name: "test-list-3"}) - defer da.GroupDelete(ctx, "test-list-3") - - groups, err := da.GroupList(ctx) - assert.NoError(t, err) - - if len(groups) != 4 { - t.Errorf("Expected len(groups) = 4; got %d", len(groups)) - t.FailNow() - } - - for _, u := range groups { - if u.Name == "" { - t.Error("Expected non-empty name") - t.FailNow() - } - } -} - -func testGroupRoleList(t *testing.T) { - var ( - groupname = "group-test-group-list-roles" - rolenames = []string{ - "role-test-group-list-roles-0", - "role-test-group-list-roles-1", - "role-test-group-list-roles-2", - } - ) - da.GroupCreate(ctx, rest.Group{Name: groupname}) - defer da.GroupDelete(ctx, groupname) - - da.RoleCreate(ctx, rolenames[1]) - defer da.RoleDelete(ctx, rolenames[1]) - - da.RoleCreate(ctx, rolenames[0]) - defer da.RoleDelete(ctx, rolenames[0]) - - da.RoleCreate(ctx, rolenames[2]) - defer da.RoleDelete(ctx, rolenames[2]) - - roles, err := da.GroupRoleList(ctx, groupname) - if !assert.NoError(t, err) && !assert.Empty(t, roles) { - t.FailNow() - } - - err = da.GroupRoleAdd(ctx, groupname, rolenames[1]) - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.GroupRoleAdd(ctx, groupname, rolenames[0]) - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.GroupRoleAdd(ctx, groupname, rolenames[2]) - if !assert.NoError(t, err) { - t.FailNow() - } - - // Note: alphabetically sorted! - expected := []rest.Role{ - {Name: rolenames[0], Permissions: []rest.RolePermission{}}, - {Name: rolenames[1], Permissions: []rest.RolePermission{}}, - {Name: rolenames[2], Permissions: []rest.RolePermission{}}, - } - - actual, err := da.GroupRoleList(ctx, groupname) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expected, actual) -} - -func testGroupUserDelete(t *testing.T) { - da.GroupCreate(ctx, rest.Group{Name: "foo"}) - defer da.GroupDelete(ctx, "foo") - - da.UserCreate(ctx, rest.User{Username: "bat"}) - defer da.UserDelete(ctx, "bat") - - err := da.GroupUserAdd(ctx, "foo", "bat") - assert.NoError(t, err) - - group, err := da.GroupGet(ctx, "foo") - assert.NoError(t, err) - - if len(group.Users) != 1 { - t.Error("Users list empty") - t.FailNow() - } - - if len(group.Users) > 0 && group.Users[0].Username != "bat" { - t.Error("Wrong user!") - t.FailNow() - } - - err = da.GroupUserDelete(ctx, "foo", "bat") - assert.NoError(t, err) - - group, err = da.GroupGet(ctx, "foo") - assert.NoError(t, err) - - if len(group.Users) != 0 { - t.Error("User not removed") - t.FailNow() - } -} diff --git a/dataaccess/postgres/postgres-data-access.go b/dataaccess/postgres/postgres-data-access.go index a158f6e..9978d44 100644 --- a/dataaccess/postgres/postgres-data-access.go +++ b/dataaccess/postgres/postgres-data-access.go @@ -25,9 +25,9 @@ import ( "github.com/getgort/gort/dataaccess/errs" gerr "github.com/getgort/gort/errors" "github.com/getgort/gort/telemetry" - "go.opentelemetry.io/otel" _ "github.com/lib/pq" // Load the Postgres drivers + "go.opentelemetry.io/otel" ) const ( @@ -181,6 +181,18 @@ func (da PostgresDataAccess) initializeGortData(ctx context.Context) error { } } + // Check whether the bundles_kubernetes table exists + exists, err = da.tableExists(ctx, "bundle_kubernetes", db) + if err != nil { + return err + } + if !exists { + err = da.createBundleKubernetesTables(ctx, db) + if err != nil { + return err + } + } + // Check whether the roles table exists exists, err = da.tableExists(ctx, "roles", db) if err != nil { @@ -254,8 +266,8 @@ func (da PostgresDataAccess) createBundlesTables(ctx context.Context, db *sql.DB homepage TEXT, description TEXT NOT NULL CHECK(description <> ''), long_description TEXT, - docker_image TEXT, - docker_tag TEXT, + image_repository TEXT, + image_tag TEXT, install_timestamp TIMESTAMP WITH TIME ZONE, install_user TEXT, CONSTRAINT unq_bundle UNIQUE(name, version), @@ -284,11 +296,24 @@ func (da PostgresDataAccess) createBundlesTables(ctx context.Context, db *sql.DB ON DELETE CASCADE ); + CREATE TABLE bundle_templates ( + bundle_name TEXT NOT NULL, + bundle_version TEXT NOT NULL, + command TEXT, + command_error TEXT, + message TEXT, + message_error TEXT, + CONSTRAINT unq_bundle_template UNIQUE(bundle_name, bundle_version), + PRIMARY KEY (bundle_name, bundle_version), + FOREIGN KEY (bundle_name, bundle_version) REFERENCES bundles(name, version) + ON DELETE CASCADE + ); + CREATE TABLE bundle_commands ( bundle_name TEXT NOT NULL, bundle_version TEXT NOT NULL, name TEXT NOT NULL CHECK(name <> ''), - description TEXT NOT NULL CHECK(description <> ''), + description TEXT NOT NULL, executable TEXT NOT NULL, long_description TEXT, CONSTRAINT unq_bundle_command UNIQUE(bundle_name, bundle_version, name), @@ -307,6 +332,39 @@ func (da PostgresDataAccess) createBundlesTables(ctx context.Context, db *sql.DB REFERENCES bundle_commands(bundle_name, bundle_version, name) ON DELETE CASCADE ); + + CREATE TABLE bundle_command_templates ( + bundle_name TEXT NOT NULL, + bundle_version TEXT NOT NULL, + command_name TEXT NOT NULL, + command TEXT, + command_error TEXT, + message TEXT, + message_error TEXT, + CONSTRAINT unq_bundle_command_templates UNIQUE(bundle_name, bundle_version, command_name), + PRIMARY KEY (bundle_name, bundle_version, command_name), + FOREIGN KEY (bundle_name, bundle_version, command_name) + REFERENCES bundle_commands(bundle_name, bundle_version, name) + ON DELETE CASCADE + ); + ` + + _, err = db.ExecContext(ctx, createBundlesQuery) + if err != nil { + return gerr.Wrap(errs.ErrDataAccess, err) + } + + return nil +} + +func (da PostgresDataAccess) createBundleKubernetesTables(ctx context.Context, db *sql.DB) error { + var err error + + createBundlesQuery := `CREATE TABLE bundle_kubernetes ( + bundle_version TEXT NOT NULL, + bundle_name TEXT NOT NULL, + service_account_name TEXT NOT NULL + ); ` _, err = db.ExecContext(ctx, createBundlesQuery) diff --git a/dataaccess/postgres/request-access.go b/dataaccess/postgres/request-access.go index 03892b0..a449965 100644 --- a/dataaccess/postgres/request-access.go +++ b/dataaccess/postgres/request-access.go @@ -76,13 +76,11 @@ func (da PostgresDataAccess) RequestBegin(ctx context.Context, req *data.Command } func (da PostgresDataAccess) RequestError(ctx context.Context, req data.CommandRequest, err error) error { - response := data.CommandResponse{ - Command: req, - Error: err, - Status: 1, - } + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + _, sp := tr.Start(ctx, "postgres.RequestUpdate") + defer sp.End() - return da.RequestClose(ctx, response) + return da.RequestClose(ctx, data.NewCommandResponseEnvelope(req, data.WithError("", err, 1))) } func (da PostgresDataAccess) RequestUpdate(ctx context.Context, req data.CommandRequest) error { @@ -125,12 +123,12 @@ func (da PostgresDataAccess) RequestUpdate(ctx context.Context, req data.Command return err } -func (da PostgresDataAccess) RequestClose(ctx context.Context, res data.CommandResponse) error { +func (da PostgresDataAccess) RequestClose(ctx context.Context, envelope data.CommandResponseEnvelope) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) ctx, sp := tr.Start(ctx, "postgres.RequestClose") defer sp.End() - if res.Command.RequestID == 0 { + if envelope.Request.RequestID == 0 { return fmt.Errorf("command request ID unset") } @@ -148,26 +146,26 @@ func (da PostgresDataAccess) RequestClose(ctx context.Context, res data.CommandR WHERE request_id=$15;` errMsg := "" - if res.Error != nil { - errMsg = res.Error.Error() + if envelope.Data.Error != nil { + errMsg = envelope.Data.Error.Error() } _, err = db.ExecContext(ctx, query, - res.Command.Bundle.Name, - res.Command.Bundle.Version, - res.Command.Command.Name, - encodeStringSlice(res.Command.Command.Executable), - strings.Join(res.Command.Parameters, " "), - res.Command.Adapter, - res.Command.UserID, - res.Command.UserEmail, - res.Command.ChannelID, - res.Command.UserName, - res.Command.Timestamp, - res.Duration.Milliseconds(), - res.Status, + envelope.Request.Bundle.Name, + envelope.Request.Bundle.Version, + envelope.Request.Command.Name, + encodeStringSlice(envelope.Request.Command.Executable), + strings.Join(envelope.Request.Parameters, " "), + envelope.Request.Adapter, + envelope.Request.UserID, + envelope.Request.UserEmail, + envelope.Request.ChannelID, + envelope.Request.UserName, + envelope.Request.Timestamp, + envelope.Data.Duration.Milliseconds(), + envelope.Data.ExitCode, errMsg, - res.Command.RequestID) + envelope.Request.RequestID) if err != nil { err = gerr.Wrap(errs.ErrDataAccess, err) } diff --git a/dataaccess/postgres/role-access_test.go b/dataaccess/postgres/role-access_test.go deleted file mode 100644 index 3297bba..0000000 --- a/dataaccess/postgres/role-access_test.go +++ /dev/null @@ -1,417 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package postgres - -import ( - "testing" - - "github.com/getgort/gort/data/rest" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" -) - -func testRoleAccess(t *testing.T) { - t.Run("testRoleCreate", testRoleCreate) - t.Run("testRoleList", testRoleList) - t.Run("testRoleExists", testRoleExists) - t.Run("testRoleDelete", testRoleDelete) - t.Run("testRoleGet", testRoleGet) - t.Run("testRoleGroupAdd", testRoleGroupAdd) - t.Run("testRoleGroupDelete", testRoleGroupDelete) - t.Run("testRoleGroupExists", testRoleGroupExists) - t.Run("testRoleGroupList", testRoleGroupList) - t.Run("testRolePermissionExists", testRolePermissionExists) - t.Run("testRolePermissionAdd", testRolePermissionAdd) - t.Run("testRolePermissionList", testRolePermissionList) -} - -func testRoleCreate(t *testing.T) { - var err error - - // Expect an error - err = da.RoleCreate(ctx, "") - assert.Error(t, err, errs.ErrEmptyRoleName) - - // Expect no error - err = da.RoleCreate(ctx, "test-create") - defer da.RoleDelete(ctx, "test-create") - assert.NoError(t, err) - - // Expect an error - err = da.RoleCreate(ctx, "test-create") - assert.Error(t, err, errs.ErrRoleExists) -} - -func testRoleList(t *testing.T) { - var err error - - // Get initial set of roles - roles, err := da.RoleList(ctx) - assert.NoError(t, err) - startingRoles := len(roles) - - // Create and populate role - rolename := "test-role-list" - bundle := "test-bundle-list" - permission := "test-permission-list" - err = da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - assert.NoError(t, err) - - err = da.RolePermissionAdd(ctx, rolename, bundle, permission) - assert.NoError(t, err) - - // Expect 1 new role - roles, err = da.RoleList(ctx) - assert.NoError(t, err) - if assert.Equal(t, startingRoles+1, len(roles)) { - assert.Equal(t, rolename, roles[startingRoles].Name) - assert.Equal(t, bundle, roles[startingRoles].Permissions[0].BundleName) - assert.Equal(t, permission, roles[startingRoles].Permissions[0].Permission) - } -} - -func testRoleDelete(t *testing.T) { - // Delete blank group - err := da.RoleDelete(ctx, "") - assert.Error(t, err, errs.ErrEmptyRoleName) - - // Delete group that doesn't exist - err = da.RoleDelete(ctx, "no-such-group") - assert.Error(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, "test-delete") // This has its own test - defer da.RoleDelete(ctx, "test-delete") - - err = da.RoleDelete(ctx, "test-delete") - assert.NoError(t, err) - - exists, _ := da.RoleExists(ctx, "test-delete") - if exists { - t.Error("Shouldn't exist anymore!") - t.FailNow() - } -} - -func testRoleExists(t *testing.T) { - var exists bool - - exists, _ = da.RoleExists(ctx, "test-exists") - if exists { - t.Error("Role should not exist now") - t.FailNow() - } - - // Now we add a group to find. - da.RoleCreate(ctx, "test-exists") - defer da.RoleDelete(ctx, "test-exists") - - exists, _ = da.RoleExists(ctx, "test-exists") - if !exists { - t.Error("Role should exist now") - t.FailNow() - } -} - -func testRoleGet(t *testing.T) { - var err error - var role rest.Role - - // Expect an error - _, err = da.RoleGet(ctx, "") - assert.Error(t, err, errs.ErrEmptyRoleName) - - // Expect an error - _, err = da.RoleGet(ctx, "test-get") - assert.Error(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, "test-get") - defer da.RoleDelete(ctx, "test-get") - - // da.Role ctx, should exist now - exists, _ := da.RoleExists(ctx, "test-get") - if !exists { - t.Error("Role should exist now") - t.FailNow() - } - - err = da.RolePermissionAdd(ctx, "test-get", "foo", "bar") - assert.NoError(t, err) - - err = da.RolePermissionAdd(ctx, "test-get", "foo", "bat") - assert.NoError(t, err) - - err = da.RolePermissionDelete(ctx, "test-get", "foo", "bat") - assert.NoError(t, err) - - expected := rest.Role{ - Name: "test-get", - Permissions: []rest.RolePermission{{BundleName: "foo", Permission: "bar"}}, - } - - // Expect no error - role, err = da.RoleGet(ctx, "test-get") - assert.NoError(t, err) - assert.Equal(t, expected, role) -} - -func testRoleGroupAdd(t *testing.T) { - var err error - - rolename := "role-test-role-group-add" - groupnames := []string{ - "perm-test-role-group-add-0", - "perm-test-role-group-add-1", - } - - // No such group yet - err = da.RoleGroupAdd(ctx, rolename, groupnames[1]) - assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) - defer da.GroupDelete(ctx, groupnames[0]) - da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) - defer da.GroupDelete(ctx, groupnames[1]) - - // Groups exist now, but the role doesn't - err = da.RoleGroupAdd(ctx, rolename, groupnames[1]) - assert.ErrorIs(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - for _, groupname := range groupnames { - err = da.RoleGroupAdd(ctx, rolename, groupname) - assert.NoError(t, err) - } - - for _, groupname := range groupnames { - exists, _ := da.RoleGroupExists(ctx, rolename, groupname) - assert.True(t, exists, groupname) - } -} - -func testRoleGroupDelete(t *testing.T) { - -} - -func testRoleGroupExists(t *testing.T) { - var err error - - rolename := "role-test-role-group-exists" - groupnames := []string{ - "group-test-role-group-exists-0", - "group-test-role-group-exists-1", - } - groupnull := "group-test-role-group-exists-null" - - // No such role yet - _, err = da.RoleGroupExists(ctx, rolename, groupnames[1]) - assert.ErrorIs(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - // Groups exist now, but the role doesn't - _, err = da.RoleGroupExists(ctx, rolename, groupnames[1]) - assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - - da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) - defer da.GroupDelete(ctx, groupnames[0]) - da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) - defer da.GroupDelete(ctx, groupnames[1]) - da.GroupCreate(ctx, rest.Group{Name: groupnull}) - defer da.GroupDelete(ctx, groupnull) - - for _, groupname := range groupnames { - da.RoleGroupAdd(ctx, rolename, groupname) - } - - for _, groupname := range groupnames { - exists, err := da.RoleGroupExists(ctx, rolename, groupname) - assert.NoError(t, err) - assert.True(t, exists) - } - - // Null group should NOT exist on the role - exists, err := da.RoleGroupExists(ctx, rolename, groupnull) - assert.NoError(t, err) - assert.False(t, exists) -} - -func testRoleGroupList(t *testing.T) { - var err error - - rolename := "role-test-role-group-list" - groupnames := []string{ - "group-test-role-group-list-0", - "group-test-role-group-list-1", - } - groupnull := "group-test-role-group-list-null" - - // No such role yet - _, err = da.RoleGroupList(ctx, rolename) - assert.ErrorIs(t, err, errs.ErrNoSuchRole) - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - // Groups exist now, but the role doesn't - groups, err := da.RoleGroupList(ctx, rolename) - assert.NoError(t, err) - assert.Empty(t, groups) - - da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) - defer da.GroupDelete(ctx, groupnames[1]) - da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) - defer da.GroupDelete(ctx, groupnames[0]) - da.GroupCreate(ctx, rest.Group{Name: groupnull}) - defer da.GroupDelete(ctx, groupnull) - - for _, groupname := range groupnames { - da.RoleGroupAdd(ctx, rolename, groupname) - } - - // Currently the groups are NOT expected to be fully described (i.e., - // their roles slices don't have to be complete). - groups, err = da.RoleGroupList(ctx, rolename) - assert.NoError(t, err) - assert.Len(t, groups, 2) - - for i, g := range groups { - assert.Equal(t, groupnames[i], g.Name) - } -} - -func testRolePermissionAdd(t *testing.T) { - var exists bool - var err error - - const rolename = "role-test-role-permission-add" - const bundlename = "test" - const permname1 = "perm-test-role-permission-add-0" - const permname2 = "perm-test-role-permission-add-1" - - da.RoleCreate(ctx, rolename) - defer da.RoleDelete(ctx, rolename) - - role, _ := da.RoleGet(ctx, rolename) - if !assert.Len(t, role.Permissions, 0) { - t.FailNow() - } - - // First permission - err = da.RolePermissionAdd(ctx, rolename, bundlename, permname1) - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, rolename, bundlename, permname1) - - role, _ = da.RoleGet(ctx, rolename) - if !assert.Len(t, role.Permissions, 1) { - t.FailNow() - } - - exists, _ = da.RolePermissionExists(ctx, rolename, bundlename, permname1) - if !assert.True(t, exists) { - t.FailNow() - } - - // Second permission - err = da.RolePermissionAdd(ctx, rolename, bundlename, permname2) - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, rolename, bundlename, permname2) - - role, _ = da.RoleGet(ctx, rolename) - if !assert.Len(t, role.Permissions, 2) { - t.FailNow() - } - - exists, _ = da.RolePermissionExists(ctx, rolename, bundlename, permname2) - if !assert.True(t, exists) { - t.FailNow() - } -} - -func testRolePermissionExists(t *testing.T) { - var err error - - da.RoleCreate(ctx, "role-test-role-has-permission") - defer da.RoleDelete(ctx, "role-test-role-has-permission") - - has, err := da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - if !assert.NoError(t, err) || !assert.False(t, has) { - t.FailNow() - } - - err = da.RolePermissionAdd(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - - has, err = da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - if !assert.NoError(t, err) || !assert.True(t, has) { - t.FailNow() - } - - has, err = da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-2") - if !assert.NoError(t, err) || !assert.False(t, has) { - t.FailNow() - } -} - -func testRolePermissionList(t *testing.T) { - var err error - - da.RoleCreate(ctx, "role-test-role-permission-list") - defer da.RoleDelete(ctx, "role-test-role-permission-list") - - err = da.RolePermissionAdd(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-1") - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-1") - - err = da.RolePermissionAdd(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-3") - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-3") - - err = da.RolePermissionAdd(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-2") - if !assert.NoError(t, err) { - t.FailNow() - } - defer da.RolePermissionDelete(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-2") - - // Expect a sorted list! - expect := rest.RolePermissionList{ - {BundleName: "test", Permission: "permission-test-role-permission-list-1"}, - {BundleName: "test", Permission: "permission-test-role-permission-list-2"}, - {BundleName: "test", Permission: "permission-test-role-permission-list-3"}, - } - - actual, err := da.RolePermissionList(ctx, "role-test-role-permission-list") - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expect, actual) -} diff --git a/dataaccess/postgres/token-access_test.go b/dataaccess/postgres/token-access_test.go deleted file mode 100644 index 845146c..0000000 --- a/dataaccess/postgres/token-access_test.go +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package postgres - -import ( - "testing" - "time" - - "github.com/getgort/gort/data/rest" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" -) - -func testTokenAccess(t *testing.T) { - t.Run("testTokenGenerate", testTokenGenerate) - t.Run("testTokenRetrieveByUser", testTokenRetrieveByUser) - t.Run("testTokenRetrieveByToken", testTokenRetrieveByToken) - t.Run("testTokenExpiry", testTokenExpiry) - t.Run("testTokenInvalidate", testTokenInvalidate) -} - -func testTokenGenerate(t *testing.T) { - err := da.UserCreate(ctx, rest.User{Username: "test_generate"}) - defer da.UserDelete(ctx, "test_generate") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_generate", 10*time.Minute) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - if token.Duration != 10*time.Minute { - t.Errorf("Duration mismatch: %v vs %v\n", token.Duration, 10*time.Minute) - t.FailNow() - } - - if token.User != "test_generate" { - t.Error("User mismatch") - t.FailNow() - } - - if token.ValidFrom.Add(10*time.Minute) != token.ValidUntil { - t.Error("Validity duration mismatch") - t.FailNow() - } -} - -func testTokenRetrieveByUser(t *testing.T) { - _, err := da.TokenRetrieveByUser(ctx, "no-such-user") - assert.Error(t, err, errs.ErrNoSuchToken) - - err = da.UserCreate(ctx, rest.User{Username: "test_uretrieve", Email: "test_uretrieve"}) - defer da.UserDelete(ctx, "test_uretrieve") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_uretrieve", 10*time.Minute) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - rtoken, err := da.TokenRetrieveByUser(ctx, "test_uretrieve") - assert.NoError(t, err) - - if token.Token != rtoken.Token { - t.Error("token mismatch") - t.FailNow() - } -} - -func testTokenRetrieveByToken(t *testing.T) { - _, err := da.TokenRetrieveByToken(ctx, "no-such-token") - assert.Error(t, err, errs.ErrNoSuchToken) - - err = da.UserCreate(ctx, rest.User{Username: "test_tretrieve", Email: "test_tretrieve"}) - defer da.UserDelete(ctx, "test_tretrieve") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_tretrieve", 10*time.Minute) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - rtoken, err := da.TokenRetrieveByToken(ctx, token.Token) - assert.NoError(t, err) - - if token.Token != rtoken.Token { - t.Error("token mismatch") - t.FailNow() - } -} - -func testTokenExpiry(t *testing.T) { - err := da.UserCreate(ctx, rest.User{Username: "test_expires", Email: "test_expires"}) - defer da.UserDelete(ctx, "test_expires") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_expires", 1*time.Second) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - if token.IsExpired() { - t.Error("Expected token to be unexpired") - t.FailNow() - } - - time.Sleep(time.Second) - - if !token.IsExpired() { - t.Error("Expected token to be expired") - t.FailNow() - } -} - -func testTokenInvalidate(t *testing.T) { - err := da.UserCreate(ctx, rest.User{Username: "test_invalidate", Email: "test_invalidate"}) - defer da.UserDelete(ctx, "test_invalidate") - assert.NoError(t, err) - - token, err := da.TokenGenerate(ctx, "test_invalidate", 10*time.Minute) - defer da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - if !da.TokenEvaluate(ctx, token.Token) { - t.Error("Expected token to be valid") - t.FailNow() - } - - err = da.TokenInvalidate(ctx, token.Token) - assert.NoError(t, err) - - if da.TokenEvaluate(ctx, token.Token) { - t.Error("Expected token to be invalid") - t.FailNow() - } -} diff --git a/dataaccess/postgres/user-access_test.go b/dataaccess/postgres/user-access_test.go deleted file mode 100644 index 8096600..0000000 --- a/dataaccess/postgres/user-access_test.go +++ /dev/null @@ -1,440 +0,0 @@ -/* - * Copyright 2021 The Gort Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package postgres - -import ( - "testing" - - "github.com/getgort/gort/data/rest" - "github.com/getgort/gort/dataaccess/errs" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func testUserAccess(t *testing.T) { - t.Run("testUserAuthenticate", testUserAuthenticate) - t.Run("testUserCreate", testUserCreate) - t.Run("testUserDelete", testUserDelete) - t.Run("testUserExists", testUserExists) - t.Run("testUserGet", testUserGet) - t.Run("testUserGetByEmail", testUserGetByEmail) - t.Run("testUserGetByID", testUserGetByID) - t.Run("testUserGetNoMappings", testUserGetNoMappings) - t.Run("testUserGroupList", testUserGroupList) - t.Run("testUserList", testUserList) - t.Run("testUserNotExists", testUserNotExists) - t.Run("testUserPermissionList", testUserPermissionList) - t.Run("testUserUpdate", testUserUpdate) -} - -func testUserAuthenticate(t *testing.T) { - var err error - - authenticated, err := da.UserAuthenticate(ctx, "test-auth", "no-match") - assert.Error(t, err, errs.ErrNoSuchUser) - if authenticated { - t.Error("Expected false") - t.FailNow() - } - - // Expect no error - err = da.UserCreate(ctx, rest.User{ - Username: "test-auth", - Email: "test-auth@bar.com", - Password: "password", - }) - defer da.UserDelete(ctx, "test-auth") - assert.NoError(t, err) - - authenticated, err = da.UserAuthenticate(ctx, "test-auth", "no-match") - assert.NoError(t, err) - if authenticated { - t.Error("Expected false") - t.FailNow() - } - - authenticated, err = da.UserAuthenticate(ctx, "test-auth", "password") - assert.NoError(t, err) - if !authenticated { - t.Error("Expected true") - t.FailNow() - } -} - -func testUserCreate(t *testing.T) { - var err error - var user rest.User - - // Expect an error - err = da.UserCreate(ctx, user) - assert.Error(t, err, errs.ErrEmptyUserName) - - // Expect no error - err = da.UserCreate(ctx, rest.User{Username: "test-create", Email: "test-create@bar.com"}) - defer da.UserDelete(ctx, "test-create") - assert.NoError(t, err) - - // Expect an error - err = da.UserCreate(ctx, rest.User{Username: "test-create", Email: "test-create@bar.com"}) - assert.Error(t, err, errs.ErrUserExists) -} - -func testUserDelete(t *testing.T) { - // Delete blank user - err := da.UserDelete(ctx, "") - assert.Error(t, err, errs.ErrEmptyUserName) - - // Delete admin user - err = da.UserDelete(ctx, "admin") - assert.Error(t, err, errs.ErrAdminUndeletable) - - // Delete user that doesn't exist - err = da.UserDelete(ctx, "no-such-user") - assert.Error(t, err, errs.ErrNoSuchUser) - - user := rest.User{Username: "test-delete", Email: "foo1.example.com"} - da.UserCreate(ctx, user) // This has its own test - defer da.UserDelete(ctx, "test-delete") - - err = da.UserDelete(ctx, "test-delete") - assert.NoError(t, err) - - exists, _ := da.UserExists(ctx, "test-delete") - if exists { - t.Error("Shouldn't exist anymore!") - t.FailNow() - } -} - -func testUserExists(t *testing.T) { - var exists bool - - exists, _ = da.UserExists(ctx, "test-exists") - if exists { - t.Error("User should not exist now") - t.FailNow() - } - - // Now we add a user to find. - err := da.UserCreate(ctx, rest.User{Username: "test-exists", Email: "test-exists@bar.com"}) - defer da.UserDelete(ctx, "test-exists") - assert.NoError(t, err) - - exists, _ = da.UserExists(ctx, "test-exists") - if !exists { - t.Error("User should exist now") - t.FailNow() - } -} - -func testUserGet(t *testing.T) { - const userName = "test-get" - const userEmail = "test-get@foo.com" - const userAdapter = "slack-get" - const userAdapterID = "U12345-get" - - var err error - var user rest.User - - // Expect an error - _, err = da.UserGet(ctx, "") - assert.EqualError(t, err, errs.ErrEmptyUserName.Error()) - - // Expect an error - _, err = da.UserGet(ctx, userName) - assert.EqualError(t, err, errs.ErrNoSuchUser.Error()) - - // Create the test user - err = da.UserCreate(ctx, rest.User{ - Username: userName, - Email: userEmail, - Mappings: map[string]string{userAdapter: userAdapterID}, - }) - defer da.UserDelete(ctx, userName) - require.NoError(t, err) - - // User should exist now - exists, err := da.UserExists(ctx, userName) - require.NoError(t, err) - require.True(t, exists) - - // Expect no error - user, err = da.UserGet(ctx, userName) - require.NoError(t, err) - require.Equal(t, user.Username, userName) - require.Equal(t, user.Email, userEmail) - require.NotNil(t, user.Mappings) - require.Equal(t, userAdapterID, user.Mappings[userAdapter]) -} - -func testUserGetNoMappings(t *testing.T) { - const userName = "test-get-no-mappings" - const userEmail = "test-get-no-mappings@foo.com" - - var err error - var user rest.User - - // Create the test user - err = da.UserCreate(ctx, rest.User{Username: userName, Email: userEmail}) - defer da.UserDelete(ctx, userName) - require.NoError(t, err) - - // Expect no error - user, err = da.UserGet(ctx, userName) - require.NoError(t, err) - require.Equal(t, user.Username, userName) - require.Equal(t, user.Email, userEmail) - require.NotNil(t, user.Mappings) -} - -func testUserGetByEmail(t *testing.T) { - const userName = "test-get-by-email" - const userEmail = "test-get-by-email@foo.com" - const userAdapter = "slack-get-by-email" - const userAdapterID = "U12345-get-by-email" - - var err error - var user rest.User - - // Expect an error - _, err = da.UserGetByEmail(ctx, "") - assert.EqualError(t, err, errs.ErrEmptyUserEmail.Error()) - - // Expect an error - _, err = da.UserGetByEmail(ctx, userEmail) - assert.EqualError(t, err, errs.ErrNoSuchUser.Error()) - - // Create the test user - err = da.UserCreate(ctx, rest.User{ - Username: userName, - Email: userEmail, - Mappings: map[string]string{userAdapter: userAdapterID}, - }) - defer da.UserDelete(ctx, userName) - require.NoError(t, err) - - // User should exist now - exists, err := da.UserExists(ctx, userName) - require.NoError(t, err) - require.True(t, exists) - - // Expect no error - user, err = da.UserGetByEmail(ctx, userEmail) - require.NoError(t, err) - require.Equal(t, user.Username, userName) - require.Equal(t, user.Email, userEmail) - require.NotNil(t, user.Mappings) - require.Equal(t, userAdapterID, user.Mappings[userAdapter]) -} - -func testUserGetByID(t *testing.T) { - const userName = "test-get-by-id" - const userEmail = "test-get-by-id@foo.com" - const userAdapter = "slack-get-by-id" - const userAdapterID = "U12345-get-by-id" - - var err error - var user rest.User - - // Expect errors - _, err = da.UserGetByID(ctx, "", userAdapterID) - assert.EqualError(t, err, errs.ErrEmptyUserAdapter.Error()) - - _, err = da.UserGetByID(ctx, userAdapter, "") - assert.EqualError(t, err, errs.ErrEmptyUserID.Error()) - - _, err = da.UserGetByID(ctx, userAdapter, userAdapterID) - assert.EqualError(t, err, errs.ErrNoSuchUser.Error()) - - // Create the test user - err = da.UserCreate(ctx, rest.User{ - Username: userName, - Email: userEmail, - Mappings: map[string]string{userAdapter: userAdapterID}, - }) - defer da.UserDelete(ctx, userName) - require.NoError(t, err) - - // User should exist now - exists, err := da.UserExists(ctx, userName) - require.NoError(t, err) - require.True(t, exists) - - // Expect no error - user, err = da.UserGetByID(ctx, userAdapter, userAdapterID) - require.NoError(t, err) - require.Equal(t, user.Username, userName) - require.Equal(t, user.Email, userEmail) - require.NotNil(t, user.Mappings) - require.Equal(t, userAdapterID, user.Mappings[userAdapter]) -} - -func testUserGroupList(t *testing.T) { - da.GroupCreate(ctx, rest.Group{Name: "group-test-user-group-list-0"}) - defer da.GroupDelete(ctx, "group-test-user-group-list-0") - - da.GroupCreate(ctx, rest.Group{Name: "group-test-user-group-list-1"}) - defer da.GroupDelete(ctx, "group-test-user-group-list-1") - - da.UserCreate(ctx, rest.User{Username: "user-test-user-group-list"}) - defer da.UserDelete(ctx, "user-test-user-group-list") - - da.GroupUserAdd(ctx, "group-test-user-group-list-0", "user-test-user-group-list") - - expected := []rest.Group{{Name: "group-test-user-group-list-0", Users: nil}} - - actual, err := da.UserGroupList(ctx, "user-test-user-group-list") - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expected, actual) -} - -func testUserList(t *testing.T) { - da.UserCreate(ctx, rest.User{Username: "test-list-0", Password: "password0!", Email: "test-list-0"}) - defer da.UserDelete(ctx, "test-list-0") - da.UserCreate(ctx, rest.User{Username: "test-list-1", Password: "password1!", Email: "test-list-1"}) - defer da.UserDelete(ctx, "test-list-1") - da.UserCreate(ctx, rest.User{Username: "test-list-2", Password: "password2!", Email: "test-list-2"}) - defer da.UserDelete(ctx, "test-list-2") - da.UserCreate(ctx, rest.User{Username: "test-list-3", Password: "password3!", Email: "test-list-3"}) - defer da.UserDelete(ctx, "test-list-3") - - users, err := da.UserList(ctx) - assert.NoError(t, err) - - if len(users) != 4 { - for i, u := range users { - t.Logf("User %d: %v\n", i+1, u) - } - - t.Errorf("Expected len(users) = 4; got %d", len(users)) - t.FailNow() - } - - for _, u := range users { - if u.Password != "" { - t.Error("Expected empty password") - t.FailNow() - } - - if u.Username == "" { - t.Error("Expected non-empty username") - t.FailNow() - } - } -} - -func testUserNotExists(t *testing.T) { - var exists bool - - err := da.Initialize(ctx) - assert.NoError(t, err) - - exists, _ = da.UserExists(ctx, "test-not-exists") - if exists { - t.Error("User should not exist now") - t.FailNow() - } -} - -func testUserPermissionList(t *testing.T) { - var err error - - err = da.GroupCreate(ctx, rest.Group{Name: "test-perms"}) - defer da.GroupDelete(ctx, "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - - err = da.UserCreate(ctx, rest.User{Username: "test-perms", Password: "password0!", Email: "test-perms"}) - defer da.UserDelete(ctx, "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.GroupUserAdd(ctx, "test-perms", "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - - da.RoleCreate(ctx, "test-perms") - defer da.RoleDelete(ctx, "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.GroupRoleAdd(ctx, "test-perms", "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - - err = da.RolePermissionAdd(ctx, "test-perms", "test", "test-perms-1") - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.RolePermissionAdd(ctx, "test-perms", "test", "test-perms-2") - if !assert.NoError(t, err) { - t.FailNow() - } - err = da.RolePermissionAdd(ctx, "test-perms", "test", "test-perms-0") - if !assert.NoError(t, err) { - t.FailNow() - } - - // Expected: a sorted list of strings - expected := []string{"test:test-perms-0", "test:test-perms-1", "test:test-perms-2"} - - actual, err := da.UserPermissionList(ctx, "test-perms") - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, expected, actual.Strings()) -} - -func testUserUpdate(t *testing.T) { - // Update blank user - err := da.UserUpdate(ctx, rest.User{}) - assert.Error(t, err, errs.ErrEmptyUserName) - - // Update user that doesn't exist - err = da.UserUpdate(ctx, rest.User{Username: "no-such-user"}) - assert.Error(t, err, errs.ErrNoSuchUser) - - userA := rest.User{Username: "test-update", Email: "foo1.example.com"} - da.UserCreate(ctx, userA) - defer da.UserDelete(ctx, "test-update") - - // Get the user we just added. Emails should match. - user1, _ := da.UserGet(ctx, "test-update") - if userA.Email != user1.Email { - t.Errorf("Email mismatch: %q vs %q", userA.Email, user1.Email) - t.FailNow() - } - - // Do the update - userB := rest.User{Username: "test-update", Email: "foo2.example.com"} - err = da.UserUpdate(ctx, userB) - assert.NoError(t, err) - - // Get the user we just updated. Emails should match. - user2, _ := da.UserGet(ctx, "test-update") - if userB.Email != user2.Email { - t.Errorf("Email mismatch: %q vs %q", userB.Email, user2.Email) - t.FailNow() - } -} diff --git a/dataaccess/tests/base.go b/dataaccess/tests/base.go new file mode 100644 index 0000000..6346249 --- /dev/null +++ b/dataaccess/tests/base.go @@ -0,0 +1,45 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package tests + +import ( + "context" + "testing" +) + +type DataAccessTester struct { + TestDataAccess + cancel context.CancelFunc + ctx context.Context +} + +func NewDataAccessTester(ctx context.Context, cancel context.CancelFunc, da TestDataAccess) DataAccessTester { + return DataAccessTester{ + TestDataAccess: da, + ctx: ctx, + cancel: cancel, + } +} + +func (da DataAccessTester) RunAllTests(t *testing.T) { + t.Run("testUserAccess", da.testUserAccess) + t.Run("testGroupAccess", da.testGroupAccess) + t.Run("testTokenAccess", da.testTokenAccess) + t.Run("testBundleAccess", da.testBundleAccess) + t.Run("testRoleAccess", da.testRoleAccess) + t.Run("testRequestAccess", da.testRequestAccess) +} diff --git a/dataaccess/tests/bundle-access.go b/dataaccess/tests/bundle-access.go new file mode 100644 index 0000000..3a036ea --- /dev/null +++ b/dataaccess/tests/bundle-access.go @@ -0,0 +1,431 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package tests + +import ( + "testing" + + "github.com/getgort/gort/bundles" + "github.com/getgort/gort/data" + "github.com/getgort/gort/dataaccess/errs" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (da DataAccessTester) testBundleAccess(t *testing.T) { + t.Run("testLoadTestData", da.testLoadTestData) + t.Run("testBundleCreate", da.testBundleCreate) + t.Run("testBundleCreateMissingRequired", da.testBundleCreateMissingRequired) + t.Run("testBundleEnable", da.testBundleEnable) + t.Run("testBundleEnableTwo", da.testBundleEnableTwo) + t.Run("testBundleExists", da.testBundleExists) + t.Run("testBundleDelete", da.testBundleDelete) + t.Run("testBundleDeleteDoesntDisable", da.testBundleDeleteDoesntDisable) + t.Run("testBundleGet", da.testBundleGet) + t.Run("testBundleImageConsistency", da.testBundleImageConsistency) + t.Run("testBundleList", da.testBundleList) + t.Run("testBundleVersionList", da.testBundleVersionList) + t.Run("testFindCommandEntry", da.testFindCommandEntry) +} + +// Fail-fast: can the test bundle be loaded? +func (da DataAccessTester) testLoadTestData(t *testing.T) { + b, err := getTestBundle() + assert.NoError(t, err) + + assert.NotZero(t, b.Templates) + + assert.NotEmpty(t, b.Commands) + assert.NotEmpty(t, b.Commands["echox"].Description) + assert.NotEmpty(t, b.Commands["echox"].Executable) + assert.NotEmpty(t, b.Commands["echox"].LongDescription) + assert.NotEmpty(t, b.Commands["echox"].Name) + assert.NotEmpty(t, b.Commands["echox"].Rules) +} + +func (da DataAccessTester) testBundleCreate(t *testing.T) { + // Expect an error + err := da.BundleCreate(da.ctx, data.Bundle{}) + require.Error(t, err, errs.ErrEmptyBundleName) + + bundle, err := getTestBundle() + assert.NoError(t, err) + bundle.Name = "test-create" + + // Expect no error + err = da.BundleCreate(da.ctx, bundle) + defer da.BundleDelete(da.ctx, bundle.Name, bundle.Version) + assert.NoError(t, err) + + // Expect an error + err = da.BundleCreate(da.ctx, bundle) + require.Error(t, err, errs.ErrBundleExists) +} + +func (da DataAccessTester) testBundleCreateMissingRequired(t *testing.T) { + bundle, err := getTestBundle() + assert.NoError(t, err) + bundle.Name = "test-missing-required" + + defer da.BundleDelete(da.ctx, bundle.Name, bundle.Version) + + // GortBundleVersion + originalGortBundleVersion := bundle.GortBundleVersion + bundle.GortBundleVersion = 0 + err = da.BundleCreate(da.ctx, bundle) + require.Error(t, err, errs.ErrFieldRequired) + bundle.GortBundleVersion = originalGortBundleVersion + + // Description + originalDescription := bundle.Description + bundle.Description = "" + err = da.BundleCreate(da.ctx, bundle) + require.Error(t, err, errs.ErrFieldRequired) + bundle.Description = originalDescription +} + +func (da DataAccessTester) testBundleEnable(t *testing.T) { + bundle, err := getTestBundle() + assert.NoError(t, err) + bundle.Name = "test-enable" + + err = da.BundleCreate(da.ctx, bundle) + assert.NoError(t, err) + defer da.BundleDelete(da.ctx, bundle.Name, bundle.Version) + + // No version should be enabled + enabled, err := da.BundleEnabledVersion(da.ctx, bundle.Name) + assert.NoError(t, err) + require.Empty(t, enabled) + + // Reload and verify enabled value is false + bundle, err = da.BundleGet(da.ctx, bundle.Name, bundle.Version) + assert.NoError(t, err) + assert.False(t, bundle.Enabled) + + // Enable and verify + err = da.BundleEnable(da.ctx, bundle.Name, bundle.Version) + assert.NoError(t, err) + + enabled, err = da.BundleEnabledVersion(da.ctx, bundle.Name) + assert.NoError(t, err) + require.Equal(t, enabled, bundle.Version) + + bundle, err = da.BundleGet(da.ctx, bundle.Name, bundle.Version) + assert.NoError(t, err) + assert.True(t, bundle.Enabled) + + // Should now delete cleanly + err = da.BundleDelete(da.ctx, bundle.Name, bundle.Version) + assert.NoError(t, err) +} + +func (da DataAccessTester) testBundleEnableTwo(t *testing.T) { + bundleA, err := getTestBundle() + assert.NoError(t, err) + bundleA.Name = "test-enable-2" + bundleA.Version = "0.0.1" + + err = da.BundleCreate(da.ctx, bundleA) + assert.NoError(t, err) + defer da.BundleDelete(da.ctx, bundleA.Name, bundleA.Version) + + // Enable and verify + err = da.BundleEnable(da.ctx, bundleA.Name, bundleA.Version) + assert.NoError(t, err) + + enabled, err := da.BundleEnabledVersion(da.ctx, bundleA.Name) + assert.NoError(t, err) + require.Equal(t, enabled, bundleA.Version) + + // Create a new version of the same bundle + + bundleB, err := getTestBundle() + assert.NoError(t, err) + bundleB.Name = bundleA.Name + bundleB.Version = "0.0.2" + + err = da.BundleCreate(da.ctx, bundleB) + assert.NoError(t, err) + defer da.BundleDelete(da.ctx, bundleB.Name, bundleB.Version) + + // BundleA should still be enabled + + enabled, err = da.BundleEnabledVersion(da.ctx, bundleA.Name) + assert.NoError(t, err) + require.Equal(t, enabled, bundleA.Version) + + enabled, err = da.BundleEnabledVersion(da.ctx, bundleA.Name) + assert.NoError(t, err) + require.Equal(t, enabled, bundleA.Version) + + // Enable and verify + err = da.BundleEnable(da.ctx, bundleB.Name, bundleB.Version) + assert.NoError(t, err) + + enabled, err = da.BundleEnabledVersion(da.ctx, bundleB.Name) + assert.NoError(t, err) + + if enabled != bundleB.Version { + t.Errorf("Bundle should be enabled now. Expected=%q; Got=%q", bundleB.Version, enabled) + t.FailNow() + } +} + +func (da DataAccessTester) testBundleExists(t *testing.T) { + var exists bool + + bundle, err := getTestBundle() + assert.NoError(t, err) + bundle.Name = "test-exists" + + exists, _ = da.BundleExists(da.ctx, bundle.Name, bundle.Version) + if exists { + t.Error("Bundle should not exist now") + t.FailNow() + } + + err = da.BundleCreate(da.ctx, bundle) + defer da.BundleDelete(da.ctx, bundle.Name, bundle.Version) + assert.NoError(t, err) + + exists, _ = da.BundleExists(da.ctx, bundle.Name, bundle.Version) + if !exists { + t.Error("Bundle should exist now") + t.FailNow() + } +} + +func (da DataAccessTester) testBundleDelete(t *testing.T) { + // Delete blank bundle + err := da.BundleDelete(da.ctx, "", "0.0.1") + require.Error(t, err, errs.ErrEmptyBundleName) + + // Delete blank bundle + err = da.BundleDelete(da.ctx, "foo", "") + require.Error(t, err, errs.ErrEmptyBundleVersion) + + // Delete bundle that doesn't exist + err = da.BundleDelete(da.ctx, "no-such-bundle", "0.0.1") + require.Error(t, err, errs.ErrNoSuchBundle) + + bundle, err := getTestBundle() + assert.NoError(t, err) + bundle.Name = "test-delete" + + err = da.BundleCreate(da.ctx, bundle) // This has its own test + defer da.BundleDelete(da.ctx, bundle.Name, bundle.Version) + assert.NoError(t, err) + + err = da.BundleDelete(da.ctx, bundle.Name, bundle.Version) + assert.NoError(t, err) + + exists, _ := da.BundleExists(da.ctx, bundle.Name, bundle.Version) + if exists { + t.Error("Shouldn't exist anymore!") + t.FailNow() + } +} + +func (da DataAccessTester) testBundleDeleteDoesntDisable(t *testing.T) { + var err error + + bundle, _ := getTestBundle() + bundle.Name = "test-delete2" + bundle.Version = "0.0.1" + err = da.BundleCreate(da.ctx, bundle) + require.NoError(t, err) + defer da.BundleDelete(da.ctx, bundle.Name, bundle.Version) + + bundle2, _ := getTestBundle() + bundle2.Name = "test-delete2" + bundle2.Version = "0.0.2" + err = da.BundleCreate(da.ctx, bundle2) + require.NoError(t, err) + defer da.BundleDelete(da.ctx, bundle2.Name, bundle2.Version) + + err = da.BundleEnable(da.ctx, bundle2.Name, bundle2.Version) + require.NoError(t, err) + + err = da.BundleDelete(da.ctx, bundle.Name, bundle.Version) + require.NoError(t, err) + + bundle2, err = da.BundleGet(da.ctx, bundle2.Name, bundle2.Version) + require.NoError(t, err) + assert.True(t, bundle2.Enabled) +} + +func (da DataAccessTester) testBundleGet(t *testing.T) { + var err error + + // Empty bundle name. Expect a ErrEmptyBundleName. + _, err = da.BundleGet(da.ctx, "", "0.0.1") + require.Error(t, err, errs.ErrEmptyBundleName) + + // Empty bundle name. Expect a ErrEmptyBundleVersion. + _, err = da.BundleGet(da.ctx, "test-get", "") + require.Error(t, err, errs.ErrEmptyBundleVersion) + + // Bundle that doesn't exist. Expect a ErrNoSuchBundle. + _, err = da.BundleGet(da.ctx, "test-get", "0.0.1") + require.Error(t, err, errs.ErrNoSuchBundle) + + // Get the test bundle. Expect no error. + bundleCreate, err := getTestBundle() + assert.NoError(t, err) + + // Set some values to non-defaults + bundleCreate.Name = "test-get" + // bundleCreate.Enabled = true + + // Save the test bundle. Expect no error. + err = da.BundleCreate(da.ctx, bundleCreate) + defer da.BundleDelete(da.ctx, bundleCreate.Name, bundleCreate.Version) + assert.NoError(t, err) + + // Test bundle should now exist in the data store. + exists, _ := da.BundleExists(da.ctx, bundleCreate.Name, bundleCreate.Version) + if !exists { + t.Error("Bundle should exist now, but it doesn't") + t.FailNow() + } + + // Load the bundle from the data store. Expect no error + bundleGet, err := da.BundleGet(da.ctx, bundleCreate.Name, bundleCreate.Version) + assert.NoError(t, err) + + // This is set automatically on save, so we copy it here for the sake of the tests. + bundleCreate.InstalledOn = bundleGet.InstalledOn + + assert.Equal(t, bundleCreate.Image, bundleGet.Image) + assert.ElementsMatch(t, bundleCreate.Permissions, bundleGet.Permissions) + assert.Equal(t, bundleCreate.Commands, bundleGet.Commands) + assert.Equal(t, bundleCreate.Kubernetes, bundleGet.Kubernetes) + + // Compare everything for good measure + assert.Equal(t, bundleCreate, bundleGet) +} + +func (da DataAccessTester) testBundleImageConsistency(t *testing.T) { + tests := []struct { + B data.Bundle + ExpectedImage string + }{ + {data.Bundle{GortBundleVersion: 1, Name: "test-image-0", Version: "0.0.0", Description: "Foo"}, ""}, + {data.Bundle{GortBundleVersion: 1, Name: "test-image-1", Version: "0.0.1", Description: "Foo", Image: "ubuntu:20.04"}, "ubuntu:20.04"}, + {data.Bundle{GortBundleVersion: 1, Name: "test-image-2", Version: "0.0.2", Description: "Foo", Image: "ubuntu:latest"}, "ubuntu:latest"}, + {data.Bundle{GortBundleVersion: 1, Name: "test-image-3", Version: "0.0.3", Description: "Foo", Image: "ubuntu"}, "ubuntu:latest"}, + } + + const msg = "Test case %d: Name:%q Image:%q" + + for i, test := range tests { + err := da.BundleCreate(da.ctx, test.B) + require.NoError(t, err, msg, i, test.B.Name, test.B.Image) + defer da.BundleDelete(da.ctx, test.B.Name, test.B.Version) + + b, err := da.BundleGet(da.ctx, test.B.Name, test.B.Version) + require.NoError(t, err, msg, i, test.B.Name, test.B.Image) + + assert.Equal(t, test.ExpectedImage, b.Image) + } +} + +func (da DataAccessTester) testBundleList(t *testing.T) { + da.BundleCreate(da.ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.0", Description: "foo"}) + defer da.BundleDelete(da.ctx, "test-list-0", "0.0") + da.BundleCreate(da.ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.1", Description: "foo"}) + defer da.BundleDelete(da.ctx, "test-list-0", "0.1") + da.BundleCreate(da.ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.0", Description: "foo"}) + defer da.BundleDelete(da.ctx, "test-list-1", "0.0") + da.BundleCreate(da.ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.1", Description: "foo"}) + defer da.BundleDelete(da.ctx, "test-list-1", "0.1") + + bundles, err := da.BundleList(da.ctx) + assert.NoError(t, err) + require.Len(t, bundles, 4) +} + +func (da DataAccessTester) testBundleVersionList(t *testing.T) { + da.BundleCreate(da.ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.0", Description: "foo"}) + defer da.BundleDelete(da.ctx, "test-list-0", "0.0") + da.BundleCreate(da.ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.1", Description: "foo"}) + defer da.BundleDelete(da.ctx, "test-list-0", "0.1") + da.BundleCreate(da.ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.0", Description: "foo"}) + defer da.BundleDelete(da.ctx, "test-list-1", "0.0") + da.BundleCreate(da.ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.1", Description: "foo"}) + defer da.BundleDelete(da.ctx, "test-list-1", "0.1") + + bundles, err := da.BundleVersionList(da.ctx, "test-list-0") + assert.NoError(t, err) + require.Len(t, bundles, 2) +} + +func (da DataAccessTester) testFindCommandEntry(t *testing.T) { + const BundleName = "test" + const BundleVersion = "0.0.1" + const CommandName = "echox" + + tb, err := getTestBundle() + assert.NoError(t, err) + + // Save to data store + err = da.BundleCreate(da.ctx, tb) + assert.NoError(t, err) + + // Load back from the data store + tb, err = da.BundleGet(da.ctx, tb.Name, tb.Version) + assert.NoError(t, err) + + // Sanity testing. Has the test case changed? + assert.Equal(t, BundleName, tb.Name) + assert.Equal(t, BundleVersion, tb.Version) + assert.NotNil(t, tb.Commands[CommandName]) + + // Not yet enabled. Should find nothing. + ce, err := da.FindCommandEntry(da.ctx, BundleName, CommandName) + assert.NoError(t, err) + assert.Len(t, ce, 0) + + err = da.BundleEnable(da.ctx, BundleName, BundleVersion) + assert.NoError(t, err) + + // Reload to capture enabled status + tb, err = da.BundleGet(da.ctx, tb.Name, tb.Version) + assert.NoError(t, err) + + // Enabled. Should find commands. + ce, err = da.FindCommandEntry(da.ctx, BundleName, CommandName) + assert.NoError(t, err) + assert.Len(t, ce, 1) + + // Is the loaded bundle correct? + assert.Equal(t, tb, ce[0].Bundle) + + tc := tb.Commands[CommandName] + cmd := ce[0].Command + assert.Equal(t, tc.Description, cmd.Description) + assert.Equal(t, tc.LongDescription, cmd.LongDescription) + assert.Equal(t, tc.Executable, cmd.Executable) + assert.Equal(t, tc.Name, cmd.Name) + assert.Equal(t, tc.Rules, cmd.Rules) +} + +func getTestBundle() (data.Bundle, error) { + return bundles.LoadBundleFromFile("../../testing/test-bundle.yml") +} diff --git a/dataaccess/tests/dataaccess.go b/dataaccess/tests/dataaccess.go new file mode 100644 index 0000000..89f2cbf --- /dev/null +++ b/dataaccess/tests/dataaccess.go @@ -0,0 +1,99 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package tests + +import ( + "context" + "time" + + "github.com/getgort/gort/bundles" + "github.com/getgort/gort/data" + "github.com/getgort/gort/data/rest" +) + +// This is a copy of dataaccess.DataAccess, to break an import cycle. +// DO NOT USE THIS. +type TestDataAccess interface { + bundles.CommandEntryFinder + + Initialize(context.Context) error + + RequestBegin(ctx context.Context, request *data.CommandRequest) error + RequestUpdate(ctx context.Context, request data.CommandRequest) error + RequestError(ctx context.Context, request data.CommandRequest, err error) error + RequestClose(ctx context.Context, result data.CommandResponseEnvelope) error + + BundleCreate(ctx context.Context, bundle data.Bundle) error + BundleDelete(ctx context.Context, name string, version string) error + BundleDisable(ctx context.Context, name string, version string) error + BundleEnable(ctx context.Context, name string, version string) error + BundleEnabledVersion(ctx context.Context, name string) (string, error) + BundleExists(ctx context.Context, name string, version string) (bool, error) + BundleGet(ctx context.Context, name string, version string) (data.Bundle, error) + BundleList(ctx context.Context) ([]data.Bundle, error) + BundleVersionList(ctx context.Context, name string) ([]data.Bundle, error) + BundleUpdate(ctx context.Context, bundle data.Bundle) error + + GroupCreate(ctx context.Context, group rest.Group) error + GroupDelete(ctx context.Context, groupname string) error + GroupExists(ctx context.Context, groupname string) (bool, error) + GroupGet(ctx context.Context, groupname string) (rest.Group, error) + GroupList(ctx context.Context) ([]rest.Group, error) + GroupPermissionList(ctx context.Context, groupname string) (rest.RolePermissionList, error) + GroupRoleAdd(ctx context.Context, groupname, rolename string) error + GroupRoleDelete(ctx context.Context, groupname, rolename string) error + GroupRoleList(ctx context.Context, groupname string) ([]rest.Role, error) + GroupUpdate(ctx context.Context, group rest.Group) error + GroupUserAdd(ctx context.Context, groupname string, username string) error + GroupUserDelete(ctx context.Context, groupname string, username string) error + GroupUserList(ctx context.Context, groupname string) ([]rest.User, error) + + RoleCreate(ctx context.Context, rolename string) error + RoleDelete(ctx context.Context, rolename string) error + RoleGet(ctx context.Context, rolename string) (rest.Role, error) + RoleGroupAdd(ctx context.Context, rolename, groupname string) error + RoleGroupDelete(ctx context.Context, rolename, groupname string) error + RoleGroupExists(ctx context.Context, rolename, groupname string) (bool, error) + RoleGroupList(ctx context.Context, rolename string) ([]rest.Group, error) + RoleList(ctx context.Context) ([]rest.Role, error) + RoleExists(ctx context.Context, rolename string) (bool, error) + RolePermissionAdd(ctx context.Context, rolename, bundlename, permission string) error + RolePermissionDelete(ctx context.Context, rolename, bundlename, permission string) error + RolePermissionExists(ctx context.Context, rolename, bundlename, permission string) (bool, error) + RolePermissionList(ctx context.Context, rolename string) (rest.RolePermissionList, error) + + TokenEvaluate(ctx context.Context, token string) bool + TokenGenerate(ctx context.Context, username string, duration time.Duration) (rest.Token, error) + TokenInvalidate(ctx context.Context, token string) error + TokenRetrieveByUser(ctx context.Context, username string) (rest.Token, error) + TokenRetrieveByToken(ctx context.Context, token string) (rest.Token, error) + + UserAuthenticate(ctx context.Context, username string, password string) (bool, error) + UserCreate(ctx context.Context, user rest.User) error + UserDelete(ctx context.Context, username string) error + UserExists(ctx context.Context, username string) (bool, error) + UserGet(ctx context.Context, username string) (rest.User, error) + UserGetByEmail(ctx context.Context, email string) (rest.User, error) + UserGetByID(ctx context.Context, adapter, id string) (rest.User, error) + UserGroupList(ctx context.Context, username string) ([]rest.Group, error) + UserGroupAdd(ctx context.Context, username string, groupname string) error + UserGroupDelete(ctx context.Context, username string, groupname string) error + UserList(ctx context.Context) ([]rest.User, error) + UserPermissionList(ctx context.Context, username string) (rest.RolePermissionList, error) + UserRoleList(ctx context.Context, username string) ([]rest.Role, error) + UserUpdate(ctx context.Context, user rest.User) error +} diff --git a/dataaccess/tests/group-access.go b/dataaccess/tests/group-access.go new file mode 100644 index 0000000..577e655 --- /dev/null +++ b/dataaccess/tests/group-access.go @@ -0,0 +1,371 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package tests + +import ( + "testing" + + "github.com/getgort/gort/data/rest" + "github.com/getgort/gort/dataaccess/errs" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (da DataAccessTester) testGroupAccess(t *testing.T) { + t.Run("testGroupUserAdd", da.testGroupUserAdd) + t.Run("testGroupUserList", da.testGroupUserList) + t.Run("testGroupCreate", da.testGroupCreate) + t.Run("testGroupDelete", da.testGroupDelete) + t.Run("testGroupExists", da.testGroupExists) + t.Run("testGroupGet", da.testGroupGet) + t.Run("testGroupRoleAdd", da.testGroupRoleAdd) + t.Run("testGroupPermissionList", da.testGroupPermissionList) + t.Run("testGroupList", da.testGroupList) + t.Run("testGroupRoleList", da.testGroupRoleList) + t.Run("testGroupUserDelete", da.testGroupUserDelete) +} + +func (da DataAccessTester) testGroupUserAdd(t *testing.T) { + var ( + groupname = "group-test-group-user-add" + username = "user-test-group-user-add" + useremail = "user@foo.bar" + ) + + err := da.GroupUserAdd(da.ctx, groupname, username) + assert.Error(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(da.ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(da.ctx, groupname) + + err = da.GroupUserAdd(da.ctx, groupname, username) + assert.Error(t, err, errs.ErrNoSuchUser) + + da.UserCreate(da.ctx, rest.User{Username: username, Email: useremail}) + defer da.UserDelete(da.ctx, username) + + err = da.GroupUserAdd(da.ctx, groupname, username) + assert.NoError(t, err) + + group, _ := da.GroupGet(da.ctx, groupname) + require.Len(t, group.Users, 1) + + assert.Equal(t, group.Users[0].Username, username) + assert.Equal(t, group.Users[0].Email, useremail) +} + +func (da DataAccessTester) testGroupUserList(t *testing.T) { + var ( + groupname = "group-test-group-user-list" + expected = []rest.User{ + {Username: "user-test-group-user-list-0", Email: "user-test-group-user-list-0@email.com", Mappings: map[string]string{}}, + {Username: "user-test-group-user-list-1", Email: "user-test-group-user-list-1@email.com", Mappings: map[string]string{}}, + } + ) + + _, err := da.GroupUserList(da.ctx, groupname) + assert.Error(t, err) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(da.ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(da.ctx, groupname) + + da.UserCreate(da.ctx, expected[0]) + defer da.UserDelete(da.ctx, expected[0].Username) + da.UserCreate(da.ctx, expected[1]) + defer da.UserDelete(da.ctx, expected[1].Username) + + da.GroupUserAdd(da.ctx, groupname, expected[0].Username) + da.GroupUserAdd(da.ctx, groupname, expected[1].Username) + + actual, err := da.GroupUserList(da.ctx, groupname) + require.NoError(t, err) + require.Len(t, actual, 2) + + assert.Equal(t, expected, actual) +} + +func (da DataAccessTester) testGroupCreate(t *testing.T) { + var err error + var group rest.Group + + // Expect an error + err = da.GroupCreate(da.ctx, group) + assert.Error(t, err, errs.ErrEmptyGroupName) + + // Expect no error + err = da.GroupCreate(da.ctx, rest.Group{Name: "test-create"}) + defer da.GroupDelete(da.ctx, "test-create") + assert.NoError(t, err) + + // Expect an error + err = da.GroupCreate(da.ctx, rest.Group{Name: "test-create"}) + assert.Error(t, err, errs.ErrGroupExists) +} + +func (da DataAccessTester) testGroupDelete(t *testing.T) { + // Delete blank group + err := da.GroupDelete(da.ctx, "") + assert.Error(t, err, errs.ErrEmptyGroupName) + + // Delete group that doesn't exist + err = da.GroupDelete(da.ctx, "no-such-group") + assert.Error(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(da.ctx, rest.Group{Name: "test-delete"}) // This has its own test + defer da.GroupDelete(da.ctx, "test-delete") + + err = da.GroupDelete(da.ctx, "test-delete") + assert.NoError(t, err) + + exists, _ := da.GroupExists(da.ctx, "test-delete") + if exists { + t.Error("Shouldn't exist anymore!") + t.FailNow() + } +} + +func (da DataAccessTester) testGroupExists(t *testing.T) { + var exists bool + + exists, _ = da.GroupExists(da.ctx, "test-exists") + if exists { + t.Error("Group should not exist now") + t.FailNow() + } + + // Now we add a group to find. + da.GroupCreate(da.ctx, rest.Group{Name: "test-exists"}) + defer da.GroupDelete(da.ctx, "test-exists") + + exists, _ = da.GroupExists(da.ctx, "test-exists") + if !exists { + t.Error("Group should exist now") + t.FailNow() + } +} + +func (da DataAccessTester) testGroupGet(t *testing.T) { + groupname := "group-test-group-get" + + var err error + var group rest.Group + + // Expect an error + _, err = da.GroupGet(da.ctx, "") + assert.ErrorIs(t, err, errs.ErrEmptyGroupName) + + // Expect an error + _, err = da.GroupGet(da.ctx, groupname) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(da.ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(da.ctx, groupname) + + // da.Group ctx, should exist now + exists, _ := da.GroupExists(da.ctx, groupname) + require.True(t, exists) + + // Expect no error + group, err = da.GroupGet(da.ctx, groupname) + require.NoError(t, err) + require.Equal(t, groupname, group.Name) +} + +func (da DataAccessTester) testGroupPermissionList(t *testing.T) { + const ( + groupname = "group-test-group-permission-list" + rolename = "role-test-group-permission-list" + bundlename = "test" + ) + + var expected = rest.RolePermissionList{ + {BundleName: bundlename, Permission: "role-test-group-permission-list-1"}, + {BundleName: bundlename, Permission: "role-test-group-permission-list-2"}, + {BundleName: bundlename, Permission: "role-test-group-permission-list-3"}, + } + + var err error + + da.GroupCreate(da.ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(da.ctx, groupname) + + da.RoleCreate(da.ctx, rolename) + defer da.RoleDelete(da.ctx, rolename) + + err = da.GroupRoleAdd(da.ctx, groupname, rolename) + require.NoError(t, err) + + da.RolePermissionAdd(da.ctx, rolename, expected[0].BundleName, expected[0].Permission) + da.RolePermissionAdd(da.ctx, rolename, expected[1].BundleName, expected[1].Permission) + da.RolePermissionAdd(da.ctx, rolename, expected[2].BundleName, expected[2].Permission) + + actual, err := da.GroupPermissionList(da.ctx, groupname) + require.NoError(t, err) + + assert.Equal(t, expected, actual) +} + +func (da DataAccessTester) testGroupRoleAdd(t *testing.T) { + var err error + + groupName := "group-group-grant-role" + roleName := "role-group-grant-role" + bundleName := "bundle-group-grant-role" + permissionName := "perm-group-grant-role" + + da.GroupCreate(da.ctx, rest.Group{Name: groupName}) + defer da.GroupDelete(da.ctx, groupName) + + err = da.RoleCreate(da.ctx, roleName) + require.NoError(t, err) + defer da.RoleDelete(da.ctx, roleName) + + err = da.RolePermissionAdd(da.ctx, roleName, bundleName, permissionName) + require.NoError(t, err) + + err = da.GroupRoleAdd(da.ctx, groupName, roleName) + require.NoError(t, err) + + expectedRoles := []rest.Role{ + { + Name: roleName, + Permissions: []rest.RolePermission{{BundleName: bundleName, Permission: permissionName}}, + }, + } + + roles, err := da.GroupRoleList(da.ctx, groupName) + require.NoError(t, err) + + assert.Equal(t, expectedRoles, roles) + + err = da.GroupRoleDelete(da.ctx, groupName, roleName) + require.NoError(t, err) + + expectedRoles = []rest.Role{} + + roles, err = da.GroupRoleList(da.ctx, groupName) + require.NoError(t, err) + + assert.Equal(t, expectedRoles, roles) +} + +func (da DataAccessTester) testGroupList(t *testing.T) { + da.GroupCreate(da.ctx, rest.Group{Name: "test-list-0"}) + defer da.GroupDelete(da.ctx, "test-list-0") + da.GroupCreate(da.ctx, rest.Group{Name: "test-list-1"}) + defer da.GroupDelete(da.ctx, "test-list-1") + da.GroupCreate(da.ctx, rest.Group{Name: "test-list-2"}) + defer da.GroupDelete(da.ctx, "test-list-2") + da.GroupCreate(da.ctx, rest.Group{Name: "test-list-3"}) + defer da.GroupDelete(da.ctx, "test-list-3") + + groups, err := da.GroupList(da.ctx) + assert.NoError(t, err) + + if len(groups) != 4 { + t.Errorf("Expected len(groups) = 4; got %d", len(groups)) + t.FailNow() + } + + for _, u := range groups { + if u.Name == "" { + t.Error("Expected non-empty name") + t.FailNow() + } + } +} + +func (da DataAccessTester) testGroupRoleList(t *testing.T) { + var ( + groupname = "group-test-group-list-roles" + rolenames = []string{ + "role-test-group-list-roles-0", + "role-test-group-list-roles-1", + "role-test-group-list-roles-2", + } + ) + da.GroupCreate(da.ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(da.ctx, groupname) + + da.RoleCreate(da.ctx, rolenames[1]) + defer da.RoleDelete(da.ctx, rolenames[1]) + + da.RoleCreate(da.ctx, rolenames[0]) + defer da.RoleDelete(da.ctx, rolenames[0]) + + da.RoleCreate(da.ctx, rolenames[2]) + defer da.RoleDelete(da.ctx, rolenames[2]) + + roles, err := da.GroupRoleList(da.ctx, groupname) + assert.NoError(t, err) + require.Empty(t, roles) + + err = da.GroupRoleAdd(da.ctx, groupname, rolenames[1]) + require.NoError(t, err) + err = da.GroupRoleAdd(da.ctx, groupname, rolenames[0]) + require.NoError(t, err) + err = da.GroupRoleAdd(da.ctx, groupname, rolenames[2]) + require.NoError(t, err) + + // Note: alphabetically sorted! + expected := []rest.Role{ + {Name: rolenames[0], Permissions: []rest.RolePermission{}}, + {Name: rolenames[1], Permissions: []rest.RolePermission{}}, + {Name: rolenames[2], Permissions: []rest.RolePermission{}}, + } + + actual, err := da.GroupRoleList(da.ctx, groupname) + require.NoError(t, err) + assert.Equal(t, expected, actual) +} + +func (da DataAccessTester) testGroupUserDelete(t *testing.T) { + da.GroupCreate(da.ctx, rest.Group{Name: "foo"}) + defer da.GroupDelete(da.ctx, "foo") + + da.UserCreate(da.ctx, rest.User{Username: "bat"}) + defer da.UserDelete(da.ctx, "bat") + + err := da.GroupUserAdd(da.ctx, "foo", "bat") + assert.NoError(t, err) + + group, err := da.GroupGet(da.ctx, "foo") + assert.NoError(t, err) + + if len(group.Users) != 1 { + t.Error("Users list empty") + t.FailNow() + } + + if len(group.Users) > 0 && group.Users[0].Username != "bat" { + t.Error("Wrong user!") + t.FailNow() + } + + err = da.GroupUserDelete(da.ctx, "foo", "bat") + assert.NoError(t, err) + + group, err = da.GroupGet(da.ctx, "foo") + assert.NoError(t, err) + + if len(group.Users) != 0 { + t.Error("User not removed") + t.FailNow() + } +} diff --git a/dataaccess/postgres/request-access_test.go b/dataaccess/tests/request-access.go similarity index 76% rename from dataaccess/postgres/request-access_test.go rename to dataaccess/tests/request-access.go index 2344849..8006c78 100644 --- a/dataaccess/postgres/request-access_test.go +++ b/dataaccess/tests/request-access.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package postgres +package tests import ( "fmt" @@ -25,13 +25,13 @@ import ( "github.com/stretchr/testify/assert" ) -func testRequestAccess(t *testing.T) { - t.Run("testRequestBegin", testRequestBegin) - t.Run("testRequestUpdate", testRequestUpdate) - t.Run("testRequestClose", testRequestClose) +func (da DataAccessTester) testRequestAccess(t *testing.T) { + t.Run("testRequestBegin", da.testRequestBegin) + t.Run("testRequestUpdate", da.testRequestUpdate) + t.Run("testRequestClose", da.testRequestClose) } -func testRequestBegin(t *testing.T) { +func (da DataAccessTester) testRequestBegin(t *testing.T) { bundle, err := getTestBundle() assert.NoError(t, err) @@ -53,16 +53,16 @@ func testRequestBegin(t *testing.T) { assert.Zero(t, req.RequestID) - err = da.RequestBegin(ctx, &req) + err = da.RequestBegin(da.ctx, &req) assert.NoError(t, err) assert.NotZero(t, req.RequestID) - err = da.RequestBegin(ctx, &req) + err = da.RequestBegin(da.ctx, &req) assert.Error(t, err) } -func testRequestUpdate(t *testing.T) { +func (da DataAccessTester) testRequestUpdate(t *testing.T) { bundle, err := getTestBundle() assert.NoError(t, err) @@ -82,16 +82,16 @@ func testRequestUpdate(t *testing.T) { UserName: "testUserName ", } - err = da.RequestUpdate(ctx, req) + err = da.RequestUpdate(da.ctx, req) assert.Error(t, err) req.RequestID = 1 - err = da.RequestUpdate(ctx, req) + err = da.RequestUpdate(da.ctx, req) assert.NoError(t, err) } -func testRequestClose(t *testing.T) { +func (da DataAccessTester) testRequestClose(t *testing.T) { bundle, err := getTestBundle() assert.NoError(t, err) @@ -112,13 +112,7 @@ func testRequestClose(t *testing.T) { UserName: "testUserName ", } - res := data.CommandResponse{ - Command: req, - Duration: time.Second, - Status: 1, - Error: fmt.Errorf("Fake error"), - } - - err = da.RequestClose(ctx, res) + env := data.NewCommandResponseEnvelope(req, data.WithError("", fmt.Errorf("fake error"), 1)) + err = da.RequestClose(da.ctx, env) assert.NoError(t, err) } diff --git a/dataaccess/tests/role-access.go b/dataaccess/tests/role-access.go new file mode 100644 index 0000000..0eb8315 --- /dev/null +++ b/dataaccess/tests/role-access.go @@ -0,0 +1,379 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package tests + +import ( + "testing" + + "github.com/getgort/gort/data/rest" + "github.com/getgort/gort/dataaccess/errs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (da DataAccessTester) testRoleAccess(t *testing.T) { + t.Run("testRoleCreate", da.testRoleCreate) + t.Run("testRoleList", da.testRoleList) + t.Run("testRoleExists", da.testRoleExists) + t.Run("testRoleDelete", da.testRoleDelete) + t.Run("testRoleGet", da.testRoleGet) + t.Run("testRoleGroupAdd", da.testRoleGroupAdd) + t.Run("testRoleGroupDelete", da.testRoleGroupDelete) + t.Run("testRoleGroupExists", da.testRoleGroupExists) + t.Run("testRoleGroupList", da.testRoleGroupList) + t.Run("testRolePermissionExists", da.testRolePermissionExists) + t.Run("testRolePermissionAdd", da.testRolePermissionAdd) + t.Run("testRolePermissionList", da.testRolePermissionList) +} + +func (da DataAccessTester) testRoleCreate(t *testing.T) { + var err error + + // Expect an error + err = da.RoleCreate(da.ctx, "") + assert.Error(t, err, errs.ErrEmptyRoleName) + + // Expect no error + err = da.RoleCreate(da.ctx, "test-create") + defer da.RoleDelete(da.ctx, "test-create") + assert.NoError(t, err) + + // Expect an error + err = da.RoleCreate(da.ctx, "test-create") + assert.Error(t, err, errs.ErrRoleExists) +} + +func (da DataAccessTester) testRoleList(t *testing.T) { + var err error + + // Get initial set of roles + roles, err := da.RoleList(da.ctx) + assert.NoError(t, err) + startingRoles := len(roles) + + // Create and populate role + rolename := "test-role-list" + bundle := "test-bundle-list" + permission := "test-permission-list" + err = da.RoleCreate(da.ctx, rolename) + defer da.RoleDelete(da.ctx, rolename) + assert.NoError(t, err) + + err = da.RolePermissionAdd(da.ctx, rolename, bundle, permission) + assert.NoError(t, err) + + // Expect 1 new role + roles, err = da.RoleList(da.ctx) + assert.NoError(t, err) + if assert.Equal(t, startingRoles+1, len(roles)) { + assert.Equal(t, rolename, roles[startingRoles].Name) + assert.Equal(t, bundle, roles[startingRoles].Permissions[0].BundleName) + assert.Equal(t, permission, roles[startingRoles].Permissions[0].Permission) + } +} + +func (da DataAccessTester) testRoleDelete(t *testing.T) { + // Delete blank group + err := da.RoleDelete(da.ctx, "") + assert.Error(t, err, errs.ErrEmptyRoleName) + + // Delete group that doesn't exist + err = da.RoleDelete(da.ctx, "no-such-group") + assert.Error(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(da.ctx, "test-delete") // This has its own test + defer da.RoleDelete(da.ctx, "test-delete") + + err = da.RoleDelete(da.ctx, "test-delete") + assert.NoError(t, err) + + exists, _ := da.RoleExists(da.ctx, "test-delete") + require.False(t, exists) +} + +func (da DataAccessTester) testRoleExists(t *testing.T) { + var exists bool + + exists, _ = da.RoleExists(da.ctx, "test-exists") + require.False(t, exists) + + // Now we add a group to find. + da.RoleCreate(da.ctx, "test-exists") + defer da.RoleDelete(da.ctx, "test-exists") + + exists, _ = da.RoleExists(da.ctx, "test-exists") + require.True(t, exists) +} + +func (da DataAccessTester) testRoleGet(t *testing.T) { + var err error + var role rest.Role + + // Expect an error + _, err = da.RoleGet(da.ctx, "") + assert.Error(t, err, errs.ErrEmptyRoleName) + + // Expect an error + _, err = da.RoleGet(da.ctx, "test-get") + assert.Error(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(da.ctx, "test-get") + defer da.RoleDelete(da.ctx, "test-get") + + // da.Role da.ctx, should exist now + exists, _ := da.RoleExists(da.ctx, "test-get") + require.True(t, exists) + + err = da.RolePermissionAdd(da.ctx, "test-get", "foo", "bar") + assert.NoError(t, err) + + err = da.RolePermissionAdd(da.ctx, "test-get", "foo", "bat") + assert.NoError(t, err) + + err = da.RolePermissionDelete(da.ctx, "test-get", "foo", "bat") + assert.NoError(t, err) + + expected := rest.Role{ + Name: "test-get", + Permissions: []rest.RolePermission{{BundleName: "foo", Permission: "bar"}}, + } + + // Expect no error + role, err = da.RoleGet(da.ctx, "test-get") + assert.NoError(t, err) + assert.Equal(t, expected, role) +} + +func (da DataAccessTester) testRoleGroupAdd(t *testing.T) { + var err error + + rolename := "role-test-role-group-add" + groupnames := []string{ + "perm-test-role-group-add-0", + "perm-test-role-group-add-1", + } + + // No such group yet + err = da.RoleGroupAdd(da.ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(da.ctx, rest.Group{Name: groupnames[0]}) + defer da.GroupDelete(da.ctx, groupnames[0]) + da.GroupCreate(da.ctx, rest.Group{Name: groupnames[1]}) + defer da.GroupDelete(da.ctx, groupnames[1]) + + // Groups exist now, but the role doesn't + err = da.RoleGroupAdd(da.ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(da.ctx, rolename) + defer da.RoleDelete(da.ctx, rolename) + + for _, groupname := range groupnames { + err = da.RoleGroupAdd(da.ctx, rolename, groupname) + assert.NoError(t, err) + } + + for _, groupname := range groupnames { + exists, _ := da.RoleGroupExists(da.ctx, rolename, groupname) + assert.True(t, exists, groupname) + } +} + +func (da DataAccessTester) testRoleGroupDelete(t *testing.T) { + +} + +func (da DataAccessTester) testRoleGroupExists(t *testing.T) { + var err error + + rolename := "role-test-role-group-exists" + groupnames := []string{ + "group-test-role-group-exists-0", + "group-test-role-group-exists-1", + } + groupnull := "group-test-role-group-exists-null" + + // No such role yet + _, err = da.RoleGroupExists(da.ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(da.ctx, rolename) + defer da.RoleDelete(da.ctx, rolename) + + // Groups exist now, but the role doesn't + _, err = da.RoleGroupExists(da.ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(da.ctx, rest.Group{Name: groupnames[0]}) + defer da.GroupDelete(da.ctx, groupnames[0]) + da.GroupCreate(da.ctx, rest.Group{Name: groupnames[1]}) + defer da.GroupDelete(da.ctx, groupnames[1]) + da.GroupCreate(da.ctx, rest.Group{Name: groupnull}) + defer da.GroupDelete(da.ctx, groupnull) + + for _, groupname := range groupnames { + da.RoleGroupAdd(da.ctx, rolename, groupname) + } + + for _, groupname := range groupnames { + exists, err := da.RoleGroupExists(da.ctx, rolename, groupname) + assert.NoError(t, err) + assert.True(t, exists) + } + + // Null group should NOT exist on the role + exists, err := da.RoleGroupExists(da.ctx, rolename, groupnull) + assert.NoError(t, err) + assert.False(t, exists) +} + +func (da DataAccessTester) testRoleGroupList(t *testing.T) { + var err error + + rolename := "role-test-role-group-list" + groupnames := []string{ + "group-test-role-group-list-0", + "group-test-role-group-list-1", + } + groupnull := "group-test-role-group-list-null" + + // No such role yet + _, err = da.RoleGroupList(da.ctx, rolename) + assert.ErrorIs(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(da.ctx, rolename) + defer da.RoleDelete(da.ctx, rolename) + + // Groups exist now, but the role doesn't + groups, err := da.RoleGroupList(da.ctx, rolename) + assert.NoError(t, err) + assert.Empty(t, groups) + + da.GroupCreate(da.ctx, rest.Group{Name: groupnames[1]}) + defer da.GroupDelete(da.ctx, groupnames[1]) + da.GroupCreate(da.ctx, rest.Group{Name: groupnames[0]}) + defer da.GroupDelete(da.ctx, groupnames[0]) + da.GroupCreate(da.ctx, rest.Group{Name: groupnull}) + defer da.GroupDelete(da.ctx, groupnull) + + for _, groupname := range groupnames { + da.RoleGroupAdd(da.ctx, rolename, groupname) + } + + // Currently the groups are NOT expected to be fully described (i.e., + // their roles slices don't have to be complete). + groups, err = da.RoleGroupList(da.ctx, rolename) + assert.NoError(t, err) + assert.Len(t, groups, 2) + + for i, g := range groups { + assert.Equal(t, groupnames[i], g.Name) + } +} + +func (da DataAccessTester) testRolePermissionAdd(t *testing.T) { + var exists bool + var err error + + const rolename = "role-test-role-permission-add" + const bundlename = "test" + const permname1 = "perm-test-role-permission-add-0" + const permname2 = "perm-test-role-permission-add-1" + + da.RoleCreate(da.ctx, rolename) + defer da.RoleDelete(da.ctx, rolename) + + role, _ := da.RoleGet(da.ctx, rolename) + require.Len(t, role.Permissions, 0) + + // First permission + err = da.RolePermissionAdd(da.ctx, rolename, bundlename, permname1) + require.NoError(t, err) + defer da.RolePermissionDelete(da.ctx, rolename, bundlename, permname1) + + role, _ = da.RoleGet(da.ctx, rolename) + require.Len(t, role.Permissions, 1) + + exists, _ = da.RolePermissionExists(da.ctx, rolename, bundlename, permname1) + require.True(t, exists) + + // Second permission + err = da.RolePermissionAdd(da.ctx, rolename, bundlename, permname2) + require.NoError(t, err) + defer da.RolePermissionDelete(da.ctx, rolename, bundlename, permname2) + + role, _ = da.RoleGet(da.ctx, rolename) + require.Len(t, role.Permissions, 2) + + exists, _ = da.RolePermissionExists(da.ctx, rolename, bundlename, permname2) + require.True(t, exists) +} + +func (da DataAccessTester) testRolePermissionExists(t *testing.T) { + var err error + + da.RoleCreate(da.ctx, "role-test-role-has-permission") + defer da.RoleDelete(da.ctx, "role-test-role-has-permission") + + has, err := da.RolePermissionExists(da.ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") + assert.NoError(t, err) + require.False(t, has) + + err = da.RolePermissionAdd(da.ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") + require.NoError(t, err) + defer da.RolePermissionDelete(da.ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") + + has, err = da.RolePermissionExists(da.ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") + assert.NoError(t, err) + require.True(t, has) + + has, err = da.RolePermissionExists(da.ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-2") + assert.NoError(t, err) + require.False(t, has) +} + +func (da DataAccessTester) testRolePermissionList(t *testing.T) { + var err error + + da.RoleCreate(da.ctx, "role-test-role-permission-list") + defer da.RoleDelete(da.ctx, "role-test-role-permission-list") + + err = da.RolePermissionAdd(da.ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-1") + require.NoError(t, err) + defer da.RolePermissionDelete(da.ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-1") + + err = da.RolePermissionAdd(da.ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-3") + require.NoError(t, err) + defer da.RolePermissionDelete(da.ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-3") + + err = da.RolePermissionAdd(da.ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-2") + require.NoError(t, err) + defer da.RolePermissionDelete(da.ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-2") + + // Expect a sorted list! + expect := rest.RolePermissionList{ + {BundleName: "test", Permission: "permission-test-role-permission-list-1"}, + {BundleName: "test", Permission: "permission-test-role-permission-list-2"}, + {BundleName: "test", Permission: "permission-test-role-permission-list-3"}, + } + + actual, err := da.RolePermissionList(da.ctx, "role-test-role-permission-list") + require.NoError(t, err) + + assert.Equal(t, expect, actual) +} diff --git a/dataaccess/tests/token-access.go b/dataaccess/tests/token-access.go new file mode 100644 index 0000000..427a732 --- /dev/null +++ b/dataaccess/tests/token-access.go @@ -0,0 +1,113 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package tests + +import ( + "testing" + "time" + + "github.com/getgort/gort/data/rest" + "github.com/getgort/gort/dataaccess/errs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (da DataAccessTester) testTokenAccess(t *testing.T) { + t.Run("testTokenGenerate", da.testTokenGenerate) + t.Run("testTokenRetrieveByUser", da.testTokenRetrieveByUser) + t.Run("testTokenRetrieveByToken", da.testTokenRetrieveByToken) + t.Run("testTokenExpiry", da.testTokenExpiry) + t.Run("testTokenInvalidate", da.testTokenInvalidate) +} + +func (da DataAccessTester) testTokenGenerate(t *testing.T) { + err := da.UserCreate(da.ctx, rest.User{Username: "test_generate"}) + defer da.UserDelete(da.ctx, "test_generate") + assert.NoError(t, err) + + token, err := da.TokenGenerate(da.ctx, "test_generate", 10*time.Minute) + defer da.TokenInvalidate(da.ctx, token.Token) + assert.NoError(t, err) + + require.Equal(t, token.Duration, 10*time.Minute) + require.Equal(t, token.User, "test_generate") + require.Equal(t, token.ValidFrom.Add(10*time.Minute), token.ValidUntil) +} + +func (da DataAccessTester) testTokenRetrieveByUser(t *testing.T) { + _, err := da.TokenRetrieveByUser(da.ctx, "no-such-user") + assert.Error(t, err, errs.ErrNoSuchToken) + + err = da.UserCreate(da.ctx, rest.User{Username: "test_uretrieve", Email: "test_uretrieve"}) + defer da.UserDelete(da.ctx, "test_uretrieve") + assert.NoError(t, err) + + token, err := da.TokenGenerate(da.ctx, "test_uretrieve", 10*time.Minute) + defer da.TokenInvalidate(da.ctx, token.Token) + assert.NoError(t, err) + + rtoken, err := da.TokenRetrieveByUser(da.ctx, "test_uretrieve") + assert.NoError(t, err) + require.Equal(t, token.Token, rtoken.Token) +} + +func (da DataAccessTester) testTokenRetrieveByToken(t *testing.T) { + _, err := da.TokenRetrieveByToken(da.ctx, "no-such-token") + assert.Error(t, err, errs.ErrNoSuchToken) + + err = da.UserCreate(da.ctx, rest.User{Username: "test_tretrieve", Email: "test_tretrieve"}) + defer da.UserDelete(da.ctx, "test_tretrieve") + assert.NoError(t, err) + + token, err := da.TokenGenerate(da.ctx, "test_tretrieve", 10*time.Minute) + defer da.TokenInvalidate(da.ctx, token.Token) + assert.NoError(t, err) + + rtoken, err := da.TokenRetrieveByToken(da.ctx, token.Token) + assert.NoError(t, err) + require.Equal(t, token.Token, rtoken.Token) +} + +func (da DataAccessTester) testTokenExpiry(t *testing.T) { + err := da.UserCreate(da.ctx, rest.User{Username: "test_expires", Email: "test_expires"}) + defer da.UserDelete(da.ctx, "test_expires") + assert.NoError(t, err) + + token, err := da.TokenGenerate(da.ctx, "test_expires", time.Second/2) + defer da.TokenInvalidate(da.ctx, token.Token) + assert.NoError(t, err) + require.False(t, token.IsExpired()) + + time.Sleep(time.Second) + + require.True(t, token.IsExpired()) +} + +func (da DataAccessTester) testTokenInvalidate(t *testing.T) { + err := da.UserCreate(da.ctx, rest.User{Username: "test_invalidate", Email: "test_invalidate"}) + defer da.UserDelete(da.ctx, "test_invalidate") + assert.NoError(t, err) + + token, err := da.TokenGenerate(da.ctx, "test_invalidate", 10*time.Minute) + defer da.TokenInvalidate(da.ctx, token.Token) + assert.NoError(t, err) + require.True(t, da.TokenEvaluate(da.ctx, token.Token)) + + err = da.TokenInvalidate(da.ctx, token.Token) + assert.NoError(t, err) + require.False(t, da.TokenEvaluate(da.ctx, token.Token)) +} diff --git a/dataaccess/tests/user-access.go b/dataaccess/tests/user-access.go new file mode 100644 index 0000000..58e6789 --- /dev/null +++ b/dataaccess/tests/user-access.go @@ -0,0 +1,383 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package tests + +import ( + "testing" + + "github.com/getgort/gort/data/rest" + "github.com/getgort/gort/dataaccess/errs" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (da DataAccessTester) testUserAccess(t *testing.T) { + t.Run("testUserAuthenticate", da.testUserAuthenticate) + t.Run("testUserCreate", da.testUserCreate) + t.Run("testUserDelete", da.testUserDelete) + t.Run("testUserExists", da.testUserExists) + t.Run("testUserGet", da.testUserGet) + t.Run("testUserGetByEmail", da.testUserGetByEmail) + t.Run("testUserGetByID", da.testUserGetByID) + t.Run("testUserGetNoMappings", da.testUserGetNoMappings) + t.Run("testUserGroupList", da.testUserGroupList) + t.Run("testUserList", da.testUserList) + t.Run("testUserNotExists", da.testUserNotExists) + t.Run("testUserPermissionList", da.testUserPermissionList) + t.Run("testUserUpdate", da.testUserUpdate) +} + +func (da DataAccessTester) testUserAuthenticate(t *testing.T) { + var err error + + authenticated, err := da.UserAuthenticate(da.ctx, "test-auth", "no-match") + assert.Error(t, err, errs.ErrNoSuchUser) + require.False(t, authenticated) + + // Expect no error + err = da.UserCreate(da.ctx, rest.User{ + Username: "test-auth", + Email: "test-auth@bar.com", + Password: "password", + }) + defer da.UserDelete(da.ctx, "test-auth") + assert.NoError(t, err) + + authenticated, err = da.UserAuthenticate(da.ctx, "test-auth", "no-match") + assert.NoError(t, err) + require.False(t, authenticated) + + authenticated, err = da.UserAuthenticate(da.ctx, "test-auth", "password") + assert.NoError(t, err) + require.True(t, authenticated) +} + +func (da DataAccessTester) testUserCreate(t *testing.T) { + var err error + var user rest.User + + // Expect an error + err = da.UserCreate(da.ctx, user) + assert.Error(t, err, errs.ErrEmptyUserName) + + // Expect no error + err = da.UserCreate(da.ctx, rest.User{Username: "test-create", Email: "test-create@bar.com"}) + defer da.UserDelete(da.ctx, "test-create") + assert.NoError(t, err) + + // Expect an error + err = da.UserCreate(da.ctx, rest.User{Username: "test-create", Email: "test-create@bar.com"}) + assert.Error(t, err, errs.ErrUserExists) +} + +func (da DataAccessTester) testUserDelete(t *testing.T) { + // Delete blank user + err := da.UserDelete(da.ctx, "") + assert.Error(t, err, errs.ErrEmptyUserName) + + // Delete admin user + err = da.UserDelete(da.ctx, "admin") + assert.Error(t, err, errs.ErrAdminUndeletable) + + // Delete user that doesn't exist + err = da.UserDelete(da.ctx, "no-such-user") + assert.Error(t, err, errs.ErrNoSuchUser) + + user := rest.User{Username: "test-delete", Email: "foo1.example.com"} + da.UserCreate(da.ctx, user) // This has its own test + defer da.UserDelete(da.ctx, "test-delete") + + err = da.UserDelete(da.ctx, "test-delete") + assert.NoError(t, err) + + exists, _ := da.UserExists(da.ctx, "test-delete") + require.False(t, exists) +} + +func (da DataAccessTester) testUserExists(t *testing.T) { + var exists bool + + exists, _ = da.UserExists(da.ctx, "test-exists") + if exists { + t.Error("User should not exist now") + t.FailNow() + } + + // Now we add a user to find. + err := da.UserCreate(da.ctx, rest.User{Username: "test-exists", Email: "test-exists@bar.com"}) + defer da.UserDelete(da.ctx, "test-exists") + assert.NoError(t, err) + + exists, _ = da.UserExists(da.ctx, "test-exists") + require.True(t, exists) +} + +func (da DataAccessTester) testUserGet(t *testing.T) { + const userName = "test-get" + const userEmail = "test-get@foo.com" + const userAdapter = "slack-get" + const userAdapterID = "U12345-get" + + var err error + var user rest.User + + // Expect an error + _, err = da.UserGet(da.ctx, "") + assert.EqualError(t, err, errs.ErrEmptyUserName.Error()) + + // Expect an error + _, err = da.UserGet(da.ctx, userName) + assert.EqualError(t, err, errs.ErrNoSuchUser.Error()) + + // Create the test user + err = da.UserCreate(da.ctx, rest.User{ + Username: userName, + Email: userEmail, + Mappings: map[string]string{userAdapter: userAdapterID}, + }) + defer da.UserDelete(da.ctx, userName) + require.NoError(t, err) + + // User should exist now + exists, err := da.UserExists(da.ctx, userName) + require.NoError(t, err) + require.True(t, exists) + + // Expect no error + user, err = da.UserGet(da.ctx, userName) + require.NoError(t, err) + require.Equal(t, user.Username, userName) + require.Equal(t, user.Email, userEmail) + require.NotNil(t, user.Mappings) + require.Equal(t, userAdapterID, user.Mappings[userAdapter]) +} + +func (da DataAccessTester) testUserGetNoMappings(t *testing.T) { + const userName = "test-get-no-mappings" + const userEmail = "test-get-no-mappings@foo.com" + + var err error + var user rest.User + + // Create the test user + err = da.UserCreate(da.ctx, rest.User{Username: userName, Email: userEmail}) + defer da.UserDelete(da.ctx, userName) + require.NoError(t, err) + + // Expect no error + user, err = da.UserGet(da.ctx, userName) + require.NoError(t, err) + require.Equal(t, user.Username, userName) + require.Equal(t, user.Email, userEmail) + require.NotNil(t, user.Mappings) +} + +func (da DataAccessTester) testUserGetByEmail(t *testing.T) { + const userName = "test-get-by-email" + const userEmail = "test-get-by-email@foo.com" + const userAdapter = "slack-get-by-email" + const userAdapterID = "U12345-get-by-email" + + var err error + var user rest.User + + // Expect an error + _, err = da.UserGetByEmail(da.ctx, "") + assert.EqualError(t, err, errs.ErrEmptyUserEmail.Error()) + + // Expect an error + _, err = da.UserGetByEmail(da.ctx, userEmail) + assert.EqualError(t, err, errs.ErrNoSuchUser.Error()) + + // Create the test user + err = da.UserCreate(da.ctx, rest.User{ + Username: userName, + Email: userEmail, + Mappings: map[string]string{userAdapter: userAdapterID}, + }) + defer da.UserDelete(da.ctx, userName) + require.NoError(t, err) + + // User should exist now + exists, err := da.UserExists(da.ctx, userName) + require.NoError(t, err) + require.True(t, exists) + + // Expect no error + user, err = da.UserGetByEmail(da.ctx, userEmail) + require.NoError(t, err) + require.Equal(t, user.Username, userName) + require.Equal(t, user.Email, userEmail) + require.NotNil(t, user.Mappings) + require.Equal(t, userAdapterID, user.Mappings[userAdapter]) +} + +func (da DataAccessTester) testUserGetByID(t *testing.T) { + const userName = "test-get-by-id" + const userEmail = "test-get-by-id@foo.com" + const userAdapter = "slack-get-by-id" + const userAdapterID = "U12345-get-by-id" + + var err error + var user rest.User + + // Expect errors + _, err = da.UserGetByID(da.ctx, "", userAdapterID) + assert.EqualError(t, err, errs.ErrEmptyUserAdapter.Error()) + + _, err = da.UserGetByID(da.ctx, userAdapter, "") + assert.EqualError(t, err, errs.ErrEmptyUserID.Error()) + + _, err = da.UserGetByID(da.ctx, userAdapter, userAdapterID) + assert.EqualError(t, err, errs.ErrNoSuchUser.Error()) + + // Create the test user + err = da.UserCreate(da.ctx, rest.User{ + Username: userName, + Email: userEmail, + Mappings: map[string]string{userAdapter: userAdapterID}, + }) + defer da.UserDelete(da.ctx, userName) + require.NoError(t, err) + + // User should exist now + exists, err := da.UserExists(da.ctx, userName) + require.NoError(t, err) + require.True(t, exists) + + // Expect no error + user, err = da.UserGetByID(da.ctx, userAdapter, userAdapterID) + require.NoError(t, err) + require.Equal(t, user.Username, userName) + require.Equal(t, user.Email, userEmail) + require.NotNil(t, user.Mappings) + require.Equal(t, userAdapterID, user.Mappings[userAdapter]) +} + +func (da DataAccessTester) testUserGroupList(t *testing.T) { + da.GroupCreate(da.ctx, rest.Group{Name: "group-test-user-group-list-0"}) + defer da.GroupDelete(da.ctx, "group-test-user-group-list-0") + + da.GroupCreate(da.ctx, rest.Group{Name: "group-test-user-group-list-1"}) + defer da.GroupDelete(da.ctx, "group-test-user-group-list-1") + + da.UserCreate(da.ctx, rest.User{Username: "user-test-user-group-list"}) + defer da.UserDelete(da.ctx, "user-test-user-group-list") + + da.GroupUserAdd(da.ctx, "group-test-user-group-list-0", "user-test-user-group-list") + + expected := []rest.Group{{Name: "group-test-user-group-list-0", Users: nil}} + + actual, err := da.UserGroupList(da.ctx, "user-test-user-group-list") + require.NoError(t, err) + + assert.Equal(t, expected, actual) +} + +func (da DataAccessTester) testUserList(t *testing.T) { + da.UserCreate(da.ctx, rest.User{Username: "test-list-0", Password: "password0!", Email: "test-list-0"}) + defer da.UserDelete(da.ctx, "test-list-0") + da.UserCreate(da.ctx, rest.User{Username: "test-list-1", Password: "password1!", Email: "test-list-1"}) + defer da.UserDelete(da.ctx, "test-list-1") + da.UserCreate(da.ctx, rest.User{Username: "test-list-2", Password: "password2!", Email: "test-list-2"}) + defer da.UserDelete(da.ctx, "test-list-2") + da.UserCreate(da.ctx, rest.User{Username: "test-list-3", Password: "password3!", Email: "test-list-3"}) + defer da.UserDelete(da.ctx, "test-list-3") + + users, err := da.UserList(da.ctx) + assert.NoError(t, err) + + require.Len(t, users, 4) + + for _, u := range users { + require.Empty(t, u.Password) + require.NotEmpty(t, u.Username) + } +} + +func (da DataAccessTester) testUserNotExists(t *testing.T) { + var exists bool + + err := da.Initialize(da.ctx) + assert.NoError(t, err) + + exists, _ = da.UserExists(da.ctx, "test-not-exists") + require.False(t, exists) +} + +func (da DataAccessTester) testUserPermissionList(t *testing.T) { + var err error + + err = da.GroupCreate(da.ctx, rest.Group{Name: "test-perms"}) + defer da.GroupDelete(da.ctx, "test-perms") + require.NoError(t, err) + + err = da.UserCreate(da.ctx, rest.User{Username: "test-perms", Password: "password0!", Email: "test-perms"}) + defer da.UserDelete(da.ctx, "test-perms") + require.NoError(t, err) + err = da.GroupUserAdd(da.ctx, "test-perms", "test-perms") + require.NoError(t, err) + + da.RoleCreate(da.ctx, "test-perms") + defer da.RoleDelete(da.ctx, "test-perms") + require.NoError(t, err) + err = da.GroupRoleAdd(da.ctx, "test-perms", "test-perms") + require.NoError(t, err) + + err = da.RolePermissionAdd(da.ctx, "test-perms", "test", "test-perms-1") + require.NoError(t, err) + err = da.RolePermissionAdd(da.ctx, "test-perms", "test", "test-perms-2") + require.NoError(t, err) + err = da.RolePermissionAdd(da.ctx, "test-perms", "test", "test-perms-0") + require.NoError(t, err) + + // Expected: a sorted list of strings + expected := []string{"test:test-perms-0", "test:test-perms-1", "test:test-perms-2"} + + actual, err := da.UserPermissionList(da.ctx, "test-perms") + require.NoError(t, err) + + assert.Equal(t, expected, actual.Strings()) +} + +func (da DataAccessTester) testUserUpdate(t *testing.T) { + // Update blank user + err := da.UserUpdate(da.ctx, rest.User{}) + assert.Error(t, err, errs.ErrEmptyUserName) + + // Update user that doesn't exist + err = da.UserUpdate(da.ctx, rest.User{Username: "no-such-user"}) + assert.Error(t, err, errs.ErrNoSuchUser) + + userA := rest.User{Username: "test-update", Email: "foo1.example.com"} + da.UserCreate(da.ctx, userA) + defer da.UserDelete(da.ctx, "test-update") + + // Get the user we just added. Emails should match. + user1, _ := da.UserGet(da.ctx, "test-update") + require.Equal(t, userA.Email, user1.Email) + + // Do the update + userB := rest.User{Username: "test-update", Email: "foo2.example.com"} + err = da.UserUpdate(da.ctx, userB) + assert.NoError(t, err) + + // Get the user we just updated. Emails should match. + user2, _ := da.UserGet(da.ctx, "test-update") + require.Equal(t, userB.Email, user2.Email) +} diff --git a/docker-compose.yml b/docker-compose.yml index ba2202c..883638c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,8 +25,8 @@ services: postgres: image: postgres:14 - # ports: - # - "5432:5432" + ports: + - "5432:5432" environment: - POSTGRES_USER=gort - POSTGRES_PASSWORD=veryKleverPassw0rd! diff --git a/go.mod b/go.mod index a839d93..d9b344b 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,25 @@ module github.com/getgort/gort go 1.16 require ( - github.com/Microsoft/go-winio v0.5.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver v1.5.0 // indirect + github.com/Masterminds/sprig v2.22.0+incompatible github.com/bwmarrin/discordgo v0.23.2 - github.com/containerd/containerd v1.5.7 // indirect - github.com/docker/docker v20.10.9+incompatible + github.com/containerd/containerd v1.5.8 // indirect + github.com/coreos/go-semver v0.3.0 + github.com/docker/docker v20.10.11+incompatible github.com/docker/go-connections v0.4.0 github.com/go-git/go-git/v5 v5.4.2 github.com/gorilla/mux v1.8.0 - github.com/lib/pq v1.10.3 + github.com/huandu/xstrings v1.3.2 // indirect + github.com/lib/pq v1.10.4 + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 - github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect + github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/image-spec v1.0.2 // indirect github.com/sirupsen/logrus v1.8.1 - github.com/slack-go/slack v0.9.5 + github.com/slack-go/slack v0.10.0 github.com/spf13/cobra v1.2.1 github.com/stretchr/testify v1.7.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0 @@ -25,6 +31,9 @@ require ( go.opentelemetry.io/otel/metric v0.20.0 go.opentelemetry.io/otel/sdk v0.20.0 go.opentelemetry.io/otel/trace v0.20.0 - golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 + golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b + k8s.io/api v0.22.4 + k8s.io/apimachinery v0.22.4 + k8s.io/client-go v0.22.4 ) diff --git a/go.sum b/go.sum index fcb0777..bfd320b 100644 --- a/go.sum +++ b/go.sum @@ -39,21 +39,31 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= +github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg= github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= +github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= +github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= @@ -61,9 +71,8 @@ github.com/Microsoft/go-winio v0.4.16-0.20201130162521-d1ffc52c7331/go.mod h1:XB github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.4.17 h1:iT12IBVClFevaf8PuVyi3UmZOVh4OqnaLxDTW2O6j3w= github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= -github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU= -github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ= @@ -71,7 +80,7 @@ github.com/Microsoft/hcsshim v0.8.9/go.mod h1:5692vkUqntj1idxauYlpoINNKeqCiG6Sg3 github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2ow3VK6a9Lg= github.com/Microsoft/hcsshim v0.8.15/go.mod h1:x38A4YbHbdxJtc0sF6oIz+RG0npwSCAvn69iY6URG00= github.com/Microsoft/hcsshim v0.8.16/go.mod h1:o5/SZqmR7x9JNKsW3pu+nqHm0MF8vbA+VxGOoXdC600= -github.com/Microsoft/hcsshim v0.8.21/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwTOcER2fw4I4= +github.com/Microsoft/hcsshim v0.8.23/go.mod h1:4zegtUJth7lAvFyc6cH2gGQ5B3OFQim01nnU2M8jKDg= github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU= github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= @@ -133,6 +142,7 @@ github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -182,13 +192,13 @@ github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMX github.com/containerd/containerd v1.4.0-beta.2.0.20200729163537-40b22ef07410/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.4.9/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.5.0-beta.1/go.mod h1:5HfvG1V2FsKesEGQ17k5/T7V960Tmcumvqn8Mc+pCYQ= github.com/containerd/containerd v1.5.0-beta.3/go.mod h1:/wr9AVtEM7x9c+n0+stptlo/uBBoBORwEx6ardVcmKU= github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09ZvgqEq8EfBp/m3lcVZIvPHhI= github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s= -github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g= -github.com/containerd/containerd v1.5.7 h1:rQyoYtj4KddB3bxG6SAqd4+08gePNyJjRqvOIfV3rkM= -github.com/containerd/containerd v1.5.7/go.mod h1:gyvv6+ugqY25TiXxcZC3L5yOeYgEw0QMhscqVp1AR9c= +github.com/containerd/containerd v1.5.8 h1:NmkCC1/QxyZFBny8JogwLpOy2f+VEbO/f6bV2Mqtwuw= +github.com/containerd/containerd v1.5.8/go.mod h1:YdFSv5bTFLpG2HIYmfqDpSYYTDX+mc5qtSuYx1YUb/s= github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= @@ -221,6 +231,7 @@ github.com/containerd/ttrpc v0.0.0-20190828172938-92c8520ef9f8/go.mod h1:PvCDdDG github.com/containerd/ttrpc v0.0.0-20191028202541-4f1b8fe65a5c/go.mod h1:LPm1u0xBw8r8NOKoOdNMeVHSawSsltak+Ihv+etqsE8= github.com/containerd/ttrpc v1.0.1/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y= github.com/containerd/ttrpc v1.0.2/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y= +github.com/containerd/ttrpc v1.1.0/go.mod h1:XX4ZTnoOId4HklF4edwc4DcqskFZuvXB1Evzy5KFQpQ= github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc= github.com/containerd/typeurl v0.0.0-20190911142611-5eb25027c9fd/go.mod h1:GeKYzf2pQcqv7tJ0AoCuuhtnqhva5LNU3U+OyKxxJpk= github.com/containerd/typeurl v1.0.1/go.mod h1:TB1hUtrpaiO88KEK56ijojHS1+NeF0izUACaJW2mdXg= @@ -244,6 +255,7 @@ github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmeka github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU= github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -275,8 +287,8 @@ github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TT github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v20.10.9+incompatible h1:JlsVnETOjM2RLQa0Cc1XCIspUdXW3Zenq9P54uXBm6k= -github.com/docker/docker v20.10.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v20.10.11+incompatible h1:OqzI/g/W54LczvhnccGqniFoQghHx3pklbLuhfXpqGo= +github.com/docker/docker v20.10.11+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-events v0.0.0-20170721190031-9461782956ad/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= @@ -308,11 +320,13 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= @@ -345,6 +359,8 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/logr v0.4.0 h1:K7/B1jt6fIBQVd4Owv2MqGQClcgf0R266+7C/QjRcLc= +github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= @@ -378,6 +394,7 @@ github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4er github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= @@ -407,6 +424,7 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -420,6 +438,7 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -439,10 +458,14 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= +github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= +github.com/googleapis/gnostic v0.5.5 h1:9fHAtK0uDfpveeqqo1hkEZJcFvYXAiCN3UutL8F9xHw= +github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= @@ -488,6 +511,8 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -513,6 +538,7 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= @@ -541,8 +567,8 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg= -github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= +github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= @@ -566,6 +592,8 @@ github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3N github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -576,16 +604,21 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ= github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo= -github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 h1:rzf0wL0CHVc8CEsgyygG0Mn9CNCCPZqOPaz8RiiHYQk= -github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc= +github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= +github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= @@ -617,11 +650,13 @@ github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+ github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= github.com/onsi/gomega v0.0.0-20151007035656-2152b45fa28a/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= @@ -631,8 +666,9 @@ github.com/opencontainers/go-digest v1.0.0-rc1.0.20180430190053-c9281466c8b2/go. github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= @@ -743,8 +779,8 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/slack-go/slack v0.9.5 h1:j7uOUDowybWf9eSgZg/AbGx6J1OPJB6SE8Z5dNl6Mtw= -github.com/slack-go/slack v0.9.5/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= +github.com/slack-go/slack v0.10.0 h1:L16Eqg3QZzRKGXIVsFSZdJdygjOphb2FjRUwH6VrFu8= +github.com/slack-go/slack v0.10.0/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -772,6 +808,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= @@ -890,10 +927,11 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -964,6 +1002,7 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= @@ -978,8 +1017,10 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -991,6 +1032,7 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 h1:0Ja1LBD+yisY6RWM/BH7TJVXWsSjs2VwBSmvSX4HdBc= golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1055,6 +1097,7 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1062,7 +1105,6 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1084,27 +1126,33 @@ golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s= golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= +golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1200,6 +1248,7 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -1236,6 +1285,7 @@ google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= @@ -1287,8 +1337,9 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -1303,6 +1354,7 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= @@ -1324,6 +1376,7 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= @@ -1342,15 +1395,21 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 k8s.io/api v0.20.1/go.mod h1:KqwcCVogGxQY3nBlRpwt+wpAMF/KjaCc7RpywacvqUo= k8s.io/api v0.20.4/go.mod h1:++lNL1AJMkDymriNniQsWRkMDzRaX2Y/POTUi8yvqYQ= k8s.io/api v0.20.6/go.mod h1:X9e8Qag6JV/bL5G6bU8sdVRltWKmdHsFUGS3eVndqE8= +k8s.io/api v0.22.4 h1:UvyHW0ezB2oIgHAxlYoo6UJQObYXU7awuNarwoHEOjw= +k8s.io/api v0.22.4/go.mod h1:Rgs+9gIGYC5laXQSZZ9JqT5NevNgoGiOdVWi1BAB3qk= k8s.io/apimachinery v0.20.1/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= k8s.io/apimachinery v0.20.6/go.mod h1:ejZXtW1Ra6V1O5H8xPBGz+T3+4gfkTCeExAHKU57MAc= +k8s.io/apimachinery v0.22.4 h1:9uwcvPpukBw/Ri0EUmWz+49cnFtaoiyEhQTK+xOe7Ck= +k8s.io/apimachinery v0.22.4/go.mod h1:yU6oA6Gnax9RrxGzVvPFFJ+mpnW6PBSqp0sx0I0HHW0= k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU= k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM= k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q= k8s.io/client-go v0.20.1/go.mod h1:/zcHdt1TeWSd5HoUe6elJmHSQ6uLLgp4bIJHVEuy+/Y= k8s.io/client-go v0.20.4/go.mod h1:LiMv25ND1gLUdBeYxBIwKpkSC5IsozMMmOOeSJboP+k= k8s.io/client-go v0.20.6/go.mod h1:nNQMnOvEUEsOzRRFIIkdmYOjAZrC8bgq0ExboWSU1I0= +k8s.io/client-go v0.22.4 h1:aAQ1Wk+I3bjCNk35YWUqbaueqrIonkfDPJSPDDe8Kfg= +k8s.io/client-go v0.22.4/go.mod h1:Yzw4e5e7h1LNHA4uqnMVrpEpUs1hJOiuBsJKIlRCHDA= k8s.io/component-base v0.20.1/go.mod h1:guxkoJnNoh8LNrbtiQOlyp2Y2XFCZQmrcg2n/DeYNLk= k8s.io/component-base v0.20.4/go.mod h1:t4p9EdiagbVCJKrQ1RsA5/V4rFQNDfRlevJajlGwgjI= k8s.io/component-base v0.20.6/go.mod h1:6f1MPBAeI+mvuts3sIdtpjljHWBQ2cIy38oBIWMYnrM= @@ -1361,9 +1420,14 @@ k8s.io/cri-api v0.20.6/go.mod h1:ew44AjNXwyn1s0U4xCKGodU7J1HzBeZ1MpGrpa5r8Yc= k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/klog/v2 v2.9.0 h1:D7HV+n1V57XeZ0m6tdRkfknthUaM06VFbWldOFh8kzM= +k8s.io/klog/v2 v2.9.0/go.mod h1:hy9LJ/NvuK+iVyP4Ehqva4HxZG/oXyIS3n3Jmire4Ec= k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= +k8s.io/kube-openapi v0.0.0-20211109043538-20434351676c/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw= k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a h1:8dYfu/Fc9Gz2rNJKB9IQRGgQOh2clmRzNIPPY1xLY5g= +k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= @@ -1371,6 +1435,9 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.14/go.mod h1:LEScyz sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.15/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= sigs.k8s.io/structured-merge-diff/v4 v4.0.3/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/structured-merge-diff/v4 v4.1.2 h1:Hr/htKFmJEbtMgS/UD0N+gtgctAqz81t3nu+sPzynno= +sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/gort.go b/gort.go index 9fa937e..90ad233 100644 --- a/gort.go +++ b/gort.go @@ -160,7 +160,24 @@ func startServer(ctx context.Context, config data.GortServerConfigs) { // Build the service representation server := service.BuildRESTServer(ctx, config.APIAddress) - // Start watching the + var certFile, keyFile = config.TLSCertFile, config.TLSKeyFile + var generated bool + + if certFile == "" || keyFile == "" { + log.Warn("Generating TLS certificates, please consider getting real ones") + + cf, kf, err := service.GenerateTemporaryTLSKeys() + if err != nil { + log.WithError(err).Fatal("Failed to generate TLS certificates") + return + } + + certFile = cf + keyFile = kf + generated = true + } + + // Start watching the request events go func() { logs := server.Requests() for event := range logs { @@ -174,16 +191,16 @@ func startServer(ctx context.Context, config data.GortServerConfigs) { } }() - // Make the service listen. + // Make the service listen go func() { - var err error - if config.TLSCertFile != "" && config.TLSKeyFile != "" { - err = server.ListenAndServeTLS(config.TLSCertFile, config.TLSKeyFile) - } else { - log.Warn("Using http for API connections, please consider using https") - err = server.ListenAndServe() - } - if err != nil { + defer func() { + if generated { + os.Remove(certFile) + os.Remove(keyFile) + } + }() + + if err := server.ListenAndServeTLS(certFile, keyFile); err != nil { telemetry.Errors().WithError(err).Commit(ctx) log.WithError(err).Fatal("Fatal service error") } diff --git a/helm/gort/.gitignore b/helm/gort/.gitignore new file mode 100644 index 0000000..80bf7fc --- /dev/null +++ b/helm/gort/.gitignore @@ -0,0 +1 @@ +charts \ No newline at end of file diff --git a/helm/gort/.helmignore b/helm/gort/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/helm/gort/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/helm/gort/Chart.yaml b/helm/gort/Chart.yaml new file mode 100644 index 0000000..ef09af2 --- /dev/null +++ b/helm/gort/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +name: gort +description: ChatOps automation framework +version: 0.0.1 +home: https://guide.getgort.io/ +appVersion: 0.9.0-dev.0 +keywords: +- gort +- chatops +sources: +- https://github.com/getgort/gort +- https://guide.getgort.io/ diff --git a/helm/gort/templates/gort/_helpers.tpl b/helm/gort/templates/gort/_helpers.tpl new file mode 100644 index 0000000..2fe77ba --- /dev/null +++ b/helm/gort/templates/gort/_helpers.tpl @@ -0,0 +1,34 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "gort.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "gort.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create the name of the service account to use +*/}} +{{- define "gort.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{ default (include "gort.fullname" .) .Values.serviceAccount.name }} +{{- else -}} +{{ default "default" .Values.serviceAccount.name }} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "gort.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/helm/gort/templates/gort/config.yaml b/helm/gort/templates/gort/config.yaml new file mode 100644 index 0000000..8fcd447 --- /dev/null +++ b/helm/gort/templates/gort/config.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "gort.fullname" . }}-config + labels: + app: {{ template "gort.name" . }} + chart: {{ template "gort.chart" . }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} +data: + config.yml: | +{{ .Values.config | toYaml | indent 4 }} diff --git a/helm/gort/templates/gort/deployment.yaml b/helm/gort/templates/gort/deployment.yaml new file mode 100644 index 0000000..de53c15 --- /dev/null +++ b/helm/gort/templates/gort/deployment.yaml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ template "gort.fullname" . }} + labels: + app: {{ template "gort.name" . }} + chart: {{ template "gort.chart" . }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} +spec: + selector: + matchLabels: + app: {{ template "gort.name" . }} + release: {{ .Release.Name }} + template: + metadata: + labels: + app: {{ template "gort.name" . }} + release: {{ .Release.Name }} + spec: + serviceAccountName: {{ template "gort.serviceAccountName" . }} + containers: + - name: gort + image: "{{ .Values.gort.image.repository }}:{{ default .Chart.AppVersion .Values.gort.image.version }}" + imagePullPolicy: {{ .Values.gort.image.pullPolicy }} + command: [ "gort", "start", "-v", "-c", "/etc/config/config.yml" ] + env: + - name: GORT_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: GORT_POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + ports: + - containerPort: {{ .Values.gort.internalPort }} + volumeMounts: + - name: config-volume + mountPath: /etc/config + volumes: + - name: config-volume + configMap: + name: {{ template "gort.fullname" . }}-config diff --git a/helm/gort/templates/gort/ingress.yaml b/helm/gort/templates/gort/ingress.yaml new file mode 100644 index 0000000..b3add1d --- /dev/null +++ b/helm/gort/templates/gort/ingress.yaml @@ -0,0 +1,27 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ template "gort.fullname" . }} + labels: + app: {{ template "gort.name" . }} + chart: {{ template "gort.chart" . }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + {{- range $key, $value := .Values.ingress.annotations }} + {{ $key }}: {{ $value | quote }} + {{- end }} +spec: + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ template "gort.fullname" . }} + port: + number: {{ .Values.gort.externalPort }} +{{- end -}} diff --git a/helm/gort/templates/gort/role.yaml b/helm/gort/templates/gort/role.yaml new file mode 100644 index 0000000..4795554 --- /dev/null +++ b/helm/gort/templates/gort/role.yaml @@ -0,0 +1,13 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app: {{ template "gort.name" . }} + chart: {{ template "gort.chart" . }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "gort.fullname" . }} +rules: +{{ toYaml .Values.rbac.role.rules }} +{{- end }} diff --git a/helm/gort/templates/gort/rolebinding.yaml b/helm/gort/templates/gort/rolebinding.yaml new file mode 100644 index 0000000..6bb27b9 --- /dev/null +++ b/helm/gort/templates/gort/rolebinding.yaml @@ -0,0 +1,18 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app: {{ template "gort.name" . }} + chart: {{ template "gort.chart" . }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "gort.fullname" . }} +subjects: +- kind: ServiceAccount + name: {{ template "gort.serviceAccountName" . }} +roleRef: + kind: Role + apiGroup: rbac.authorization.k8s.io + name: {{ template "gort.fullname" . }} +{{- end }} diff --git a/helm/gort/templates/gort/service.yaml b/helm/gort/templates/gort/service.yaml new file mode 100644 index 0000000..c98fb88 --- /dev/null +++ b/helm/gort/templates/gort/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "gort.fullname" . }} + labels: + app: {{ template "gort.name" . }} + chart: {{ template "gort.chart" . }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} +spec: + type: {{ .Values.gort.service.type }} + selector: + app: {{ template "gort.name" . }} + release: {{ .Release.Name }} + ports: + - port: {{ .Values.gort.externalPort }} + targetPort: {{ .Values.gort.internalPort }} + protocol: TCP + name: {{ .Release.Name }} diff --git a/helm/gort/templates/gort/serviceaccount.yaml b/helm/gort/templates/gort/serviceaccount.yaml new file mode 100644 index 0000000..ddd0b50 --- /dev/null +++ b/helm/gort/templates/gort/serviceaccount.yaml @@ -0,0 +1,11 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app: {{ template "gort.name" . }} + chart: {{ template "gort.chart" . }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "gort.serviceAccountName" . }} +{{- end }} diff --git a/helm/gort/values.yaml b/helm/gort/values.yaml new file mode 100644 index 0000000..71aae7b --- /dev/null +++ b/helm/gort/values.yaml @@ -0,0 +1,95 @@ +## Gort service settings +gort: + image: + repository: getgort/gort + version: latest + pullPolicy: IfNotPresent + + service: + name: gort + type: ClusterIP + annotations: {} + + externalPort: 4000 + internalPort: 4000 + +## Role Based Access Control +## +rbac: + create: true + + role: + ## Rules to create. It follows the role specification + rules: + - apiGroups: ['', 'batch'] + resources: ['jobs', 'pods'] + verbs: ['create', 'delete', 'get', 'list', 'watch'] + - apiGroups: ['', 'batch'] + resources: ['pods/log'] + verbs: ['get', 'watch'] + - apiGroups: ['', 'batch'] + resources: ['endpoints'] + verbs: ['list'] + +## Service Account +## +serviceAccount: + create: true + + ## The name of the ServiceAccount to use. + ## If not set and create is true, a name is generated using the fullname template + name: + +ingress: + enabled: true + +config: + global: + # How long before a command times out. Accepts a duration string: a sequence + # of decimal numbers, each with optional fraction and a unit suffix: 1d, + # 1h30m, 5m, 10s. Valid units are "ms", "s", "m", "h". Defaults to 60s. + # TODO Allow overriding at the command level + command_timeout: 60s + + gort: + # Gort will automatically create accounts for new users when set. + # User accounts created this way will still need to be placed into groups + # by an administrator in order to be granted any permissions. + allow_self_registration: true + + # The address to listen on for Gort's REST API. Defaults to ":4000". + api_address: ":4000" + + # Controls the prefix of URLs generated for the core API. URLs may contain a + # scheme (either http or https), a host, an optional port (defaulting to 80 + # for http and 443 for https), and an optional path. + # Defaults to localhost + api_url_base: localhost + + # Enables development mode. Currently this only affects log output format. + # Defaults to false + development_mode: true + + # If true, allows Gort to respond to commands prefixed with ! instead of only + # via direct mentions. Defaults to true. + enable_spoken_commands: true + + # If set along with tls_key_file, TLS will be used for API connections. + # This parameter specifies the path to a certificate file. + # tls_cert_file: host.crt + + # If set along with tls_cert_file, TLS will be used for API connections. + # This parameter specifies the path to a key file. + # The key must not be encrypted with a password. + # tls_key_file: host.key + + kubernetes: + # The selectors for Gort's endpoint resource. Used to dynamically find the + # API endpoint. If both are omitted the label selector "app=gort" is used. + endpoint_label_selector: "app=gort,release=gort" + endpoint_field_selector: + + # The selectors for Gort's pod resource. Used to dynamically find the + # API endpoint. If both are omitted the label selector "app=gort" is used. + pod_field_selector: "app=gort,release=gort" + pod_label_selector: diff --git a/relay/exitcodes.go b/relay/exitcodes.go new file mode 100644 index 0000000..8ac4e02 --- /dev/null +++ b/relay/exitcodes.go @@ -0,0 +1,66 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package relay + +// Most of the follow exit codes were borrowed from sysexits.h. + +const ( + // ExitOK represents a successful termination. + ExitOK = 0 + + // ExitGeneral is catchall for otherwise unspecified errors. + ExitGeneral = 1 + + // ExitNoUser represents a "user unknown" error. + ExitNoUser = 67 + + // ExitNoRelay represents "relay name unknown" error. + ExitNoRelay = 68 + + // ExitUnavailable represents a "relay unavailable" error. + ExitUnavailable = 69 + + // ExitInternalError represents an internal software error. It's returned + // if a command has a failure that's detectable by the framework. + ExitInternalError = 70 + + // ExitSystemErr represents a system (Gort) error (for example, it can't + // spawn a worker). + ExitSystemErr = 71 + + // ExitTimeout represents a timeout exceeded. + ExitTimeout = 72 + + // ExitIoErr represents an input/output error. + ExitIoErr = 74 + + // ExitTempFail represents a temporary failure. The user can retry. + ExitTempFail = 75 + + // ExitProtocol represents a remote error in protocol. + ExitProtocol = 76 + + // ExitNoPerm represents a permission denied. + ExitNoPerm = 77 + + // ExitCannotInvoke represents that the invoked command cannot execute. + // TODO(mtitmus) What does this mean, exactly? + ExitCannotInvoke = 126 + + // ExitCommandNotFound represents that the command can't be found. + ExitCommandNotFound = 127 +) diff --git a/relay/relay.go b/relay/relay.go index f645d11..0d104f1 100644 --- a/relay/relay.go +++ b/relay/relay.go @@ -18,11 +18,14 @@ package relay import ( "context" + "fmt" + "strings" "time" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel" + "github.com/getgort/gort/config" "github.com/getgort/gort/data" "github.com/getgort/gort/data/rest" "github.com/getgort/gort/dataaccess" @@ -32,25 +35,6 @@ import ( "github.com/getgort/gort/worker" ) -const ( - // Most exit codes borrowed from sysexits.h - - ExitOK = 0 // successful termination - ExitGeneral = 1 // catchall for errors - ExitNoUser = 67 // user unknown - ExitNoRelay = 68 // relay name unknown - ExitUnavailable = 69 // relay unavailable - ExitInternalError = 70 // internal software error - ExitSystemErr = 71 // system error (e.g., can't spawn worker) - ExitTimeout = 72 // timeout exceeded - ExitIoErr = 74 // input/output error - ExitTempFail = 75 // temp failure; user can retry - ExitProtocol = 76 // remote error in protocol - ExitNoPerm = 77 // permission denied - ExitCannotInvoke = 126 // Command invoked cannot execute - ExitCommandNotFound = 127 // "command not found" -) - // AuthorizeUser is not yet implemented, and will not be until remote relays // become a thing. For now, all authentication is done by the command execution // framework (which, in turn, invokes a/the relay). @@ -60,9 +44,9 @@ func AuthorizeUser(commandRequest data.CommandRequest, user rest.User) (bool, er } // StartListening instructs the relay to begin listening for incoming command requests. -func StartListening() (chan<- data.CommandRequest, <-chan data.CommandResponse) { +func StartListening() (chan<- data.CommandRequest, <-chan data.CommandResponseEnvelope) { commandRequests := make(chan data.CommandRequest) - commandResponses := make(chan data.CommandResponse) + commandResponses := make(chan data.CommandResponseEnvelope) go func() { for commandRequest := range commandRequests { @@ -77,7 +61,7 @@ func StartListening() (chan<- data.CommandRequest, <-chan data.CommandResponse) // SpawnWorker receives a CommandEntry and a slice of command parameters // strings, and constructs a new worker.Worker. -func SpawnWorker(ctx context.Context, command data.CommandRequest) (*worker.Worker, error) { +func SpawnWorker(ctx context.Context, command data.CommandRequest) (worker.Worker, error) { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) _, sp := tr.Start(ctx, "relay.SpawnWorker") defer sp.End() @@ -93,7 +77,7 @@ func SpawnWorker(ctx context.Context, command data.CommandRequest) (*worker.Work return nil, err } - return worker.NewWorker(command, token) + return worker.New(command, token) } // getUser is just a convenience function for interacting with the DAL. @@ -116,139 +100,160 @@ func getUser(ctx context.Context, username string) (rest.User, error) { // up after worker processes. It receives incoming command requests from the // StartListening() CommandRequest channel, returning a CommandResponse which // in turn gets forwarded to that function's CommandRequest channel. -func handleRequest(ctx context.Context, commandRequest data.CommandRequest) data.CommandResponse { +func handleRequest(ctx context.Context, request data.CommandRequest) data.CommandResponseEnvelope { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) ctx, sp := tr.Start(ctx, "relay.handleRequest") defer sp.End() - response := data.CommandResponse{ - Command: commandRequest, - Output: []string{}, + var envelope data.CommandResponseEnvelope + + da, err := dataaccess.Get() + if err != nil { + envelope = data.NewCommandResponseEnvelope( + request, + data.WithError("Failed to access data access layer", err, ExitIoErr), + ) + envelope.Data.Duration = time.Since(envelope.Request.Timestamp) + return envelope } - user, err := getUser(ctx, commandRequest.UserName) + defer func() { + envelope.Data.Duration = time.Since(envelope.Request.Timestamp) + da.RequestClose(ctx, envelope) + }() + + user, err := getUser(ctx, request.UserName) + switch { case err == nil: break case gerrs.Is(err, errs.ErrNoSuchUser): - response.Status = ExitNoUser - response.Error = err - response.Title = "No such Gort user: " + commandRequest.UserName - response.Output = []string{err.Error()} - return response + envelope = data.NewCommandResponseEnvelope( + request, + data.WithError("No such Gort user: "+request.UserName, err, ExitNoUser), + ) + return envelope case gerrs.Is(err, errs.ErrDataAccess): - response.Status = ExitIoErr - response.Error = err - response.Title = "Data access failure" - response.Output = []string{err.Error()} - return response + envelope = data.NewCommandResponseEnvelope( + request, + data.WithError("Data access failure", err, ExitIoErr), + ) + return envelope default: - response.Status = ExitGeneral - response.Error = err - response.Title = "Failed to get Gort user: " + commandRequest.UserName - response.Output = []string{err.Error()} - return response + envelope = data.NewCommandResponseEnvelope( + request, + data.WithError("Failed to get Gort user: "+request.UserName, err, ExitGeneral), + ) + return envelope } - if authorized, err := AuthorizeUser(commandRequest, user); err != nil { - response.Status = ExitGeneral - response.Error = err - response.Title = "Authorization system failure" - response.Output = []string{err.Error()} - return response + if authorized, err := AuthorizeUser(request, user); err != nil { + envelope = data.NewCommandResponseEnvelope( + request, + data.WithError("Authorization system failure", err, ExitSystemErr), + ) + return envelope } else if !authorized { - response.Status = ExitNoPerm - response.Error = err - response.Title = "Permission denied" - response.Output = []string{err.Error()} - return response + envelope = data.NewCommandResponseEnvelope( + request, + data.WithError("Permission denied", err, ExitNoPerm), + ) + return envelope } - worker, err := SpawnWorker(ctx, commandRequest) + worker, err := SpawnWorker(ctx, request) if err != nil { - response.Status = ExitSystemErr - response.Error = err - response.Title = "Failed to spawn worker" - response.Output = []string{err.Error()} - return response + envelope = data.NewCommandResponseEnvelope( + request, + data.WithError("Failed to spawn worker", err, ExitSystemErr), + ) + return envelope } - response = runWorker(ctx, worker, response) - response.Duration = time.Since(response.Command.Timestamp) + envelope = runWorker(ctx, worker, request) - da, err := dataaccess.Get() - if err != nil { - response.Status = ExitIoErr - response.Error = err - response.Title = "Failed to access data access layer" - response.Output = []string{err.Error()} - return response - } - - da.RequestClose(ctx, response) - - return response + return envelope } // runWorker is called by handleRequest to do the work of starting an // individual worker, capturing its output, and cleaning up after it. -func runWorker(ctx context.Context, worker *worker.Worker, response data.CommandResponse) data.CommandResponse { +func runWorker(ctx context.Context, worker worker.Worker, request data.CommandRequest) data.CommandResponseEnvelope { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) _, sp := tr.Start(ctx, "relay.runWorker") defer sp.End() + var envelope data.CommandResponseEnvelope + defer func() { + envelope.Data.Duration = time.Since(envelope.Request.Timestamp) + }() + // Get configured timeout. Zero (or less) is no timeout. - timeout := worker.ExecutionTimeout - if timeout <= 0 { - timeout = time.Hour * 24 * 365 // No timeout? No problem. + var cancel context.CancelFunc + if timeout := config.GetGlobalConfigs().CommandTimeout; timeout > 0 { + ctx, cancel = context.WithTimeout(ctx, timeout) + } else { + ctx, cancel = context.WithCancel(ctx) } - ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() stdoutChan, err := worker.Start(ctx) if err != nil { - response.Status = ExitSystemErr - response.Error = err - response.Title = "Failed to start worker" - response.Output = []string{err.Error()} - return response + envelope = data.NewCommandResponseEnvelope( + request, + data.WithError("Failed to start worker", err, ExitSystemErr), + ) + return envelope } // Read input from the worker until the stream closes + var lines []string for line := range stdoutChan { - response.Output = append(response.Output, line) + lines = append(lines, line) } + var exitCode int64 + select { - case response.Status = <-worker.Stopped(): - if response.Status != ExitOK { - response.Title = "Command Error" + case exitCode = <-worker.Stopped(): + var opts []data.CommandResponseEnvelopeOption - if len(response.Output) == 0 { - response.Output = []string{"Unknown error executing command"} + if exitCode != ExitOK { + if len(lines) == 0 { + lines = []string{"Unknown error executing command"} } + + opts = append(opts, data.WithError( + "Command Error", + fmt.Errorf(strings.Join(lines, " ")), + int16(exitCode), + )) } + opts = append(opts, data.WithResponseLines(lines)) + envelope = data.NewCommandResponseEnvelope(request, opts...) + log. - WithField("request.id", response.Command.RequestID). - WithField("status", response.Status). + WithField("request.id", request.RequestID). + WithField("status", exitCode). Info("Command exited") case <-ctx.Done(): err := ctx.Err() - response.Status = ExitTimeout - response.Error = err - response.Title = err.Error() + + envelope = data.NewCommandResponseEnvelope( + request, + data.WithError(err.Error(), err, ExitTimeout), + ) log. WithError(err). - WithField("request.id", response.Command.RequestID). - WithField("status", response.Status). + WithField("request.id", request.RequestID). + WithField("status", ExitTimeout). Info("Command exited with error") } forceTerm := time.Second * 10 worker.Stop(ctx, &forceTerm) - return response + return envelope } diff --git a/service/service.go b/service/service.go index 60de6d6..084707c 100644 --- a/service/service.go +++ b/service/service.go @@ -149,11 +149,18 @@ func (s *RESTServer) Requests() <-chan RequestEvent { // ListenAndServe starts the Gort web service. func (s *RESTServer) ListenAndServe() error { - log.WithField("address", s.Addr).Info("Gort controller is starting") + log.WithField("address", s.Addr).Info("Gort controller is starting in HTTP mode") return s.Server.ListenAndServe() } +// ListenAndServe starts the Gort web service. +func (s *RESTServer) ListenAndServeTLS(certFile string, keyFile string) error { + log.WithField("address", s.Addr).Info("Gort controller is starting in HTTPS mode") + + return s.Server.ListenAndServeTLS(certFile, keyFile) +} + func addManagementMethodsToRouter(router *mux.Router) { router.Handle("/v2/reload", otelhttp.NewHandler(http.HandlerFunc(handleReload), "reload")).Methods("GET") } @@ -287,7 +294,7 @@ func handleAuthenticate(w http.ResponseWriter, r *http.Request) { return } - token, err := dataAccessLayer.TokenGenerate(r.Context(), username, 10*time.Minute) + token, err := dataAccessLayer.TokenGenerate(r.Context(), username, 10*time.Second) if err != nil { respondAndLogError(r.Context(), w, err) return diff --git a/service/tls.go b/service/tls.go new file mode 100644 index 0000000..65aca6d --- /dev/null +++ b/service/tls.go @@ -0,0 +1,106 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "time" +) + +func generateKeyBytes() ([]byte, []byte, error) { + ca := &x509.Certificate{ + SerialNumber: big.NewInt(2019), + Subject: pkix.Name{ + Organization: []string{"Company, Inc."}, + StreetAddress: []string{"2020 5th Avenue"}, + Locality: []string{"New York"}, + Province: []string{"NY"}, + PostalCode: []string{"10000"}, + Country: []string{"US"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + + caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, nil, err + } + + caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) + if err != nil { + return nil, nil, err + } + + caPEM := new(bytes.Buffer) + pem.Encode(caPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: caBytes, + }) + + caPrivKeyPEM := new(bytes.Buffer) + pem.Encode(caPrivKeyPEM, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(caPrivKey), + }) + + return caPEM.Bytes(), caPrivKeyPEM.Bytes(), nil +} + +func GenerateTemporaryTLSKeys() (string, string, error) { + filemode := os.FileMode(0600) + + certBytes, keyBytes, err := generateKeyBytes() + if err != nil { + return "", "", err + } + + certFile, err := os.CreateTemp("", "gort.c.") + if err != nil { + return "", "", err + } + if err := certFile.Chmod(filemode); err != nil { + return "", "", err + } + if _, key := certFile.Write(certBytes); key != nil { + return "", "", err + } + + keyFile, err := os.CreateTemp("", "gort.k.") + if err != nil { + return "", "", err + } + if err := keyFile.Chmod(filemode); err != nil { + return "", "", err + } + if _, key := keyFile.Write(keyBytes); key != nil { + return "", "", err + } + + return certFile.Name(), keyFile.Name(), nil +} diff --git a/templates/functions.go b/templates/functions.go new file mode 100644 index 0000000..f53d2ca --- /dev/null +++ b/templates/functions.go @@ -0,0 +1,92 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +import ( + "encoding/json" + "fmt" + "reflect" + "text/template" + + "github.com/Masterminds/sprig" +) + +type Functions struct{} + +func FunctionMap() template.FuncMap { + functions := &Functions{} + + fm := map[string]interface{}{ + // Header + "header": functions.HeaderFunction, + "color": functions.HeaderColorFunction, + + // Image + "image": functions.ImageFunction, + "thumbnail": functions.ImageThumbnailunction, + + // Section + "section": functions.SectionFunction, + "endsection": functions.SectionEndFunction, + + // Text + "text": functions.TextFunction, + "inline": functions.TextInlineFunction, + "markdown": functions.TextMarkdownFunction, + "monospace": functions.TextMonospaceFunction, + "emoji": functions.TextEmojiFunction, + "endtext": functions.TextEndFunction, + + // Multiform functions + "title": functions.MultipleTitleFunction, + + // Simple blocks + "divider": functions.DividerFunction, + + // Alternative text + "alt": functions.AltFunction, + + // Unimplemented - for testing fallback behavior + "unimplemented": functions.UnimplementedFunction, + } + + sprigFuncs := sprig.FuncMap() + + for k, f := range fm { + sprigFuncs[k] = f + } + + return template.FuncMap(sprigFuncs) +} + +type Tag struct { + FirstIndex int `json:"-"` + LastIndex int `json:"-"` +} + +func (t *Tag) First() int { + return t.FirstIndex +} + +func (t *Tag) Last() int { + return t.LastIndex +} + +func encodeTag(i interface{}) string { + b, _ := json.Marshal(i) + return fmt.Sprintf("<<%s|%s>>", reflect.TypeOf(i).Name(), string(b)) +} diff --git a/templates/functions_alt.go b/templates/functions_alt.go new file mode 100644 index 0000000..ab6a21a --- /dev/null +++ b/templates/functions_alt.go @@ -0,0 +1,36 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +// Alt provides alternative text to be shown if other elements in a message cannot be rendered. +// Only the first instance of Alt will be shown. +type Alt struct { + Tag + Text string +} + +func (o *Alt) String() string { + return encodeTag(*o) +} + +func (o *Alt) Alt() string { + return o.Text +} + +func (f *Functions) AltFunction(content string) *Alt { + return &Alt{Text: content} +} diff --git a/templates/functions_header.go b/templates/functions_header.go new file mode 100644 index 0000000..b7b695c --- /dev/null +++ b/templates/functions_header.go @@ -0,0 +1,56 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +import ( + "fmt" + "strconv" + "strings" +) + +type Header struct { + Tag + + // Color must be expressed in RGB hex as "#123456" + Color string `json:",omitempty"` + + Title string `json:",omitempty"` +} + +func (o *Header) String() string { + return encodeTag(*o) +} + +func (o *Header) Alt() string { + return o.Title +} + +func (f *Functions) HeaderFunction() *Header { + return &Header{} +} + +func (f *Functions) HeaderColorFunction(s string, t *Header) (*Header, error) { + s = strings.Replace(s, "#", "", 1) + + v, err := strconv.ParseUint(s, 16, 64) + if err != nil { + return nil, fmt.Errorf("colors should be expressed in RGB hex format: #123456") + } + + t.Color = fmt.Sprintf("#%06X", v) + return t, nil +} diff --git a/templates/functions_image.go b/templates/functions_image.go new file mode 100644 index 0000000..35cdb87 --- /dev/null +++ b/templates/functions_image.go @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +type Image struct { + Tag + Height int `json:",omitempty"` + Width int `json:",omitempty"` + URL string `json:",omitempty"` + Thumbnail bool `json:",omitempty"` +} + +func (o *Image) String() string { + return encodeTag(*o) +} + +func (o *Image) Alt() string { + return o.URL +} + +func (f *Functions) ImageFunction(url string) *Image { + return &Image{URL: url} +} + +func (f *Functions) ImageThumbnailunction(b bool, i *Image) *Image { + i.Thumbnail = b + return i +} diff --git a/templates/functions_multiform.go b/templates/functions_multiform.go new file mode 100644 index 0000000..007b3a9 --- /dev/null +++ b/templates/functions_multiform.go @@ -0,0 +1,32 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +import "fmt" + +func (f *Functions) MultipleTitleFunction(s string, i interface{}) (interface{}, error) { + switch t := i.(type) { + case *Header: + t.Title = s + return t, nil + case *Text: + t.Title = s + return t, nil + default: + return nil, fmt.Errorf("%T does not support the header function", t) + } +} diff --git a/templates/functions_section.go b/templates/functions_section.go new file mode 100644 index 0000000..666c47e --- /dev/null +++ b/templates/functions_section.go @@ -0,0 +1,63 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +import "fmt" + +type Section struct { + Tag + Text *Text `json:",omitempty"` + Fields []OutputElement `json:",omitempty"` +} + +func (o *Section) String() string { + return encodeTag(*o) +} + +func (o *Section) Alt() string { + var out string + if o.Text != nil { + out = o.Text.Text + } + for _, element := range o.Fields { + if a, isAlt := element.(WithAlt); isAlt { + out = fmt.Sprintf("%v\n\n%v", out, a.Alt()) + } + } + return out +} + +func (f *Functions) SectionFunction() *Section { + return &Section{} +} + +type SectionEnd struct { + Tag +} + +func (o *SectionEnd) String() string { + return encodeTag(*o) +} + +func (o *SectionEnd) Alt() string { + return "" +} + +func (f *Functions) SectionEndFunction() *SectionEnd { + o := &SectionEnd{} + return o +} diff --git a/templates/functions_simple.go b/templates/functions_simple.go new file mode 100644 index 0000000..2704bc2 --- /dev/null +++ b/templates/functions_simple.go @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +type Divider struct { + Tag +} + +func (o *Divider) String() string { + return encodeTag(*o) +} + +func (o *Divider) Alt() string { + return "===" +} + +func (f *Functions) DividerFunction() *Divider { + return &Divider{} +} diff --git a/templates/functions_text.go b/templates/functions_text.go new file mode 100644 index 0000000..43e00ed --- /dev/null +++ b/templates/functions_text.go @@ -0,0 +1,79 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +type Text struct { + Tag + Emoji bool `json:",omitempty"` + Inline bool `json:",omitempty"` + Markdown bool `json:",omitempty"` + Monospace bool `json:",omitempty"` + Title string `json:",omitempty"` + Text string `json:",omitempty"` +} + +func (o *Text) String() string { + return encodeTag(*o) +} + +func (o *Text) Alt() string { + return o.Text +} + +func (f *Functions) TextFunction() *Text { + return &Text{ + Emoji: true, + Markdown: true, + } +} + +func (f *Functions) TextEmojiFunction(b bool, t *Text) *Text { + t.Emoji = b + return t +} + +func (f *Functions) TextInlineFunction(b bool, t *Text) *Text { + t.Inline = b + return t +} + +func (f *Functions) TextMarkdownFunction(b bool, t *Text) *Text { + t.Markdown = b + return t +} + +func (f *Functions) TextMonospaceFunction(b bool, t *Text) *Text { + t.Monospace = b + return t +} + +type TextEnd struct { + Tag +} + +func (o *TextEnd) String() string { + return encodeTag(*o) +} + +func (o *TextEnd) Alt() string { + return "" +} + +func (f *Functions) TextEndFunction() *TextEnd { + o := &TextEnd{} + return o +} diff --git a/templates/functions_unimplemented.go b/templates/functions_unimplemented.go new file mode 100644 index 0000000..257573a --- /dev/null +++ b/templates/functions_unimplemented.go @@ -0,0 +1,31 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +// Unimplemented is an OutputElement that should not be implemented by any chat adapters. +// It may be used when testing to force a fallback to alt text. +type Unimplemented struct { + Tag +} + +func (o *Unimplemented) String() string { + return encodeTag(*o) +} + +func (f *Functions) UnimplementedFunction() *Unimplemented { + return &Unimplemented{} +} diff --git a/templates/get.go b/templates/get.go new file mode 100644 index 0000000..aa3f534 --- /dev/null +++ b/templates/get.go @@ -0,0 +1,84 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +import ( + "fmt" + + "github.com/getgort/gort/config" + "github.com/getgort/gort/data" +) + +const ( + // DefaultCommand is a template used to format the outputs from successfully + // executed commands. + DefaultCommand = `{{ text | monospace true }}{{ .Response.Out }}{{ endtext }}` + + // DefaultCommandError is a template used to format the error messages + // produced by commands that return with a non-zero status. + DefaultCommandError = `{{ header | color "#FF0000" | title .Response.Title }} +{{ text }}Gort failed to execute the following command:{{ endtext }} +{{ text | monospace true }}{{ .Request.Bundle.Name }}:{{ .Request.Command.Name }} {{ .Request.Parameters }}{{ endtext }} +{{ text }}The specific error was:{{ endtext }} +{{ text | monospace true }}{{ .Response.Out }}{{ endtext }}` + + // DefaultMessage is a template used to format standard informative + // (non-error) messages from the Gort system (not commands). + DefaultMessage = `{{ text }}{{ .Response.Out }}{{ endtext }}` + + // DefaultMessageError is a template used to format error messages from the + // Gort system (not commands). + DefaultMessageError = `{{ header | color "#FF0000" | title .Response.Title }} +{{ text }}{{ .Response.Out }}{{ endtext }}` +) + +var templateDefaults = data.Templates{ + Message: DefaultMessage, + MessageError: DefaultMessageError, + Command: DefaultCommand, + CommandError: DefaultCommandError, +} + +// Get returns the first defined template found in the following sequence: +// 1. Command +// 2. Bundle +// 3. Config +// 4. Default +func Get(cmd data.BundleCommand, bundle data.Bundle, tt data.TemplateType) (string, error) { + // We really only need to check for an error on the first call. The + // outcome won't change after this. + switch template, err := cmd.Templates.Get(tt); { + case err != nil: + return "", err + case template != "": + return template, nil + } + + if template, _ := bundle.Templates.Get(tt); template != "" { + return template, nil + } + + if template, _ := config.GetTemplates().Get(tt); template != "" { + return template, nil + } + + if template, _ := templateDefaults.Get(tt); template != "" { + return template, nil + } + + return "", fmt.Errorf("no default template for %s found", tt) +} diff --git a/templates/get_test.go b/templates/get_test.go new file mode 100644 index 0000000..6d9de28 --- /dev/null +++ b/templates/get_test.go @@ -0,0 +1,90 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +import ( + "testing" + + "github.com/getgort/gort/bundles" + "github.com/getgort/gort/data" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadBundleFromFile(t *testing.T) { + bundle, err := bundles.LoadBundleFromFile("../testing/test-bundle.yml") + if err != nil { + t.Error(err.Error()) + } + cmd := *bundle.Commands["echox"] + + template, err := Get(cmd, bundle, data.TemplateType("foo")) + require.Equal(t, "", template) + require.Error(t, err) + + template, err = Get(cmd, bundle, data.Command) + assert.Equal(t, "Template:Command:Command", template) + assert.NoError(t, err) + + template, err = Get(cmd, bundle, data.CommandError) + assert.Equal(t, "Template:Command:CommandError", template) + assert.NoError(t, err) + + template, err = Get(cmd, bundle, data.Message) + assert.Equal(t, "Template:Command:Message", template) + assert.NoError(t, err) + + template, err = Get(cmd, bundle, data.MessageError) + assert.Equal(t, "Template:Command:MessageError", template) + assert.NoError(t, err) + + cmd.Templates = data.Templates{} + + template, err = Get(cmd, bundle, data.Command) + assert.Equal(t, "Template:Bundle:Command", template) + assert.NoError(t, err) + + template, err = Get(cmd, bundle, data.CommandError) + assert.Equal(t, "Template:Bundle:CommandError", template) + assert.NoError(t, err) + + template, err = Get(cmd, bundle, data.Message) + assert.Equal(t, "Template:Bundle:Message", template) + assert.NoError(t, err) + + template, err = Get(cmd, bundle, data.MessageError) + assert.Equal(t, "Template:Bundle:MessageError", template) + assert.NoError(t, err) + + bundle.Templates = data.Templates{} + + template, err = Get(cmd, bundle, data.Command) + assert.Equal(t, DefaultCommand, template) + assert.NoError(t, err) + + template, err = Get(cmd, bundle, data.CommandError) + assert.Equal(t, DefaultCommandError, template) + assert.NoError(t, err) + + template, err = Get(cmd, bundle, data.Message) + assert.Equal(t, DefaultMessage, template) + assert.NoError(t, err) + + template, err = Get(cmd, bundle, data.MessageError) + assert.Equal(t, DefaultMessageError, template) + assert.NoError(t, err) +} diff --git a/templates/templates.go b/templates/templates.go new file mode 100644 index 0000000..2c65929 --- /dev/null +++ b/templates/templates.go @@ -0,0 +1,269 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "text/template" + + "github.com/getgort/gort/data" +) + +type OutputElement interface { + First() int + Last() int + String() string +} + +type WithAlt interface { + Alt() string +} + +type OutputElements struct { + // Color is the representation of the RGB color hex code. If defined, it + // MUST adhere to the format "#RRGGBB". Optional. + Color string + + // Title is the title of the message, if set. Optional. + Title string + + // Elements includes the various message construction elements. + Elements []OutputElement +} + +// Alt returns simplified text for this output. +// It will either return a concatenation of the text form for each element, separated by newlines, +// or the first Alt element. +func (o *OutputElements) Alt() string { + var out string = o.Title + for _, element := range o.Elements { + switch t := element.(type) { + case *Alt: + return t.Text + default: + if a, isAlt := element.(WithAlt); isAlt { + out = fmt.Sprintf("%v\n\n%v", out, a.Alt()) + } + } + } + return out +} + +func TransformAndEncode(tmpl string, envelope data.CommandResponseEnvelope) (OutputElements, error) { + enc, err := Transform(tmpl, envelope) + if err != nil { + return OutputElements{}, err + } + + return EncodeElements(enc) +} + +func calculateLineNumber(text string, index int) int { + if index < 0 { + return -1 + } + + lines := strings.Split(text, "\n") + + for i, left := 0, index; i < len(lines); i++ { + length := len(lines[i]) + 1 + + if left < length { + return i + 1 + } + + left -= length + } + + return -1 +} + +// Encodes intermediate text generated by Transform into an OutputElements value +// that can be passed to an adapter. +func EncodeElements(text string) (OutputElements, error) { + var header *Header + var lastSection *Section + var lastText *Text + + var elements OutputElements + + for tag, jsn, first, last := nextTag(text, 0); first != -1; tag, jsn, first, last = nextTag(text, last) { + etag := Tag{FirstIndex: first, LastIndex: last} + + switch tag { + case "": + continue + + case "Divider": + switch { + case lastSection != nil: + return encodingError(text, first, "illegal {{divider}} in {{section}} on line %d") + case lastText != nil: + return encodingError(text, first, "illegal {{divider}} in {{text}} on line %d") + default: + elements.Elements = append(elements.Elements, &Divider{Tag: etag}) + } + + case "Header": + switch { + case header != nil: + return encodingError(text, first, "duplicate {{header}} on line %d") + case len(elements.Elements) != 0: + return encodingError(text, first, "unexpected {{header}} on line %d; header must be the first element") + default: + o := &Header{Tag: etag} + json.Unmarshal([]byte(jsn), o) + elements.Elements = append(elements.Elements, o) + } + + case "Image": + o := &Image{Tag: etag} + json.Unmarshal([]byte(jsn), o) + + switch { + case lastText != nil: + return encodingError(text, first, "illegal {{image}} in {{text}} on line %d") + case lastSection != nil: + lastSection.Fields = append(lastSection.Fields, o) + default: + elements.Elements = append(elements.Elements, o) + } + + case "Section": + switch { + case lastSection != nil: + return encodingError(text, first, "illegal {{section}} in {{section}} on line %d") + case lastText != nil: + return encodingError(text, first, "illegal {{section}} in {{text}} on line %d") + default: + o := &Section{Tag: etag} + json.Unmarshal([]byte(jsn), o) + lastSection = o + } + + case "SectionEnd": + switch { + case lastSection == nil: + return encodingError(text, first, "unmatched {{endsection}} on line %d") + default: + lastSection.Tag.LastIndex = last + elements.Elements = append(elements.Elements, lastSection) + lastSection = nil + } + + case "Text": + o := &Text{Tag: etag} + json.Unmarshal([]byte(jsn), o) + + switch { + case lastText != nil: + return encodingError(text, first, "illegal {{text}} in {{text}} on line %d") + default: + lastText = o + } + + case "TextEnd": + switch { + case lastText == nil: + return encodingError(text, first, "unmatched {{endtext}} on line %d") + case lastSection != nil: + lastText.Text = text[lastText.Last()+1 : first] + lastText.Tag.LastIndex = last + lastSection.Fields = append(lastSection.Fields, lastText) + lastText = nil + default: + lastText.Text = text[lastText.Last()+1 : first] + lastText.Tag.LastIndex = last + elements.Elements = append(elements.Elements, lastText) + lastText = nil + } + case "Unimplemented": + elements.Elements = append(elements.Elements, &Unimplemented{Tag: etag}) + + case "Alt": + o := &Alt{Tag: etag} + json.Unmarshal([]byte(jsn), o) + elements.Elements = append(elements.Elements, o) + + default: + return OutputElements{}, fmt.Errorf("unsupported {{ %s }}", tag) + } + } + + if lastSection != nil { + lineNumber := calculateLineNumber(text, lastSection.First()) + return OutputElements{}, fmt.Errorf("unmatched {{section}} on line %d", lineNumber) + } + if lastText != nil { + lineNumber := calculateLineNumber(text, lastText.First()) + return OutputElements{}, fmt.Errorf("unmatched {{text}} on line %d", lineNumber) + } + + return elements, nil +} + +// Transforms template text + envelope, resulting in intermediate text that +// can be encoded into an OutputElements value. +func Transform(tmpl string, envelope data.CommandResponseEnvelope) (string, error) { + t, err := template.New(envelope.Request.String()).Funcs(FunctionMap()).Parse(tmpl) + if err != nil { + return "", err + } + + b := new(bytes.Buffer) + err = t.Execute(b, envelope) + if err != nil { + return "", err + } + + return b.String(), nil +} + +func encodingError(text string, index int, format string) (OutputElements, error) { + return OutputElements{}, fmt.Errorf(format, calculateLineNumber(text, index)) +} + +// nextTag returns the tag, JSON text, and start and end tags for the next tag +// after start. If no tag is found, the index values are both returned as -1. +func nextTag(text string, start int) (tag string, json string, first int, last int) { + if start >= len(text) { + return "", "", -1, -1 + } + + text = text[start:] + + i, j := strings.Index(text, "<<"), strings.Index(text, ">>") + if i < 0 || j < 0 { + return "", "", -1, -1 + } + + first = i + start + last = j + start + 1 + text = text[i+2 : j] + + if m := strings.Index(text, "|{"); m == -1 || m > j { + tag = text + } else { + tag = text[:m] + json = text[m+1:] + } + + return tag, json, first, last +} diff --git a/templates/templates_test.go b/templates/templates_test.go new file mode 100644 index 0000000..88da631 --- /dev/null +++ b/templates/templates_test.go @@ -0,0 +1,279 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templates + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/getgort/gort/data" + "github.com/stretchr/testify/assert" +) + +var testStructuredEnvelope = data.CommandResponseEnvelope{ + Request: data.CommandRequest{ + CommandEntry: data.CommandEntry{ + Bundle: data.Bundle{ + Name: "gort", + }, + Command: data.BundleCommand{ + Name: "echo", + Executable: []string{"echo"}, + }, + }, + Parameters: []string{"foo", "bar"}, + }, + Response: data.CommandResponse{ + Lines: []string{"foo bar"}, + Out: "foo bar", + Title: "Error", + Structured: false, + }, +} + +const payloadJSON = `{ + "User": "Assistant to the Regional Manager Dwight", + "Requestor": "Michael Scott", + "Company": "Dunder Mifflin", + "Results": [ + { + "Name": "Farmhouse Thai Cuisine", + "Reviews": 1234, + "Description": "Awesome", + "Stars": 4, + "Image": "https://s3-media3.fl.yelpcdn.com/bphoto/c7ed05m9lC2EmA3Aruue7A/o.jpg" + } + ] +}` + +func TestMain(m *testing.M) { + json.Unmarshal([]byte(payloadJSON), &testStructuredEnvelope.Payload) + m.Run() +} + +func TestCalcLine(t *testing.T) { + text := "This is line 1.\n" + + "This is line 2.\n" + + "\n" + + "This is line 4." + + // Out of bounds should return -1 + assert.Equal(t, -1, calculateLineNumber(text, -1)) + assert.Equal(t, -1, calculateLineNumber(text, 1000)) + + for i, line := range strings.Split(text, "\n") { + if len(line) == 0 { + continue + } + + start := strings.Index(text, line) + end := start + len(line) + assert.Equal(t, i+1, calculateLineNumber(text, start)) + assert.Equal(t, i+1, calculateLineNumber(text, end)) + } +} + +func TestNextTag(t *testing.T) { + text := `<>This is text.<>` + + tag, json, first, last := nextTag(text, 0) + assert.Equal(t, "Text", tag) + assert.Equal(t, `{"Foo":"Bar"}`, json) + assert.Equal(t, 0, first) + assert.Equal(t, 21, last) + + tag, json, first, last = nextTag(text, last) + assert.Equal(t, "TextEnd", tag) + assert.Equal(t, `{}`, json) + assert.Equal(t, 35, first) + assert.Equal(t, 48, last) + + tag, json, first, last = nextTag(text, last) + assert.Equal(t, "", tag) + assert.Equal(t, "", json) + assert.Equal(t, -1, first) + assert.Equal(t, -1, last) +} + +func TestTransformAndEncodeText(t *testing.T) { + tests := []struct { + Template string + Transformed string + TransformError string + Encoded OutputElements + EncodeError string + }{ + { + Template: `{{ divider }}`, + Transformed: `<>`, + Encoded: OutputElements{ + Elements: []OutputElement{ + &Divider{ + Tag: Tag{FirstIndex: 0, LastIndex: 13}, + }, + }, + }, + }, + { + Template: `{{ header }}`, + Transformed: `<>`, + Encoded: OutputElements{ + Elements: []OutputElement{ + &Header{ + Tag: Tag{FirstIndex: 0, LastIndex: 12}, + }, + }, + }, + }, + { + Template: `{{ header | color "#FF0000" }}`, + Transformed: `<>`, + Encoded: OutputElements{ + Elements: []OutputElement{ + &Header{ + Tag: Tag{FirstIndex: 0, LastIndex: 29}, + Color: "#FF0000", + }, + }, + }, + }, + { + Template: `{{ header | color "FF 00 00" }}`, + Transformed: `<>`, + TransformError: `template: gort:echo foo bar:1:12: executing "gort:echo foo bar" at : error calling color: colors should be expressed in RGB hex format: #123456`, + }, + { + Template: `{{ header | title "Error" }}`, + Transformed: `<>`, + Encoded: OutputElements{ + Elements: []OutputElement{ + &Header{ + Tag: Tag{FirstIndex: 0, LastIndex: 27}, + Title: "Error", + }, + }, + }, + }, + { + Template: `{{ header | color "#FF0000" | title "Error" }}`, + Transformed: `<>`, + Encoded: OutputElements{ + Elements: []OutputElement{ + &Header{ + Tag: Tag{FirstIndex: 0, LastIndex: 45}, + Color: "#FF0000", + Title: "Error", + }, + }, + }, + }, + { + Template: `{{ image "https://example.com/image.jpg" }}`, + Transformed: `<>`, + Encoded: OutputElements{ + Elements: []OutputElement{ + &Image{ + Tag: Tag{FirstIndex: 0, LastIndex: 48}, + URL: "https://example.com/image.jpg", + }, + }, + }, + }, + { + Template: `{{ text | emoji true | markdown true | monospace true }}Test`, + Transformed: `<>Test`, + EncodeError: "unmatched {{text}} on line 1", + }, + { + Template: `Test{{ endtext }}`, + Transformed: `Test<>`, + EncodeError: "unmatched {{endtext}} on line 1", + }, + { + Template: `{{ text | emoji true | inline true | markdown true | monospace true }}Test{{ endtext }}`, + Transformed: `<>Test<>`, + Encoded: OutputElements{ + Elements: []OutputElement{ + &Text{ + Tag: Tag{FirstIndex: 0, LastIndex: 87}, + Emoji: true, + Inline: true, + Markdown: true, + Monospace: true, + Text: "Test", + }, + }, + }, + }, + { + Template: `{{ text | emoji true | inline true | markdown true | monospace true }}{{ .Payload.Company }}{{ endtext }}`, + Transformed: `<>Dunder Mifflin<>`, + Encoded: OutputElements{ + Elements: []OutputElement{ + &Text{ + Tag: Tag{FirstIndex: 0, LastIndex: 97}, + Emoji: true, + Inline: true, + Markdown: true, + Monospace: true, + Text: "Dunder Mifflin", + }, + }, + }, + }, + { + Template: `{{ text | title .Payload.Company }}{{ .Payload.Company }}{{ endtext }}`, + Transformed: `<>Dunder Mifflin<>`, + Encoded: OutputElements{ + Elements: []OutputElement{ + &Text{ + Tag: Tag{FirstIndex: 0, LastIndex: 91}, + Emoji: true, + Markdown: true, + Text: "Dunder Mifflin", + Title: "Dunder Mifflin", + }, + }, + }, + }, + } + + for idx, test := range tests { + msg := fmt.Sprintf("Index %d: %s", idx, test.Template) + + tf, err := Transform(test.Template, testStructuredEnvelope) + if test.TransformError != "" { + assert.EqualError(t, err, test.TransformError, msg) + continue + } else { + assert.NoError(t, err, msg) + } + assert.Equal(t, test.Transformed, tf, msg) + + enc, err := EncodeElements(tf) + if test.EncodeError != "" { + assert.EqualError(t, err, test.EncodeError, msg) + continue + } else { + assert.NoError(t, err, msg) + } + + assert.Equal(t, test.Encoded, enc, msg) + } +} diff --git a/testing/config/complete.yml b/testing/config/complete.yml index f3a29ca..0a0f9c0 100644 --- a/testing/config/complete.yml +++ b/testing/config/complete.yml @@ -113,37 +113,3 @@ slack: # The name of the bot, as it appears in Slack. Defaults to the name used # when the bot was added to the account. bot_name: Gort - -bundles: -- name: echo - description: A default bundle with echo commands. - - permissions: - - echo - - docker: - image: getgort/relaytest - tag: latest - - commands: - echo: - description: "Echos back anything sent to it, all at once." - executable: ["/bin/echo"] - splitecho: - description: "Echos back anything sent to it, one parameter at a time." - executable: ["/opt/app/splitecho.sh"] - -- name: curl - description: A default bundle that provides curl. - - permissions: - - echo - - docker: - image: getgort/relaytest - tag: latest - - commands: - curl: - description: "The official curl command." - executable: ["/usr/bin/curl"] diff --git a/testing/config/no-database-password.yml b/testing/config/no-database-password.yml index d434d0f..43fe2a9 100644 --- a/testing/config/no-database-password.yml +++ b/testing/config/no-database-password.yml @@ -110,37 +110,3 @@ slack: # The name of the bot, as it appears in Slack. Defaults to the name used # when the bot was added to the account. bot_name: Gort - -bundles: -- name: echo - description: A default bundle with echo commands. - - permissions: - - echo - - docker: - image: getgort/relaytest - tag: latest - - commands: - echo: - description: "Echos back anything sent to it, all at once." - executable: ["/bin/echo"] - splitecho: - description: "Echos back anything sent to it, one parameter at a time." - executable: ["/opt/app/splitecho.sh"] - -- name: curl - description: A default bundle that provides curl. - - permissions: - - echo - - docker: - image: getgort/relaytest - tag: latest - - commands: - curl: - description: "The official curl command." - executable: ["/usr/bin/curl"] diff --git a/testing/config/no-database.yml b/testing/config/no-database.yml index 50464e4..45dab34 100644 --- a/testing/config/no-database.yml +++ b/testing/config/no-database.yml @@ -68,37 +68,3 @@ slack: # The name of the bot, as it appears in Slack. Defaults to the name used # when the bot was added to the account. bot_name: Gort - -bundles: -- name: echo - description: A default bundle with echo commands. - - permissions: - - echo - - docker: - image: getgort/relaytest - tag: latest - - commands: - echo: - description: "Echos back anything sent to it, all at once." - executable: ["/bin/echo"] - splitecho: - description: "Echos back anything sent to it, one parameter at a time." - executable: ["/opt/app/splitecho.sh"] - -- name: curl - description: A default bundle that provides curl. - - permissions: - - echo - - docker: - image: getgort/relaytest - tag: latest - - commands: - curl: - description: "The official curl command." - executable: ["/usr/bin/curl"] diff --git a/testing/test-bundle-entrypoint.yml b/testing/test-bundle-entrypoint.yml index fbfbf03..b7cb8e8 100644 --- a/testing/test-bundle-entrypoint.yml +++ b/testing/test-bundle-entrypoint.yml @@ -13,9 +13,7 @@ long_description: |- permissions: - bar -docker: - image: ubuntu - tag: 20.04 +image: ubuntu:20.04 commands: bar: diff --git a/testing/test-bundle-foo.yml b/testing/test-bundle-foo.yml index 4e20d16..db3d9cf 100644 --- a/testing/test-bundle-foo.yml +++ b/testing/test-bundle-foo.yml @@ -13,9 +13,7 @@ long_description: |- permissions: - foo -docker: - image: ubuntu - tag: 20.04 +image: ubuntu:20.04 commands: foo: diff --git a/testing/test-bundle.yml b/testing/test-bundle.yml index b9e9476..6a77f25 100644 --- a/testing/test-bundle.yml +++ b/testing/test-bundle.yml @@ -13,9 +13,16 @@ long_description: |- permissions: - echox -docker: - image: ubuntu - tag: 20.04 +image: ubuntu:20.04 + +templates: + command_error: 'Template:Bundle:CommandError' + command: 'Template:Bundle:Command' + message_error: 'Template:Bundle:MessageError' + message: 'Template:Bundle:Message' + +kubernetes: + serviceAccountName: service-account commands: echox: @@ -28,3 +35,43 @@ commands: executable: [ "/bin/echo" ] rules: - must have test:echox + templates: + command: 'Template:Command:Command' + command_error: 'Template:Command:CommandError' + message: 'Template:Command:Message' + message_error: 'Template:Command:MessageError' + echoa: + description: "Write arguments to the standard output. Accessible to all users" + long_description: |- + Write arguments to the standard output. + + Usage: + test:echox [string ...] + executable: [ "/bin/echo" ] + rules: + - allow + templates: + command: '{{ text }}{{ .Response.Out }}{{ endtext }}' + command_error: 'Template:Command:CommandError' + message: 'Template:Command:Message' + message_error: 'Template:Command:MessageError' + noalt: + description: "Returns a message with an unrenderable tag, forcing alt text formed from element text." + executable: [ "/bin/echo" ] + rules: + - allow + templates: + command: '{{ text }}{{ .Response.Out }}{{ endtext }}{{ unimplemented }}' + command_error: 'Template:Command:CommandError' + message: 'Template:Command:Message' + message_error: 'Template:Command:MessageError' + alt: + description: "Returns a message with an unrenderable tag, forcing the alt tag to be rendered." + executable: [ "/bin/echo" ] + rules: + - allow + templates: + command: '{{ text }}{{ .Response.Out }}{{ endtext }}{{ unimplemented }}{{ alt "alt text" }}' + command_error: 'Template:Command:CommandError' + message: 'Template:Command:Message' + message_error: 'Template:Command:MessageError' diff --git a/testing/test-default.yml b/testing/test-default.yml index 4e9dd8f..11fe693 100644 --- a/testing/test-default.yml +++ b/testing/test-default.yml @@ -17,9 +17,7 @@ permissions: - manage_roles - manage_users -docker: - image: getgort/gort - tag: latest +image: getgort/gort:latest commands: bundle: @@ -138,3 +136,14 @@ commands: executable: [ "/bin/gort", "hidden", "commands" ] rules: - allow + + whoami: + description: "Provides your basic identity and account information" + long_description: |- + Provides your basic identity and account information. + + Usage: + gort:whoami + executable: [ "/bin/gort", "hidden", "whoami" ] + rules: + - allow diff --git a/tilt-datasources.yaml b/tilt-datasources.yaml new file mode 100644 index 0000000..0d8d050 --- /dev/null +++ b/tilt-datasources.yaml @@ -0,0 +1,40 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + labels: + app: postgres +spec: + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:14 + ports: + - containerPort: 5432 + env: + - name: POSTGRES_USER + value: gort + - name: POSTGRES_PASSWORD + value: veryKleverPassw0rd! +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + labels: + app: postgres +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 + protocol: TCP + name: postgres \ No newline at end of file diff --git a/version/version.go b/version/version.go index 722acf0..a93c01c 100644 --- a/version/version.go +++ b/version/version.go @@ -18,5 +18,5 @@ package version const ( // Version is the current version of Gort - Version = "0.8.4" + Version = "0.9.0" ) diff --git a/worker/container.go b/worker/docker/container.go similarity index 99% rename from worker/container.go rename to worker/docker/container.go index 30081ef..1c9935f 100644 --- a/worker/container.go +++ b/worker/docker/container.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package worker +package docker import ( "bufio" diff --git a/worker/docker/worker.go b/worker/docker/worker.go new file mode 100644 index 0000000..efdb2d0 --- /dev/null +++ b/worker/docker/worker.go @@ -0,0 +1,309 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package docker + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/getgort/gort/config" + "github.com/getgort/gort/data" + "github.com/getgort/gort/data/rest" + "github.com/getgort/gort/telemetry" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" +) + +// Worker represents a container executor. It has a lifetime of a single command execution. +type ContainerWorker struct { + command data.CommandRequest + commandParameters []string + containerID string + dockerClient *client.Client + dockerHost string + entryPoint []string + exitStatus chan int64 + imageName string + token rest.Token +} + +// New will build and returns a new Worker for a single command execution. +func New(command data.CommandRequest, token rest.Token) (*ContainerWorker, error) { + entrypoint := command.Command.Executable + params := command.Parameters + + dcli, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return nil, err + } + + // Reset the default host + err = client.WithHost(config.GetDockerConfigs().DockerHost)(dcli) + if err != nil { + return nil, err + } + + return &ContainerWorker{ + command: command, + commandParameters: params, + dockerClient: dcli, + dockerHost: config.GetDockerConfigs().DockerHost, + entryPoint: entrypoint, + exitStatus: make(chan int64), + imageName: command.Bundle.ImageFull(), + token: token, + }, nil +} + +// Start triggers a worker to run a container according to its settings. +// It returns a string channel that emits the container's combined stdout and stderr streams. +func (w *ContainerWorker) Start(ctx context.Context) (<-chan string, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + _, sp := tr.Start(ctx, "worker.docker.Start") + defer sp.End() + + // Track time spent in this method + startTime := time.Now() + + cli := w.dockerClient + imageName := w.imageName + entryPoint := w.entryPoint + + event := log. + WithField("image", w.imageName). + WithField("entry", entryPoint). + WithField("params", strings.Join(w.commandParameters, " ")) + if sp.SpanContext().HasTraceID() { + event = event.WithField("trace.id", sp.SpanContext().TraceID()) + } + + sp.SetAttributes( + attribute.String("image", w.imageName), + attribute.String("entry", strings.Join(entryPoint, " ")), + attribute.String("params", strings.Join(w.commandParameters, " ")), + ) + + // Start the image pull. This blocks until the pull is complete. + err := w.pullImage(ctx, false) + if err != nil { + return nil, err + } + + cfg := container.Config{ + Image: imageName, + Cmd: w.commandParameters, + Tty: true, + Env: w.envVars(), + } + + if len(entryPoint) > 0 { + cfg.Entrypoint = entryPoint + } + + // Create the container + resp, err := func() (container.ContainerCreateCreatedBody, error) { + ctx, sp := tr.Start(ctx, "worker.docker.ContainerCreate") + defer sp.End() + + // If a host network is defined, set it here. + var hc *container.HostConfig + if network := config.GetDockerConfigs().Network; network != "" { + hc = &container.HostConfig{NetworkMode: container.NetworkMode(network)} + } + + return cli.ContainerCreate(ctx, &cfg, hc, nil, nil, "") + }() + if err != nil { + return nil, err + } + + w.containerID = resp.ID + event = event.WithField("containerID", w.containerID) + + // Start the container + err = func() error { + ctx, sp := tr.Start(ctx, "worker.docker.ContainerStart") + defer sp.End() + return cli.ContainerStart(ctx, w.containerID, types.ContainerStartOptions{}) + }() + if err != nil { + return nil, err + } + + // Watch for the container to enter "not running" state. This supports the Stopped() method. + go func() { + chwait, errs := cli.ContainerWait(ctx, w.containerID, container.WaitConditionNotRunning) + event = event.WithField("duration", time.Since(startTime)) + + var status int64 + + select { + case ok := <-chwait: + if ok.Error != nil && ok.Error.Message != "" { + event = event.WithError(fmt.Errorf(ok.Error.Message)) + sp.SetAttributes(attribute.String("error", ok.Error.Message)) + telemetry.Errors().WithError(fmt.Errorf(ok.Error.Message)).Commit(ctx) + } + + status = ok.StatusCode + event.WithField("status", status). + Info("Worker completed") + + case err := <-errs: + status = 500 + event.WithField("status", status). + WithError(err). + Error("Error running container") + sp.SetAttributes(attribute.String("error", err.Error())) + } + + w.exitStatus <- status + sp.SetAttributes(attribute.Int64("status", status)) + }() + + // Build the channel that will stream back the container logs. + // Blocks until the container stops. + logs, err := BuildContainerLogChannel(ctx, cli, w.containerID) + if err != nil { + return nil, err + } + + return logs, nil +} + +// Stop will stop (if it's not already stopped) a worker process and clean up +// any resources it's using. If the worker fails to stop gracefully within a +// timeframe specified by the timeout argument, it is forcefully terminated +// (killed). If the timeout is nil, the engine's default is used. A negative +// timeout indicates no timeout: no forceful termination is performed. +func (w *ContainerWorker) Stop(ctx context.Context, timeout *time.Duration) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "worker.docker.Stop") + defer sp.End() + + func() error { + ctx, sp := tr.Start(ctx, "docker.ContainerStop") + defer sp.End() + return w.dockerClient.ContainerStop(ctx, w.containerID, timeout) + }() + + func() error { + ctx, sp := tr.Start(ctx, "docker.ContainerRemove") + defer sp.End() + return w.dockerClient.ContainerRemove(ctx, w.containerID, types.ContainerRemoveOptions{}) + }() + + log.WithField("containerID", w.containerID).Trace("container stopped and removed") +} + +// Stopped returns a channel that blocks until this worker's container has stopped. +// The value emitted is the exit status code of the underlying process. +func (w *ContainerWorker) Stopped() <-chan int64 { + return w.exitStatus +} + +func (w *ContainerWorker) envVars() []string { + env := []string{} + + vars := map[string]string{ + `GORT_ADAPTER`: w.command.Adapter, + `GORT_BUNDLE`: w.command.Bundle.Name, + `GORT_COMMAND`: w.command.Command.Name, + `GORT_CHAT_ID`: w.command.UserID, + `GORT_INVOCATION_ID`: fmt.Sprintf("%d", w.command.RequestID), + `GORT_ROOM`: w.command.ChannelID, + `GORT_SERVICE_TOKEN`: w.token.Token, + `GORT_SERVICES_ROOT`: config.GetGortServerConfigs().APIURLBase, + `GORT_USER`: w.command.UserName, + } + + for k, v := range vars { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + + return env +} + +// imageExistsLocally returns true if the specified image is present and +// accessible to the docker daemon. +func (w *ContainerWorker) imageExistsLocally(ctx context.Context, image string) (bool, error) { + if strings.IndexByte(image, ':') == -1 { + image += ":latest" + } + + images, err := w.dockerClient.ImageList(ctx, types.ImageListOptions{}) + if err != nil { + return false, err + } + + for _, img := range images { + for _, tag := range img.RepoTags { + if image == tag { + return true, nil + } + } + } + + return false, nil +} + +// pullImage pull the worker's image. It blocks until the pull is complete. +func (w *ContainerWorker) pullImage(ctx context.Context, force bool) error { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + _, sp := tr.Start(ctx, "worker.docker.pullImage") + defer sp.End() + + cli := w.dockerClient + imageName := w.imageName + + exists, err := w.imageExistsLocally(ctx, imageName) + if err != nil { + return err + } + + if force || !exists { + startTime := time.Now() + + log.WithField("image", imageName).Trace("Pulling container image", imageName) + + reader, err := cli.ImagePull(ctx, imageName, types.ImagePullOptions{}) + if err != nil { + return err + } + defer reader.Close() + + // Watch the daemon output until we get an EOF + bytes := make([]byte, 256) + var e error + for e == nil { + _, e = reader.Read(bytes) + } + + log.WithField("image", imageName). + WithField("duration", time.Since(startTime)). + Debug("Container image pulled") + } + + return nil +} diff --git a/worker/kubernetes/worker.go b/worker/kubernetes/worker.go new file mode 100644 index 0000000..df9488e --- /dev/null +++ b/worker/kubernetes/worker.go @@ -0,0 +1,471 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package kubernetes + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "time" + + "github.com/getgort/gort/config" + "github.com/getgort/gort/data" + "github.com/getgort/gort/data/rest" + "github.com/getgort/gort/telemetry" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + k8srest "k8s.io/client-go/rest" +) + +// KubernetesWorker represents a container executor. It has a lifetime of a single command execution. +type KubernetesWorker struct { + clientset *kubernetes.Clientset + command data.CommandRequest + commandParameters []string + entryPoint []string + exitStatus chan int64 + imageName string + jobName string + namespace string + token rest.Token +} + +// New will build and returns a new Worker for a single command execution. +func New(command data.CommandRequest, token rest.Token) (*KubernetesWorker, error) { + entrypoint := command.Command.Executable + params := command.Parameters + + // creates the in-cluster config + kconfig, err := k8srest.InClusterConfig() + if err != nil { + return nil, err + } + + // creates the clientset + clientset, err := kubernetes.NewForConfig(kconfig) + if err != nil { + return nil, err + } + + w := &KubernetesWorker{ + clientset: clientset, + command: command, + commandParameters: params, + entryPoint: entrypoint, + exitStatus: make(chan int64, 1), + imageName: command.Bundle.ImageFull(), + token: token, + } + + return w, nil +} + +// Start triggers a worker to run a Kubernetes Job. It returns a string +// channel that emits the container's combined stdout and stderr streams. +func (w *KubernetesWorker) Start(ctx context.Context) (<-chan string, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + _, sp := tr.Start(ctx, "worker.kubernetes.Start") + defer sp.End() + + // We have to set the namespace! + pod, err := w.findGortPod(ctx) + if err != nil { + return nil, err + } + w.namespace = pod.Namespace + + job, err := w.buildJobData(ctx) + if err != nil { + return nil, fmt.Errorf("failed to build job struct: %w", err) + } + + jobInterface := w.clientset.BatchV1().Jobs(w.namespace) + job, err = jobInterface.Create(ctx, job, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to start kubernetes job: %w", err) + } + + w.jobName = job.Name + + // Watch the Job's pod for termination. + if err := w.watchForPodTermination(ctx); err != nil { + return nil, fmt.Errorf("failed to watch job pod: %w", err) + } + + // Build the channel that will stream back the job logs. + // Blocks until the container stops. + logs, err := w.getJobOutput(ctx) + if err != nil { + return nil, err + } + + return logs, nil +} + +// Stop will stop (if it's not already stopped) a worker process and clean up +// any resources it's using. If the worker fails to stop gracefully within a +// timeframe specified by the timeout argument, it is forcefully terminated +// (killed). If the timeout is nil, the engine's default is used. A negative +// timeout indicates no timeout: no forceful termination is performed. +func (w *KubernetesWorker) Stop(ctx context.Context, timeout *time.Duration) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "worker.kubernetes.Stop") + defer sp.End() + + // Clean up the Job resource + err := func() error { + ctx, sp := tr.Start(ctx, "worker.kubernetes.Stop.CleanUpJob") + defer sp.End() + + // Clean up the job + jobInterface := w.clientset.BatchV1().Jobs(w.namespace) + deleteOptions := metav1.DeleteOptions{} + if timeout != nil && timeout.Seconds() > 0 { + seconds := int64(timeout.Seconds()) + deleteOptions.GracePeriodSeconds = &seconds + } + + return jobInterface.Delete(ctx, w.jobName, deleteOptions) + }() + if err != nil { + log.WithError(err).WithField("jobName", w.jobName).Error("Failed to delete job") + return + } + + // Clean up the Job's Pod resource + err = func() error { + ctx, sp := tr.Start(ctx, "worker.kubernetes.Stop.CleanUpPod") + defer sp.End() + + podInterface := w.clientset.CoreV1().Pods(w.namespace) + listOptions := metav1.ListOptions{LabelSelector: "job-name=" + w.jobName} + pl, err := podInterface.List(ctx, listOptions) + if err != nil { + return err + } + + if len(pl.Items) == 0 { + return fmt.Errorf("failed to find pod for job-name=%s", w.jobName) + } + + for _, p := range pl.Items { + if err := podInterface.Delete(ctx, p.Name, metav1.DeleteOptions{}); err != nil { + return err + } + } + return nil + }() + if err != nil { + log.WithError(err).WithField("jobName", w.jobName).Error("Failed to delete pod") + return + } + + log.WithField("jobName", w.jobName).Info("Job stopped and removed") +} + +// Stopped returns a channel that blocks until this worker's container has stopped. +// The value emitted is the exit status code of the underlying process. +func (w *KubernetesWorker) Stopped() <-chan int64 { + return w.exitStatus +} + +// Start triggers a worker to run a container according to its settings. +// It returns a string channel that emits the container's combined stdout and stderr streams. +func (w *KubernetesWorker) buildJobData(ctx context.Context) (*batchv1.Job, error) { + var backoffLimit int32 = 0 + + envVars, err := w.envVars(ctx) + if err != nil { + return nil, err + } + + job := &batchv1.Job{ + TypeMeta: metav1.TypeMeta{APIVersion: "batch/v1", Kind: "Job"}, + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s.%s-", w.command.Bundle.Name, w.command.Command.Name), + Labels: map[string]string{ + "gort.bundle": w.command.Bundle.Name, + "gort.command": w.command.Command.Name, + "gort.request": fmt.Sprintf("%d", w.command.RequestID), + }, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + ServiceAccountName: w.command.Bundle.Kubernetes.ServiceAccountName, + Containers: []corev1.Container{ + { + Name: "command", + Image: w.imageName, + Command: w.entryPoint, + Args: w.commandParameters, + Env: envVars, + }, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + } + + if len(w.entryPoint) > 0 { + job.Spec.Template.Spec.Containers[0].Command = w.entryPoint + } + + return job, nil +} + +// envVars builds the default environment variables that get injected into +// the command pod. +func (w *KubernetesWorker) envVars(ctx context.Context) ([]corev1.EnvVar, error) { + gortIP, gortPort, err := w.findGortEndpoint(ctx) + if err != nil { + return nil, err + } + + vars := map[string]string{ + `GORT_ADAPTER`: w.command.Adapter, + `GORT_BUNDLE`: w.command.Bundle.Name, + `GORT_COMMAND`: w.command.Command.Name, + `GORT_CHAT_ID`: w.command.UserID, + `GORT_INVOCATION_ID`: fmt.Sprintf("%d", w.command.RequestID), + `GORT_ROOM`: w.command.ChannelID, + `GORT_SERVICE_TOKEN`: w.token.Token, + `GORT_SERVICES_ROOT`: fmt.Sprintf("%s:%d", gortIP, gortPort), + `GORT_USER`: w.command.UserName, + } + + var env []corev1.EnvVar + + for k, v := range vars { + env = append(env, corev1.EnvVar{Name: k, Value: v}) + } + + return env, nil +} + +// findGortEndpoint uses the Kubernetes API to look for Gort's API endpoint. +// It will return an error if it doesn't have permission to "get" endpoint +// resources in the active namespace. +func (w *KubernetesWorker) findGortEndpoint(ctx context.Context) (string, int32, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "worker.kubernetes.findGortEndpoint") + defer sp.End() + + epInterface := w.clientset.CoreV1().Endpoints(w.namespace) + fieldSelector := config.GetKubernetesConfigs().EndpointFieldSelector + labelSelector := config.GetKubernetesConfigs().EndpointFieldSelector + + if fieldSelector == "" && labelSelector == "" { + labelSelector = "app=gort" + } + + opts := metav1.ListOptions{FieldSelector: fieldSelector, LabelSelector: labelSelector} + list, err := epInterface.List(ctx, opts) + if err != nil { + return "", 0, fmt.Errorf("failed to list gort endpoints: %w", err) + } + + switch len(list.Items) { + case 0: + return "", 0, fmt.Errorf("failed to find Gort endpoint (fieldSelector=%q labelSelector=%q)", labelSelector, fieldSelector) + case 1: + subset := list.Items[0].Subsets[0] + return subset.Addresses[0].IP, subset.Ports[0].Port, nil + default: + return "", 0, fmt.Errorf("found too many endpoints (n=%d fieldSelector=%q labelSelector=%q)", len(list.Items), labelSelector, fieldSelector) + } +} + +// findGortPod attempts to find the Gort service pod. +func (w *KubernetesWorker) findGortPod(ctx context.Context) (*corev1.Pod, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + _, sp := tr.Start(ctx, "worker.kubernetes.findGortPod") + defer sp.End() + + name := os.Getenv("GORT_POD_NAME") + + // The first time this is used, w.namespace may not be defined yet. + namespace := w.namespace + if namespace == "" { + namespace = os.Getenv("GORT_POD_NAMESPACE") + } + + podInterface := w.clientset.CoreV1().Pods(namespace) + + // If we know the namespace and name, this gets easy. + if name != "" && namespace != "" { + pod, err := podInterface.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get pod %s.%s: %w", namespace, name, err) + } + return pod, err + } + + // If we don't know the namespace, we have scan the list of Pods. + var listOptions metav1.ListOptions + + fieldSelector := config.GetKubernetesConfigs().PodFieldSelector + labelSelector := config.GetKubernetesConfigs().PodFieldSelector + + // To find the Gort pod, we use three possible strategies: + // 1: If the Downward API provided the Pod name, we use use that + // 2: If the config includes selectors, we use those + // 3: We use the label selector "app=gort" and hope for the best + if name != "" { + listOptions = metav1.ListOptions{FieldSelector: "metadata.name=" + name} + } else if fieldSelector != "" || labelSelector != "" { + listOptions = metav1.ListOptions{FieldSelector: fieldSelector, LabelSelector: labelSelector} + } else { + listOptions = metav1.ListOptions{LabelSelector: "app=gort"} + } + + list, err := podInterface.List(ctx, listOptions) + if err != nil { + return nil, fmt.Errorf("failed to list gort pods: %w", err) + } + + switch len(list.Items) { + case 0: + return nil, fmt.Errorf("failed to find Gort endpoint (fieldSelector=%q labelSelector=%q)", labelSelector, fieldSelector) + case 1: + return &list.Items[0], nil + default: + return nil, fmt.Errorf("found too many endpoints (n=%d fieldSelector=%q labelSelector=%q)", len(list.Items), labelSelector, fieldSelector) + } +} + +// getJobOutput returns a channel containing the job's pod's stdout output +// (i.e., its logs). This may pause briefly because it has to wait for the pod +// to exit the Pending state to access its logs. This will return an error if +// it doesn't have "get" and "watch" permissions on "pods/log". +func (w *KubernetesWorker) getJobOutput(ctx context.Context) (<-chan string, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "worker.kubernetes.getJobOutput") + defer sp.End() + + var pod *corev1.Pod + + listOptions := metav1.ListOptions{LabelSelector: "job-name=" + w.jobName} + podInterface := w.clientset.CoreV1().Pods(w.namespace) + wi, err := podInterface.Watch(ctx, listOptions) + if err != nil { + return nil, fmt.Errorf("failed to watch pod list: %w", err) + } + + resultChan := wi.ResultChan() + + for pod == nil { + select { + case e := <-resultChan: + if p, ok := e.Object.(*corev1.Pod); ok { + if p.Status.Phase != "Pending" { + pod = p + } + } + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + podLogOpts := &corev1.PodLogOptions{Container: "command", Follow: true} + req := podInterface.GetLogs(pod.Name, podLogOpts) + + podLogs, err := req.Stream(ctx) + if err != nil { + return nil, fmt.Errorf("failed to stream logs from pod: %w", err) + } + + return wrapReaderInChannel(podLogs), nil +} + +// watchForPodTermination watches for changes in the job's pod. When its +// container process terminates, it sends the exit code to w.exitStatus. +// An error is returned if the current service account lacks permissions to +// watch on pods in the working namespace. +func (w *KubernetesWorker) watchForPodTermination(ctx context.Context) error { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + _, sp := tr.Start(ctx, "worker.kubernetes.watchForPodTermination") + defer sp.End() + + listOptions := metav1.ListOptions{LabelSelector: "job-name=" + w.jobName} + podInterface := w.clientset.CoreV1().Pods(w.namespace) + watchInterface, err := podInterface.Watch(ctx, listOptions) + if err != nil { + return err + } + + go func() { + events := watchInterface.ResultChan() + + var terminated *corev1.ContainerStateTerminated + var complete bool + + for !complete { + select { + case e := <-events: + if pod, ok := e.Object.(*corev1.Pod); ok { + if len(pod.Status.ContainerStatuses) == 0 { + continue + } + + if terminated = pod.Status.ContainerStatuses[0].State.Terminated; terminated == nil { + continue + } + + complete = true + } + case <-ctx.Done(): + complete = true + } + } + + w.exitStatus <- int64(terminated.ExitCode) + }() + + return nil +} + +func wrapReaderInChannel(rc io.Reader) <-chan string { + ch := make(chan string) + errs := make(chan error) + + go func() { + scanner := bufio.NewScanner(rc) + for scanner.Scan() { + ch <- scanner.Text() + } + + err := scanner.Err() + if err != nil && err != io.EOF { + log.WithError(err).Error("error scanning reader") + } + + close(ch) + close(errs) + }() + + return ch +} diff --git a/worker/worker.go b/worker/worker.go index 819b84c..24bc3b1 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -19,301 +19,35 @@ package worker import ( "context" "fmt" - "strings" "time" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/client" - log "github.com/sirupsen/logrus" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "github.com/getgort/gort/config" "github.com/getgort/gort/data" "github.com/getgort/gort/data/rest" - "github.com/getgort/gort/telemetry" + "github.com/getgort/gort/worker/docker" + "github.com/getgort/gort/worker/kubernetes" ) // Worker represents a container executor. It has a lifetime of a single command execution. -type Worker struct { - Command data.CommandRequest - CommandParameters []string - DockerClient *client.Client - DockerHost string - EntryPoint []string - ExitStatus chan int64 - ExecutionTimeout time.Duration - ImageName string - Token rest.Token - containerID string -} - -// NewWorker will build and returns a new Worker for a single command execution. -func NewWorker(command data.CommandRequest, token rest.Token) (*Worker, error) { - image := command.Bundle.Docker.Image - tag := command.Bundle.Docker.Tag - entrypoint := command.Command.Executable - params := command.Parameters - - dcli, err := client.NewClientWithOpts(client.FromEnv) - if err != nil { - return nil, err - } - - // Reset the default host - err = client.WithHost(config.GetDockerConfigs().DockerHost)(dcli) - if err != nil { - return nil, err - } - - if tag == "" { - tag = "latest" - } - - return &Worker{ - Command: command, - CommandParameters: params, - DockerClient: dcli, - DockerHost: config.GetDockerConfigs().DockerHost, - EntryPoint: entrypoint, - ExecutionTimeout: config.GetGlobalConfigs().CommandTimeout, - ExitStatus: make(chan int64), - ImageName: image + ":" + tag, - Token: token, - }, nil -} - -// Start triggers a worker to run a container according to its settings. -// It returns a string channel that emits the container's combined stdout and stderr streams. -func (w *Worker) Start(ctx context.Context) (<-chan string, error) { - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - _, sp := tr.Start(ctx, "worker.Start") - defer sp.End() - - // Track time spent in this method - startTime := time.Now() - - cli := w.DockerClient - imageName := w.ImageName - entryPoint := w.EntryPoint - - event := log. - WithField("image", w.ImageName). - WithField("entry", entryPoint). - WithField("params", strings.Join(w.CommandParameters, " ")) - if sp.SpanContext().HasTraceID() { - event = event.WithField("trace.id", sp.SpanContext().TraceID()) - } - - sp.SetAttributes( - attribute.String("image", w.ImageName), - attribute.String("entry", strings.Join(entryPoint, " ")), - attribute.String("params", strings.Join(w.CommandParameters, " ")), - ) - - if w.ExecutionTimeout <= 0 { - w.ExecutionTimeout = time.Hour * 24 * 365 - } - - // Start the image pull. This blocks until the pull is complete. - err := w.pullImage(ctx, false) - if err != nil { - return nil, err - } - - cfg := container.Config{ - Image: imageName, - Cmd: w.CommandParameters, - Tty: true, - Env: w.envVars(), - } - - if len(entryPoint) > 0 { - cfg.Entrypoint = entryPoint - } - - // Create the container - resp, err := func() (container.ContainerCreateCreatedBody, error) { - ctx, sp := tr.Start(ctx, "docker.ContainerCreate") - defer sp.End() - - // If a host network is defined, set it here. - var hc *container.HostConfig - if network := config.GetDockerConfigs().Network; network != "" { - hc = &container.HostConfig{NetworkMode: container.NetworkMode(network)} - } - - return cli.ContainerCreate(ctx, &cfg, hc, nil, nil, "") - }() - if err != nil { - return nil, err - } - - w.containerID = resp.ID - event = event.WithField("containerID", w.containerID) - - // Start the container - err = func() error { - ctx, sp := tr.Start(ctx, "docker.ContainerStart") - defer sp.End() - return cli.ContainerStart(ctx, w.containerID, types.ContainerStartOptions{}) - }() - if err != nil { - return nil, err - } - - // Watch for the container to enter "not running" state. This supports the Stopped() method. - go func() { - chwait, errs := cli.ContainerWait(ctx, w.containerID, container.WaitConditionNotRunning) - event = event.WithField("duration", time.Since(startTime)) - - var status int64 - - select { - case ok := <-chwait: - if ok.Error != nil && ok.Error.Message != "" { - event = event.WithError(fmt.Errorf(ok.Error.Message)) - sp.SetAttributes(attribute.String("error", ok.Error.Message)) - telemetry.Errors().WithError(fmt.Errorf(ok.Error.Message)).Commit(ctx) - } - - status = ok.StatusCode - event.WithField("status", status). - Info("Worker completed") - - case err := <-errs: - status = 500 - event.WithField("status", status). - WithError(err). - Error("Error running container") - sp.SetAttributes(attribute.String("error", err.Error())) - } - - w.ExitStatus <- status - sp.SetAttributes(attribute.Int64("status", status)) - }() - - // Build the channel that will stream back the container logs. - // Blocks until the container stops. - logs, err := BuildContainerLogChannel(ctx, cli, w.containerID) - if err != nil { - return nil, err +type Worker interface { + Start(ctx context.Context) (<-chan string, error) + Stop(ctx context.Context, timeout *time.Duration) + Stopped() <-chan int64 +} + +// New will build and return a new Worker for a single command execution. +func New(command data.CommandRequest, token rest.Token) (Worker, error) { + dockerDefined := !config.IsUndefined(config.GetDockerConfigs()) + kubernetesDefined := !config.IsUndefined(config.GetKubernetesConfigs()) + + switch { + case dockerDefined && kubernetesDefined: + return nil, fmt.Errorf("exactly one of the following config sections expected: docker, kubernetes") + case dockerDefined: + return docker.New(command, token) + case kubernetesDefined: + return kubernetes.New(command, token) + default: + return nil, fmt.Errorf("exactly one of the following config sections expected: docker, kubernetes") } - - return logs, nil -} - -// Cleanup will stop (if it's not already stopped) a worker process and cleanup -// any resources it's using. If the worker fails to stop gracefully within a -// timeframe specified by the timeout argument, it is forcefully terminated -// (killed). If the timeout is nil, the engine's default is used. A negative -// timeout indicates no timeout: no forceful termination is performed. -func (w *Worker) Stop(ctx context.Context, timeout *time.Duration) { - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "worker.Start") - defer sp.End() - - func() error { - ctx, sp := tr.Start(ctx, "docker.ContainerStop") - defer sp.End() - return w.DockerClient.ContainerStop(ctx, w.containerID, timeout) - }() - - func() error { - ctx, sp := tr.Start(ctx, "docker.ContainerRemove") - defer sp.End() - return w.DockerClient.ContainerRemove(ctx, w.containerID, types.ContainerRemoveOptions{}) - }() - - log.WithField("containerID", w.containerID).Trace("container stopped and removed") -} - -// Stopped returns a channel that blocks until this worker's container has stopped. -// The value emitted is the exit status code of the underlying process. -func (w *Worker) Stopped() <-chan int64 { - return w.ExitStatus -} - -func (w *Worker) envVars() []string { - env := []string{} - - vars := map[string]string{ - `GORT_BUNDLE`: w.Command.Bundle.Name, - `GORT_COMMAND`: w.Command.Command.Name, - `GORT_CHAT_HANDLE`: w.Command.UserID, - `GORT_INVOCATION_ID`: fmt.Sprintf("%d", w.Command.RequestID), - `GORT_ROOM`: w.Command.ChannelID, - `GORT_SERVICE_TOKEN`: w.Token.Token, - `GORT_SERVICES_ROOT`: config.GetGortServerConfigs().APIURLBase, - } - - for k, v := range vars { - env = append(env, fmt.Sprintf("%s=%s", k, v)) - } - - return env -} - -// imageExistsLocally returns true if the specified image is present and -// accessible to the docker daemon. -func (w *Worker) imageExistsLocally(ctx context.Context, image string) (bool, error) { - if strings.IndexByte(image, ':') == -1 { - image += ":latest" - } - - images, err := w.DockerClient.ImageList(ctx, types.ImageListOptions{}) - if err != nil { - return false, err - } - - for _, img := range images { - for _, tag := range img.RepoTags { - if image == tag { - return true, nil - } - } - } - - return false, nil -} - -// pullImage pull the worker's image. It blocks until the pull is complete. -func (w *Worker) pullImage(ctx context.Context, force bool) error { - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - _, sp := tr.Start(ctx, "worker.pullImage") - defer sp.End() - - cli := w.DockerClient - imageName := w.ImageName - - exists, err := w.imageExistsLocally(ctx, imageName) - if err != nil { - return err - } - - if force || !exists { - startTime := time.Now() - - log.WithField("image", imageName).Trace("Pulling container image", imageName) - - reader, err := cli.ImagePull(ctx, imageName, types.ImagePullOptions{}) - if err != nil { - return err - } - defer reader.Close() - - // Watch the daemon output until we get an EOF - bytes := make([]byte, 256) - var e error - for e == nil { - _, e = reader.Read(bytes) - } - - log.WithField("image", imageName). - WithField("duration", time.Since(startTime)). - Debug("Container image pulled") - } - - return nil }