Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/controlplane/pkg/data/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
config "github.com/chainloop-dev/chainloop/app/controlplane/pkg/conf/controlplane/config/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/organization"
"github.com/chainloop-dev/chainloop/pkg/entdriver"
"github.com/go-kratos/kratos/v2/log"
"github.com/google/uuid"
"github.com/google/wire"
Expand Down Expand Up @@ -76,7 +77,11 @@ type Data struct {
// NOTE: this is different than running migrations
// this method is used to load the schema into the DB for TESTING!
func (data *Data) SchemaLoad() error {
return data.DB.Schema.Create(context.Background())
// ent's Atlas schema code casts the rows handle to *sql.Rows, which the
// stack-recording rows of StackDriver are not, so load with a plain driver.
plain := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, data.SQLDB)))

return plain.Schema.Create(context.Background())
}

// NewData .
Expand Down Expand Up @@ -140,7 +145,7 @@ func initSQLDatabase(c *config.DatabaseConfig, tp trace.TracerProvider, log *log
db.SetConnMaxIdleTime(t)
}

drv := entsql.OpenDB(dialect.Postgres, db)
drv := entdriver.NewStackDriver(entsql.OpenDB(dialect.Postgres, db))
client := ent.NewClient(ent.Driver(drv))

// NOTE: We do not run migrations automatically anymore
Expand Down
17 changes: 13 additions & 4 deletions app/controlplane/pkg/data/ent/ping.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions app/controlplane/pkg/data/ent/ping_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

129 changes: 129 additions & 0 deletions pkg/entdriver/stackdriver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//
// Copyright 2026 The Chainloop 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 entdriver provides ent driver wrappers.
package entdriver

import (
"context"

"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"github.com/chainloop-dev/chainloop/pkg/errorsx"
)

// StackDriver is an ent driver that records the stack of the caller on each
// database error. The stack holds the repository method that ran the query,
// so Sentry groups a database failure by the query that failed.
// Do not run ent's schema API (Schema.Create, Diff, WriteTo) through it: that
// code casts the result rows to *sql.Rows, which stackRows is not.
type StackDriver struct {
// Driver is the ent driver that runs the queries.
*entsql.Driver
}

// NewStackDriver wraps driver so database errors record the caller's stack.
func NewStackDriver(driver *entsql.Driver) *StackDriver {
return &StackDriver{Driver: driver}
}

// Exec runs a statement and records the stack of the caller on error.
func (d *StackDriver) Exec(ctx context.Context, query string, args, v any) error {
return errorsx.WithStack(d.Driver.Exec(ctx, query, args, v))
}

// Query runs a query and records the stack of the caller on error.
func (d *StackDriver) Query(ctx context.Context, query string, args, v any) error {
return queryResult(d.Driver.Query(ctx, query, args, v), v)
}

// Tx starts a transaction whose errors record the stack of the caller.
func (d *StackDriver) Tx(ctx context.Context) (dialect.Tx, error) {
return d.BeginTx(ctx, nil)
}

// BeginTx starts a transaction with options. The generated ent client calls it
// through an interface check, so the wrapper must keep it.
func (d *StackDriver) BeginTx(ctx context.Context, opts *entsql.TxOptions) (dialect.Tx, error) {
tx, err := d.Driver.BeginTx(ctx, opts)
if err != nil {
return nil, errorsx.WithStack(err)
}

return &stackTx{Tx: tx}, nil
}

// stackTx is a transaction that records the stack of the caller on each error.
type stackTx struct {
// Tx is the ent transaction that runs the queries.
dialect.Tx
}

// Exec runs a statement and records the stack of the caller on error.
func (t *stackTx) Exec(ctx context.Context, query string, args, v any) error {
return errorsx.WithStack(t.Tx.Exec(ctx, query, args, v))
}

// Query runs a query and records the stack of the caller on error.
func (t *stackTx) Query(ctx context.Context, query string, args, v any) error {
return queryResult(t.Tx.Query(ctx, query, args, v), v)
}

// Commit commits the transaction and records the stack of the caller on error.
func (t *stackTx) Commit() error {
return errorsx.WithStack(t.Tx.Commit())
}

// Rollback aborts the transaction and records the stack of the caller on error.
func (t *stackTx) Rollback() error {
return errorsx.WithStack(t.Tx.Rollback())
}

// queryResult records the stack of the caller on a query error. On success it
// also wraps the result rows: Query returns once Postgres accepts the query, and
// ent then reads the rows through the rows handle, not through the driver. A
// failure during that read (a dropped connection, a timeout in a large result)
// would otherwise reach Sentry with no stack.
func queryResult(err error, v any) error {
if err != nil {
return errorsx.WithStack(err)
}
if rows, ok := v.(*entsql.Rows); ok {
rows.ColumnScanner = stackRows{ColumnScanner: rows.ColumnScanner}
}

return nil
}

// stackRows records the stack of the caller on each error while it reads the result rows.
type stackRows struct {
// ColumnScanner is the rows handle of the query.
entsql.ColumnScanner
}

// Scan copies the current row and records the stack of the caller on error.
func (r stackRows) Scan(dest ...any) error {
return errorsx.WithStack(r.ColumnScanner.Scan(dest...))
}

// Err returns the error of the row iteration with the stack of the caller.
func (r stackRows) Err() error {
return errorsx.WithStack(r.ColumnScanner.Err())
}

// Close closes the rows and records the stack of the caller on error.
func (r stackRows) Close() error {
return errorsx.WithStack(r.ColumnScanner.Close())
}
123 changes: 123 additions & 0 deletions pkg/entdriver/stackdriver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//
// Copyright 2026 The Chainloop 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 entdriver

import (
"context"
"database/sql"
"errors"
"testing"

"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"github.com/chainloop-dev/chainloop/pkg/errorsx"
"github.com/chainloop-dev/chainloop/pkg/errorsx/errorsxtest"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/stretchr/testify/suite"
)

type StackDriverSuite struct {
suite.Suite
drv *StackDriver
}

func TestStackDriver(t *testing.T) {
suite.Run(t, new(StackDriverSuite))
}

// errDBClosed is the message of the unexported database/sql error that a
// closed database returns, as when the backend shuts down.
const errDBClosed = "sql: database is closed"

// SetupTest opens a closed database, so that every query fails.
func (s *StackDriverSuite) SetupTest() {
db, err := sql.Open("pgx", "postgres://localhost:1/unused")
s.Require().NoError(err)
s.Require().NoError(db.Close())

s.drv = NewStackDriver(entsql.OpenDB(dialect.Postgres, db))
}

func queryAtOrigin(ctx context.Context, drv dialect.ExecQuerier) error {
var rows entsql.Rows
return drv.Query(ctx, "SELECT 1", []any{}, &rows)
}

func execAtOrigin(ctx context.Context, drv dialect.ExecQuerier) error {
return drv.Exec(ctx, "SELECT 1", []any{}, nil)
}

// A database error records the stack of the query that failed, so that Sentry
// groups database failures by query instead of by the code that reports them.
// The message does not change, so logs and error checks do not change.
func (s *StackDriverSuite) TestDriverErrorsRecordCallerStack() {
testCases := []struct {
name string
originFunc string
runAtOrigin func(context.Context, dialect.ExecQuerier) error
}{
{name: "query", originFunc: "queryAtOrigin", runAtOrigin: queryAtOrigin},
{name: "exec", originFunc: "execAtOrigin", runAtOrigin: execAtOrigin},
}

for _, tc := range testCases {
s.Run(tc.name, func() {
err := tc.runAtOrigin(context.Background(), s.drv)
s.Require().EqualError(err, errDBClosed)
s.Contains(errorsxtest.OriginStack(errorsxtest.ErrorExceptions(err)), tc.originFunc)
})
}
}

// failingRows is a rows handle whose reads fail, as when the connection drops mid-result.
type failingRows struct {
entsql.ColumnScanner
err error
}

func (r failingRows) Scan(...any) error { return r.err }
func (r failingRows) Err() error { return r.err }
func (r failingRows) Close() error { return nil }

func scanAtOrigin(rows *entsql.Rows) error { return rows.Scan() }

func iterateAtOrigin(rows *entsql.Rows) error { return rows.Err() }

// A failure while ent reads the result rows from Postgres records the stack of
// the reader, like a failure of the query itself. The message does not change.
func (s *StackDriverSuite) TestRowReadErrorsRecordCallerStack() {
readErr := errors.New("unexpected EOF")
rows := &entsql.Rows{ColumnScanner: failingRows{err: readErr}}
s.Require().NoError(queryResult(nil, rows))

err := scanAtOrigin(rows)
s.Require().ErrorIs(err, readErr)
s.EqualError(err, readErr.Error())
s.Contains(errorsxtest.OriginStack(errorsxtest.ErrorExceptions(err)), "scanAtOrigin")

err = iterateAtOrigin(rows)
s.Require().ErrorIs(err, readErr)
s.Contains(errorsxtest.OriginStack(errorsxtest.ErrorExceptions(err)), "iterateAtOrigin")

s.NoError(rows.Close(), "a successful Close stays nil")
}

// A transaction that cannot start records the stack of its caller too.
func (s *StackDriverSuite) TestTxErrorRecordsStack() {
_, err := s.drv.Tx(context.Background())
s.Require().EqualError(err, errDBClosed)
s.True(errorsx.HasStack(err))
}
Loading
Loading