Skip to content

Commit

Permalink
fix: make MailTemplate the mail method receiver.
Browse files Browse the repository at this point in the history
fix: add subject to mail configuration.
fix: change naming scheme from MailInfo to CommitmentGroupNotification
  • Loading branch information
VoigtS committed Feb 25, 2025
1 parent 59d1d80 commit c79203b
Show file tree
Hide file tree
Showing 9 changed files with 89 additions and 75 deletions.
24 changes: 12 additions & 12 deletions internal/api/commitment.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,11 @@ func (p *v1Provider) GetProjectCommitments(w http.ResponseWriter, r *http.Reques
filter := reports.ReadFilter(r, p.Cluster)
queryStr, joinArgs := filter.PrepareQuery(getProjectAZResourceLocationsQuery)
whereStr, whereArgs := db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
azResourceLocationsByID := make(map[db.ProjectAZResourceID]datamodel.AZResourceLocation)
azResourceLocationsByID := make(map[db.ProjectAZResourceID]core.AZResourceLocation)
err := sqlext.ForeachRow(p.DB, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...), func(rows *sql.Rows) error {
var (
id db.ProjectAZResourceID
loc datamodel.AZResourceLocation
loc core.AZResourceLocation
)
err := rows.Scan(&id, &loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
if err != nil {
Expand Down Expand Up @@ -193,7 +193,7 @@ func (p *v1Provider) GetProjectCommitments(w http.ResponseWriter, r *http.Reques
respondwith.JSON(w, http.StatusOK, map[string]any{"commitments": result})
}

func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc datamodel.AZResourceLocation, token *gopherpolicy.Token) limesresources.Commitment {
func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc core.AZResourceLocation, token *gopherpolicy.Token) limesresources.Commitment {
resInfo := p.Cluster.InfoForResource(loc.ServiceType, loc.ResourceName)
apiIdentity := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName).IdentityInV1API
return limesresources.Commitment{
Expand All @@ -216,7 +216,7 @@ func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc
}
}

func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request) (*limesresources.CommitmentRequest, *datamodel.AZResourceLocation, *core.ResourceBehavior) {
func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request) (*limesresources.CommitmentRequest, *core.AZResourceLocation, *core.ResourceBehavior) {
// parse request
var parseTarget struct {
Request limesresources.CommitmentRequest `json:"commitment"`
Expand Down Expand Up @@ -262,7 +262,7 @@ func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r
return nil, nil, nil
}

loc := datamodel.AZResourceLocation{
loc := core.AZResourceLocation{
ServiceType: dbServiceType,
ResourceName: dbResourceName,
AvailabilityZone: req.AvailabilityZone,
Expand Down Expand Up @@ -476,7 +476,7 @@ func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Requ
} else if respondwith.ErrorText(w, err) {
return
}
var loc datamodel.AZResourceLocation
var loc core.AZResourceLocation
err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
if errors.Is(err, sql.ErrNoRows) {
Expand Down Expand Up @@ -630,7 +630,7 @@ func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Requ
return
}

var loc datamodel.AZResourceLocation
var loc core.AZResourceLocation
err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
if errors.Is(err, sql.ErrNoRows) {
Expand Down Expand Up @@ -702,7 +702,7 @@ func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http
return
}

var loc datamodel.AZResourceLocation
var loc core.AZResourceLocation
err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
if errors.Is(err, sql.ErrNoRows) {
Expand Down Expand Up @@ -753,7 +753,7 @@ func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request)
return
}

var loc datamodel.AZResourceLocation
var loc core.AZResourceLocation
err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
if errors.Is(err, sql.ErrNoRows) {
Expand Down Expand Up @@ -918,7 +918,7 @@ func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
} else if respondwith.ErrorText(w, err) {
return
}
var sourceLoc datamodel.AZResourceLocation
var sourceLoc core.AZResourceLocation
err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone)
if errors.Is(err, sql.ErrNoRows) {
Expand Down Expand Up @@ -1006,7 +1006,7 @@ func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
return
}
targetLoc := datamodel.AZResourceLocation{
targetLoc := core.AZResourceLocation{
ServiceType: targetServiceType,
ResourceName: targetResourceName,
AvailabilityZone: sourceLoc.AvailabilityZone,
Expand Down Expand Up @@ -1134,7 +1134,7 @@ func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Req
return
}

var loc datamodel.AZResourceLocation
var loc core.AZResourceLocation
err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
if errors.Is(err, sql.ErrNoRows) {
Expand Down
3 changes: 2 additions & 1 deletion internal/collector/capacity_scrape.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/sapcc/go-bits/logg"
"github.com/sapcc/go-bits/sqlext"

"github.com/sapcc/limes/internal/core"
"github.com/sapcc/limes/internal/datamodel"
"github.com/sapcc/limes/internal/db"
"github.com/sapcc/limes/internal/util"
Expand Down Expand Up @@ -383,7 +384,7 @@ func (c *Collector) confirmPendingCommitmentsIfNecessary(serviceType db.ServiceT
committableAZs = []liquid.AvailabilityZone{liquid.AvailabilityZoneAny}
}
for _, az := range committableAZs {
loc := datamodel.AZResourceLocation{
loc := core.AZResourceLocation{
ServiceType: serviceType,
ResourceName: resourceName,
AvailabilityZone: az,
Expand Down
4 changes: 3 additions & 1 deletion internal/collector/capacity_scrape_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ const (
# test automatic project quota calculation with non-default settings on */capacity resources
- { resource: '.*/capacity', model: autogrow, autogrow: { growth_multiplier: 1.0, project_base_quota: 10, usage_data_retention_period: 1m } }
mail_templates:
confirmed_commitments: "Domain:{{ .DomainName }} Project:{{ .ProjectName }}{{ range .Commitments }} Creator:{{ .Commitment.CreatorName }} Amount:{{ .Commitment.Amount }} Duration:{{ .Commitment.Duration }} Date:{{ .Date }} Service:{{ .Resource.ServiceType }} Resource:{{ .Resource.ResourceName }} AZ:{{ .Resource.AvailabilityZone }}{{ end }}"
confirmed_commitments:
subject: "Your recent commitment confirmations"
body: "Domain:{{ .DomainName }} Project:{{ .ProjectName }}{{ range .Commitments }} Creator:{{ .Commitment.CreatorName }} Amount:{{ .Commitment.Amount }} Duration:{{ .Commitment.Duration }} Date:{{ .DateString }} Service:{{ .Resource.ServiceType }} Resource:{{ .Resource.ResourceName }} AZ:{{ .Resource.AvailabilityZone }}{{ end }}"
`
)

Expand Down
19 changes: 10 additions & 9 deletions internal/collector/expiring_commitments.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import (
"github.com/sapcc/go-bits/jobloop"
"github.com/sapcc/go-bits/sqlext"

"github.com/sapcc/limes/internal/datamodel"
"github.com/sapcc/limes/internal/core"
"github.com/sapcc/limes/internal/db"
)

Expand All @@ -55,7 +55,7 @@ func (c *Collector) ExpiringCommitmentNotificationJob(registerer prometheus.Regi
}

type ExpiringCommitments struct {
Notifications map[db.ProjectID][]datamodel.CommitmentInfo
Notifications map[db.ProjectID][]core.CommitmentNotification
NextSubmission time.Time
ShortTermCommitments []db.ProjectCommitmentID // to be excluded from mail notifications.
}
Expand All @@ -79,14 +79,14 @@ func (c *Collector) discoverExpiringCommitments(_ context.Context, _ prometheus.
now := c.MeasureTime()
cutoff := now.Add(expiringCommitmentsNoticePeriod)
commitments := ExpiringCommitments{
Notifications: make(map[db.ProjectID][]datamodel.CommitmentInfo),
Notifications: make(map[db.ProjectID][]core.CommitmentNotification),
NextSubmission: now.Add(c.AddJitter(nextSumbissionInteval)),
}

var shortTermCommitments []db.ProjectCommitmentID
err := sqlext.ForeachRow(c.DB, findExpiringCommitmentsQuery, []any{cutoff}, func(rows *sql.Rows) error {
var pid db.ProjectID
var info datamodel.CommitmentInfo
var info core.CommitmentNotification
err := rows.Scan(
&pid,
&info.Resource.ServiceType, &info.Resource.ResourceName, &info.Resource.AvailabilityZone,
Expand All @@ -95,7 +95,7 @@ func (c *Collector) discoverExpiringCommitments(_ context.Context, _ prometheus.
if err != nil {
return err
}
info.Date = info.Commitment.ExpiresAt.Format(time.DateOnly)
info.DateString = info.Commitment.ExpiresAt.Format(time.DateOnly)
if info.Commitment.Duration.AddTo(now).Before(cutoff) {
shortTermCommitments = append(shortTermCommitments, info.Commitment.ID)
} else {
Expand All @@ -113,6 +113,7 @@ func (c *Collector) discoverExpiringCommitments(_ context.Context, _ prometheus.
}

func (c *Collector) processExpiringCommitmentTask(ctx context.Context, task ExpiringCommitments, _ prometheus.Labels) error {
template := c.Cluster.Config.MailTemplates.ExpiringCommitments
tx, err := c.DB.Begin()
if err != nil {
return err
Expand All @@ -131,14 +132,14 @@ func (c *Collector) processExpiringCommitmentTask(ctx context.Context, task Expi
projectIDs := slices.Collect(maps.Keys(task.Notifications))
sort.Slice(projectIDs, func(i, j int) bool { return projectIDs[i] < projectIDs[j] })
for _, projectID := range projectIDs {
var mailInfo datamodel.MailInfo
var notification core.CommitmentGroupNotification
commitments := task.Notifications[projectID]
err := tx.QueryRow("SELECT d.name, p.name FROM domains d JOIN projects p ON d.id = p.domain_id where p.id = $1", projectID).Scan(&mailInfo.DomainName, &mailInfo.ProjectName)
err := tx.QueryRow("SELECT d.name, p.name FROM domains d JOIN projects p ON d.id = p.domain_id where p.id = $1", projectID).Scan(&notification.DomainName, &notification.ProjectName)
if err != nil {
return err
}
mailInfo.Commitments = commitments
mail, err := mailInfo.CreateMailNotification(c.Cluster.MailTemplates.ExpiringCommitments, "Information about expiring commitments", projectID, task.NextSubmission)
notification.Commitments = commitments
mail, err := template.Render(notification, projectID, task.NextSubmission)
if err != nil {
return err
}
Expand Down
12 changes: 7 additions & 5 deletions internal/collector/expiring_commitments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ const (
capacity: 0
resources: []
mail_templates:
expiring_commitments: "Domain:{{ .DomainName }} Project:{{ .ProjectName }}{{ range .Commitments }} Creator:{{ .Commitment.CreatorName }} Amount:{{ .Commitment.Amount }} Duration:{{ .Commitment.Duration }} Date:{{ .Date }} Service:{{ .Resource.ServiceType }} Resource:{{ .Resource.ResourceName }} AZ:{{ .Resource.AvailabilityZone }}{{ end }}"
expiring_commitments:
subject: "Information about expiring commitments"
body: "Domain:{{ .DomainName }} Project:{{ .ProjectName }}{{ range .Commitments }} Creator:{{ .Commitment.CreatorName }} Amount:{{ .Commitment.Amount }} Duration:{{ .Commitment.Duration }} Date:{{ .DateString }} Service:{{ .Resource.ServiceType }} Resource:{{ .Resource.ResourceName }} AZ:{{ .Resource.AvailabilityZone }}{{ end }}"
`
)

Expand All @@ -70,8 +72,8 @@ func Test_ExpiringCommitmentNotification(t *testing.T) {
`, c.MeasureTime().Add(nextSumbissionInteval).Unix())

// mail queue with an empty template should fail
mailTemplates := s.Cluster.MailTemplates
s.Cluster.MailTemplates = core.MailTemplates{ExpiringCommitments: template.New("")}
mailTemplates := s.Cluster.Config.MailTemplates
s.Cluster.Config.MailTemplates = core.MailTemplateConfiguration{ExpiringCommitments: core.MailTemplate{Compiled: template.New("")}}
// commitments that are already sent out for a notification are not visible in the result set anymore - a new one gets created.
_, err := s.DB.Exec("INSERT INTO project_commitments (id, az_resource_id, amount, created_at, creator_uuid, creator_name, duration, expires_at, state) VALUES (99, 1, 10, UNIX(0), 'dummy', 'dummy', '1 year', UNIX(0), 'expired');")
tr.DBChanges().Ignore()
Expand All @@ -80,14 +82,14 @@ func Test_ExpiringCommitmentNotification(t *testing.T) {
if err == nil {
t.Fatal("execution without mail template must fail")
}
s.Cluster.MailTemplates = core.MailTemplates{ExpiringCommitments: nil}
s.Cluster.Config.MailTemplates = core.MailTemplateConfiguration{ExpiringCommitments: core.MailTemplate{Compiled: nil}}
err = (job.ProcessOne(s.Ctx))
if err == nil {
t.Fatal("execution without mail template must fail")
}

// create a notification for the created commitment. Do not send another notification for commitments that are already marked as notified.
s.Cluster.MailTemplates = mailTemplates
s.Cluster.Config.MailTemplates = mailTemplates
mustT(t, job.ProcessOne(s.Ctx))
tr.DBChanges().AssertEqualf(`
UPDATE project_commitments SET notified_for_expiration = TRUE WHERE id = 99 AND transfer_token = NULL;
Expand Down
14 changes: 4 additions & 10 deletions internal/core/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,6 @@ type Cluster struct {
DiscoveryPlugin DiscoveryPlugin
QuotaPlugins map[db.ServiceType]QuotaPlugin
CapacityPlugins map[string]CapacityPlugin
MailTemplates MailTemplates
}

type MailTemplates struct {
ConfirmedCommitments *template.Template
ExpiringCommitments *template.Template
}

// NewCluster creates a new Cluster instance with the given ID and
Expand Down Expand Up @@ -87,16 +81,16 @@ func NewCluster(config ClusterConfiguration) (c *Cluster, errs errext.ErrorSet)
}

// Create mail templates
confirmTPl, err := template.New("confirm").Parse(config.MailTemplates.ConfirmedCommitments)
confirmTPl, err := template.New("confirm").Parse(config.MailTemplates.ConfirmedCommitments.Body)
if err != nil {
errs.Addf("could not parse confirmation mail template: %w", err)
}
c.MailTemplates.ConfirmedCommitments = confirmTPl
expireTpl, err := template.New("expire").Parse(config.MailTemplates.ExpiringCommitments)
c.Config.MailTemplates.ConfirmedCommitments.Compiled = confirmTPl
expireTpl, err := template.New("expire").Parse(config.MailTemplates.ExpiringCommitments.Body)
if err != nil {
errs.Addf("could not parse expiration mail template: %w", err)
}
c.MailTemplates.ExpiringCommitments = expireTpl
c.Config.MailTemplates.ExpiringCommitments.Compiled = expireTpl

return c, errs
}
Expand Down
4 changes: 2 additions & 2 deletions internal/core/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ type AutogrowQuotaDistributionConfiguration struct {
}

type MailTemplateConfiguration struct {
ConfirmedCommitments string `yaml:"confirmed_commitments"`
ExpiringCommitments string `yaml:"expiring_commitments"`
ConfirmedCommitments MailTemplate `yaml:"confirmed_commitments"`
ExpiringCommitments MailTemplate `yaml:"expiring_commitments"`
}

// NewClusterFromYAML reads and validates the configuration in the given YAML document.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/******************************************************************************
*
* Copyright 2025 SAP SE
* Copyright 2023 SAP SE
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -15,7 +15,8 @@
* limitations under the License.
*
******************************************************************************/
package datamodel

package core

import (
"bytes"
Expand All @@ -24,46 +25,68 @@ import (
"text/template"
"time"

"github.com/sapcc/go-api-declarations/limes"
"github.com/sapcc/go-api-declarations/liquid"

"github.com/sapcc/limes/internal/db"
)

type MailInfo struct {
// CommitmentGroupNotification contains data for rendering mails notifying about commitment workflows (confirmation or expiration).
type CommitmentGroupNotification struct {
DomainName string
ProjectName string
Commitments []CommitmentInfo
Commitments []CommitmentNotification
}

// AZResourceLocation is a tuple identifying an AZ resource within a project.
type AZResourceLocation struct {
ServiceType db.ServiceType
ResourceName liquid.ResourceName
AvailabilityZone limes.AvailabilityZone
}

type CommitmentInfo struct {
// CommitmentNotification appears in type CommitmentGroupNotification.
type CommitmentNotification struct {
Commitment db.ProjectCommitment
Date string
DateString string
Resource AZResourceLocation
}

func (m MailInfo) CreateMailNotification(tpl *template.Template, subject string, projectID db.ProjectID, now time.Time) (db.MailNotification, error) {
type MailTemplate struct {
Subject string `yaml:"subject"`
Body string `yaml:"body"`
Compiled *template.Template `yaml:"-"` // filled during Config.Validate()
}

func (t MailTemplate) Render(m CommitmentGroupNotification, projectID db.ProjectID, now time.Time) (db.MailNotification, error) {
if len(m.Commitments) == 0 {
return db.MailNotification{}, fmt.Errorf("mail: no commitments provided for projectID: %v", projectID)
}

body, err := m.getMailContent(tpl)
if t.Subject == "" {
return db.MailNotification{}, fmt.Errorf("mail: subject is empty for projectID: %v", projectID)
}
body, err := t.getMailContent(m)
if err != nil {
return db.MailNotification{}, err
}
if body == "" {
return db.MailNotification{}, fmt.Errorf("mail: body is empty for projectID: %v", projectID)
return db.MailNotification{}, fmt.Errorf("mail: body has no content. Check the mail template. Halted at projectID: %v", projectID)
}

notification := db.MailNotification{
ProjectID: projectID,
Subject: subject,
Subject: t.Subject,
Body: body,
NextSubmissionAt: now,
}

return notification, nil
}

func (m MailInfo) getMailContent(tpl *template.Template) (string, error) {
func (t MailTemplate) getMailContent(m CommitmentGroupNotification) (string, error) {
var ioBuffer bytes.Buffer
tpl := t.Compiled
if tpl == nil {
return "", errors.New("mail: body is empty. Check the accessiblity of the mail template")
}
Expand Down
Loading

0 comments on commit c79203b

Please sign in to comment.