Skip to content

Add bundle lifecycle status tracking (build, push)#315

Merged
ashutosh-narkar merged 1 commit intoopen-policy-agent:mainfrom
ashutosh-narkar:add-bundle-state
Apr 7, 2026
Merged

Add bundle lifecycle status tracking (build, push)#315
ashutosh-narkar merged 1 commit intoopen-policy-agent:mainfrom
ashutosh-narkar:add-bundle-state

Conversation

@ashutosh-narkar
Copy link
Copy Markdown
Member

@ashutosh-narkar ashutosh-narkar commented Apr 1, 2026

This change adds support for tracking the status of a bundle
through the build and push phases. The sync phase
is not tracked as we need a bundle revision to keep
track of a tenant+bundle+revision combo.

@ashutosh-narkar ashutosh-narkar requested a review from srenatus April 2, 2026 00:33
Copy link
Copy Markdown
Contributor

@srenatus srenatus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of early comments! 👍

return svc.Run(ctx)
})

time.Sleep(time.Millisecond * 500)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please use testing/synctest? It should let us run the same test without an actual sleep. (Same as other test.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd appreciate some help. I'm getting the error panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked] when I use synctest.Test and synctest.Wait. I'm probably doing something wrong here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, let's leave it as-is then. We can clean this up later.

WithLogger(log)

var g errgroup.Group
ctx, cancel := context.WithCancel(context.Background())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Let's be consistent wth ctx := t.Context(). Also we can then use this below with GetLatestBundleStatus.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

return worker
}

func (worker *BundleWorker) WithPrincipal(principal string) *BundleWorker {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the bundle worker need a principal..? 🤔 I think it would make sense if UpsertBundleStatus didn't require authz. It's not exposed through any http handler.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Removed authz for UpsertBundleStatus

// For sqlite, postgres, and cockroach, it behaves identically to upsertReturning with returning=true.
func (d *Database) upsertReturningLastInsertID(ctx context.Context, tx *sql.Tx, tenant, table string, columns []string, primaryKey []string, values ...any) (int64, error) {
valueArgs := d.args(len(columns))
if tenant != "" { // not a relation-only table
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is taken from the generic upsert helper, I think? We don't need to deal with it like that here, I suppose.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed upsertReturningLastInsertID and rolled this into upsertReturning

Comment on lines +2030 to +2031
// For MySQL, it adds `id = LAST_INSERT_ID(id)` to the ON DUPLICATE KEY UPDATE clause so that
// LastInsertId() returns the correct ID for both inserts and updates.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it perhaps make sense to adjust upsertReturning? Sounds like this twist would be applicable across the tables.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed upsertReturningLastInsertID and rolled this into upsertReturning

@ashutosh-narkar ashutosh-narkar marked this pull request as ready for review April 2, 2026 19:42
Copy link
Copy Markdown
Contributor

@srenatus srenatus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had a quick look, it looks good, some nitpicks only!

"secrets.view",
"tokens.view",
"sources.data.read",
"bundles_statuses.view",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that we have sources.data.read, wouldn't bundles.status.view fit in slightly better?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to bundles.status.view

}

func (d *Database) upsertReturning(ctx context.Context, returning bool, tx *sql.Tx, tenant, table string, columns []string, primaryKey []string, values ...any) (int, error) {
func (d *Database) upsertReturning(ctx context.Context, returning, returningLastInsertID bool, tx *sql.Tx, tenant, table string, columns []string, primaryKey []string, values ...any) (int, error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm! I'm surprised there's a need to differentiate "returning" and "returningLastInsertID". I took the former to mean the same when it was introduced. When do we only want one of them?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC the special handling is only needed for MySQL. In our case, we want the ID for both inserts and updates. Since the other databases support the RETURNING clause, they return the correct ID. MySQL will return 0 or something for the update case. Hence this is a workaround to get the correct ID on update.

return b
}

func (b *Builder) Revision() string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this always safe to call? I think b.revision might only get populated at bundle build time...? 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're only tracking build and upload state as we need the revision to be non-empty.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't track sync state due to this.

assert.Empty(t, resp.Result)
})

t.Run("unauthorized", func(t *testing.T) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check some cross-tenant request, perhaps?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test case for this.

@ashutosh-narkar ashutosh-narkar changed the title Add bundle lifecycle status tracking (sync, build, push) Add bundle lifecycle status tracking (build, push) Apr 2, 2026
Copy link
Copy Markdown
Contributor

@johanfylling johanfylling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

setup("PUT", "/v1/bundles/{bundle}", s.v1BundlesPut)
setup("DELETE", "/v1/bundles/{bundle}", s.v1BundlesDelete)
setup("GET", "/v1/bundles/{bundle}/status/latest", s.v1BundleStatusLatestGet)
setup("GET", "/v1/bundles/{bundle}/status", s.v1BundleStatusList)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will there be a follow up docs update for these new endpoints, or is that still too early?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we merge this, I will create a PR to update the docs.

}

const ownerKey = "test-owner-key"
if err := db.UpsertToken(ctx, "internal", "default", &config.Token{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] to have less "magic" strings to track down

Suggested change
if err := db.UpsertToken(ctx, "internal", "default", &config.Token{
if err := db.UpsertToken(ctx, principal.Id, principal.Tenant, &config.Token{

Same in other places.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.


if _, err := db.UpsertBundleStatus(ctx, "default", "bundle1", "rev1", "build", "in_progress", ""); err != nil {
t.Fatal(err)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there missing assertions following this status update, or this is here to show that we only get the immediately following status update when querying? Maybe there is value in asserting both statuses(?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are testing that we get the last inserted status for a tenant+bundle combo. So we expect to get only one status which we assert later in the test.

setup("PUT", "/v1/bundles/{bundle}", s.v1BundlesPut)
setup("DELETE", "/v1/bundles/{bundle}", s.v1BundlesDelete)
setup("GET", "/v1/bundles/{bundle}/status/latest", s.v1BundleStatusLatestGet)
setup("GET", "/v1/bundles/{bundle}/status", s.v1BundleStatusList)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that we're returning a list of statuses I suppose this could also be .../statuses if we want to be sticklers for naming consistency; but I'm guessing we're seeing this as a general "status" endpoint for the bundle, and the actual return type is irrelevant(?)
(also, status kinda reads better 😄)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, status kinda reads better

👍

return
}

resp := types.BundleStatusGetResponseV1{Result: status}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there are no status records, we're gonna return an empty dictionary ({})? Maybe we should add a test for this to not have future regressions on this behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scratch that, I was misled by the func doc 😄. But we should still assert this behavior in testing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This behavior is consistent with v1BundlesGet. We have a test for this in internal/database/bundle_status_test.go (see TestGetLatestBundleStatus)

if err == nil {
_, err = tx.ExecContext(ctx,
fmt.Sprintf(
`DELETE FROM bundles_statuses WHERE bundle_id = %s AND id < %s`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forget, are bundle ID:s unique server-wide, across tenants? If not, might we be deleting records for another tenant with here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so. This is the same pattern used for deleting other resources.

if b.Revision() != "" {
_, err := w.database.UpsertBundleStatus(ctx, w.tenant, w.bundleConfig.Name, b.Revision(), BuildPhaseBuild.String(), BuildStateBuildFailed.String(), err.Error())
if err != nil {
w.log.Warnf("failed to track bundle build state %q: %v", w.bundleConfig.Name, err)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so we don't abort on status error 👍.

w.log.Warnf("failed to upload bundle %q: %v", w.bundleConfig.Name, err)

if b.Revision() != "" {
_, err := w.database.UpsertBundleStatus(ctx, w.tenant, w.bundleConfig.Name, b.Revision(), BuildPhasePush.String(), BuildStatePushFailed.String(), err.Error())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only need to report on the ultimate success/failure of each phase, not that they're in progress?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only success/failure atm

}
}

if w.storage != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's up to the client to know that storage has been configured, and therefore when to expect a PUSH phase?
Would there be value in tracking each phase separately, or that would just be unwarranted complexity?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's up to the client to know that storage has been configured, and therefore when to expect a PUSH phase?

Yes. So clients can plugin their own storage implementation.

Would there be value in tracking each phase separately, or that would just be unwarranted complexity?

We currently track build and push phases. One phase for a tenant+bundle+revision combo.

t.Fatalf("expected bundle status %v but got %v", service.BuildStatePushFailed.String(), status.Status)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also add a test case for when there is no storage, and we only expect BUILD-phase status?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service will create a storage if the user does not provide one.

This change adds support for tracking the status of a bundle
through the build and push phases. The sync phase
is not tracked as we need a bundle revision to keep
track of a tenant+bundle+revision combo.

Signed-off-by: Ashutosh Narkar <anarkar4387@gmail.com>
@ashutosh-narkar ashutosh-narkar merged commit 7e550cf into open-policy-agent:main Apr 7, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants