Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/database/projects.go
Simon Chrzanowski 1c706fffd0 Review fixes for the project inventory
- GetProjectsUsingPackage no longer counts each project's components
  per row; the package page only needs names and versions. The ?purl=
  listing leaves component_count out accordingly.
- The delete button sends to DELETE /ui/api/projects/{project}, the same
  handler as the API and still behind the token, so it sits under /ui
  like the cache deletes.
- The project page answers 500 when its components can't be read,
  instead of showing an empty project.
- DELETE of a path ending in /sbom that names no project answers 400
  with a hint, since it's most likely the upload path.
- The upload endpoint lists JSON, XML and plain text (SPDX tag-value)
  as accepted.
- The docs say that versions are compared as the SBOM spells them.
2026-10-27 20:23:14 +01:00

222 lines
7.2 KiB
Go

package database
import (
"database/sql"
"errors"
"fmt"
"time"
)
// Project is a repository that uploaded an SBOM to the project inventory.
type Project struct {
ID int64 `db:"id"`
Name string `db:"name"`
Ref sql.NullString `db:"ref"`
Branch sql.NullString `db:"branch"`
UploadedAt time.Time `db:"uploaded_at"`
// Components is the number of package versions recorded for it.
Components int64 `db:"components"`
}
// ProjectComponent is a package version a project uses. PackagePURL is the
// unversioned package URL, as packages.purl records it.
type ProjectComponent struct {
PackagePURL string `db:"package_purl"`
Ecosystem string `db:"ecosystem"`
Name string `db:"name"`
Version string `db:"version"`
}
// ProjectComponentInfo is a project's component together with the package
// record the proxy holds for it, if any.
type ProjectComponentInfo struct {
ProjectComponent
CachedEcosystem sql.NullString `db:"cached_ecosystem"`
CachedName sql.NullString `db:"cached_name"`
LatestVersion sql.NullString `db:"latest_version"`
}
// ProjectUsage is a project using a version of a package.
type ProjectUsage struct {
Name string `db:"name"`
Ref sql.NullString `db:"ref"`
Branch sql.NullString `db:"branch"`
UploadedAt time.Time `db:"uploaded_at"`
Version string `db:"version"`
}
const projectColumns = `
SELECT pr.id, pr.name, pr.ref, pr.branch, pr.uploaded_at,
(SELECT COUNT(*) FROM project_components pc WHERE pc.project_id = pr.id) AS components`
// SaveProject records the components of a project, replacing everything
// recorded for it before, in one transaction. Readers see either the old or
// the new set, never a mix.
func (db *DB) SaveProject(name, ref, branch string, uploadedAt time.Time, components []ProjectComponent) error {
tx, err := db.Beginx()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// The upsert locks the project row, so concurrent uploads of the same
// project and pruning take turns.
if _, err := tx.Exec(db.Rebind(`
INSERT INTO projects (name, ref, branch, uploaded_at) VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET ref = excluded.ref, branch = excluded.branch, uploaded_at = excluded.uploaded_at
`), name, nullString(ref), nullString(branch), uploadedAt.UTC()); err != nil {
return fmt.Errorf("recording project: %w", err)
}
var id int64
if err := tx.Get(&id, db.Rebind(`SELECT id FROM projects WHERE name = ?`), name); err != nil {
return fmt.Errorf("reading project id: %w", err)
}
if _, err := tx.Exec(db.Rebind(`DELETE FROM project_components WHERE project_id = ?`), id); err != nil {
return fmt.Errorf("removing old components: %w", err)
}
insert, err := tx.Preparex(db.Rebind(`
INSERT INTO project_components (project_id, package_purl, ecosystem, name, version)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(project_id, package_purl, version) DO NOTHING
`))
if err != nil {
return err
}
defer func() { _ = insert.Close() }()
for _, c := range components {
if _, err := insert.Exec(id, c.PackagePURL, c.Ecosystem, c.Name, c.Version); err != nil {
return fmt.Errorf("recording component %s@%s: %w", c.PackagePURL, c.Version, err)
}
}
return tx.Commit()
}
// DeleteProject removes a project and its components. It reports whether the
// project existed.
func (db *DB) DeleteProject(name string) (bool, error) {
tx, err := db.Beginx()
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
var id int64
err = tx.Get(&id, db.Rebind(`DELETE FROM projects WHERE name = ? RETURNING id`), name)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("deleting project: %w", err)
}
if _, err := tx.Exec(db.Rebind(`DELETE FROM project_components WHERE project_id = ?`), id); err != nil {
return false, fmt.Errorf("deleting project components: %w", err)
}
return true, tx.Commit()
}
// PruneProjects removes the projects last uploaded before cutoff, with their
// components, and returns how many it removed. A project uploaded again while
// the prune runs is kept.
func (db *DB) PruneProjects(cutoff time.Time) (int, error) {
var ids []int64
if err := db.Select(&ids, db.Rebind(`SELECT id FROM projects WHERE uploaded_at < ?`), cutoff.UTC()); err != nil {
return 0, fmt.Errorf("listing expired projects: %w", err)
}
pruned := 0
for _, id := range ids {
ok, err := db.pruneProject(id, cutoff)
if err != nil {
return pruned, err
}
if ok {
pruned++
}
}
return pruned, nil
}
func (db *DB) pruneProject(id int64, cutoff time.Time) (bool, error) {
tx, err := db.Beginx()
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
res, err := tx.Exec(db.Rebind(`DELETE FROM projects WHERE id = ? AND uploaded_at < ?`), id, cutoff.UTC())
if err != nil {
return false, fmt.Errorf("pruning project: %w", err)
}
if n, err := res.RowsAffected(); err != nil || n == 0 {
return false, err
}
if _, err := tx.Exec(db.Rebind(`DELETE FROM project_components WHERE project_id = ?`), id); err != nil {
return false, fmt.Errorf("pruning project components: %w", err)
}
return true, tx.Commit()
}
// ListProjects returns all projects ordered by name.
func (db *DB) ListProjects() ([]Project, error) {
projects := []Project{}
if err := db.Select(&projects, projectColumns+` FROM projects pr ORDER BY pr.name`); err != nil {
return nil, err
}
return projects, nil
}
// GetProject returns the named project, or nil if there is none.
func (db *DB) GetProject(name string) (*Project, error) {
var p Project
err := db.Get(&p, db.Rebind(projectColumns+` FROM projects pr WHERE pr.name = ?`), name)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
// GetProjectComponents returns the components of a project, ordered by
// ecosystem, name and version, with the package record the proxy holds for
// each.
func (db *DB) GetProjectComponents(projectID int64) ([]ProjectComponentInfo, error) {
components := []ProjectComponentInfo{}
err := db.Select(&components, db.Rebind(`
SELECT c.package_purl, c.ecosystem, c.name, c.version,
p.ecosystem AS cached_ecosystem, p.name AS cached_name, p.latest_version
FROM project_components c
LEFT JOIN packages p ON p.purl = c.package_purl
WHERE c.project_id = ?
ORDER BY c.ecosystem, c.name, c.version
`), projectID)
if err != nil {
return nil, err
}
return components, nil
}
// GetProjectsUsingPackage returns the projects using a package, or only
// those using version when it is not empty, ordered by project and version.
func (db *DB) GetProjectsUsingPackage(packagePURL, version string) ([]ProjectUsage, error) {
query := `
SELECT pr.name, pr.ref, pr.branch, pr.uploaded_at, c.version
FROM project_components c
JOIN projects pr ON pr.id = c.project_id
WHERE c.package_purl = ?`
args := []any{packagePURL}
if version != "" {
query += ` AND c.version = ?`
args = append(args, version)
}
usages := []ProjectUsage{}
if err := db.Select(&usages, db.Rebind(query+` ORDER BY pr.name, c.version`), args...); err != nil {
return nil, err
}
return usages, nil
}
func nullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: s != ""}
}