Initial release: self-hostable APT repository server and CLI
urapt is a self-hostable APT repository server with a companion CLI for pushing and managing Debian .deb packages. Server (urapt-server): - REST API + APT endpoint, SQLite storage (pure-Go modernc driver, no CGO) - .deb files stored content-addressed on disk, reference-counted for dedup - Server-managed RSA-4096 OpenPGP signing key (ProtonMail/go-crypto) - APT indices (Release/InRelease/Packages[.gz/.xz]) generated on demand from the DB, cached in memory, signed with the server key - Full APT model: repositories -> distributions -> components -> architectures - Bearer-token auth for REST; HTTP Basic auth for private-repo APT reads - First registrant becomes admin; repo-scoped permissions (read/write/read-write/admin) plus owner and server-admin roles - Multipart package push with control-field extraction, list/show/delete, pool serving, blob ref-count cleanup - Audit log CLI (urapt): - register/login/logout/whoami, token management - repo/distro/component/arch CRUD, member management - push/pull/ls/show/rm for packages - apt-config helper that emits apt setup commands (key, sources.list, auth.conf for private repos) Packaging & docs: - Dockerfile (multi-stage distroless), docker-compose.yml, sample config - README quick start, architecture overview, config reference, security notes - PLAN.md design blueprint, CHANGELOG.md, GPL-3.0 LICENSE - GitHub Actions CI (test, lint, cross-build for linux/darwin amd64/arm64) - Makefile release target producing static binaries + tarballs + checksums Tests cover the data-access layer, auth/permission checks, APT index generation, .deb parsing, GPG signing, the REST API, and the typed API client. Verified end-to-end on a Raspberry Pi (arm64) pushing and installing a real package.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"urapt/shared/models"
|
||||
)
|
||||
|
||||
// --- distributions ---
|
||||
|
||||
// CreateDistribution adds a distribution (suite) to a repository.
|
||||
func (s *Store) CreateDistribution(ctx context.Context, repoID, name string) (*models.Distribution, error) {
|
||||
id := newID()
|
||||
now := s.now()
|
||||
_, err := s.exec(ctx, `INSERT INTO distributions (id, repository_id, name, created_at) VALUES (?, ?, ?, ?)`,
|
||||
id, repoID, name, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert distribution: %w", err)
|
||||
}
|
||||
return &models.Distribution{ID: id, RepositoryID: repoID, Name: name, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// GetDistributionByName returns a distribution by name within a repository.
|
||||
func (s *Store) GetDistributionByName(ctx context.Context, repoID, name string) (*models.Distribution, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, repository_id, name, created_at FROM distributions WHERE repository_id = ? AND name = ?`, repoID, name)
|
||||
d := &models.Distribution{}
|
||||
err := row.Scan(&d.ID, &d.RepositoryID, &d.Name, &d.CreatedAt)
|
||||
if isErrNoRows(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return d, err
|
||||
}
|
||||
|
||||
// ListDistributions returns all distributions in a repository.
|
||||
func (s *Store) ListDistributions(ctx context.Context, repoID string) ([]*models.Distribution, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, repository_id, name, created_at FROM distributions WHERE repository_id = ? ORDER BY name`, repoID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list distributions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.Distribution
|
||||
for rows.Next() {
|
||||
d := &models.Distribution{}
|
||||
if err := rows.Scan(&d.ID, &d.RepositoryID, &d.Name, &d.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteDistribution removes a distribution and cascades to components,
|
||||
// architectures, and packages.
|
||||
func (s *Store) DeleteDistribution(ctx context.Context, repoID, name string) error {
|
||||
res, err := s.exec(ctx, `DELETE FROM distributions WHERE repository_id = ? AND name = ?`, repoID, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete distribution: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- components ---
|
||||
|
||||
// CreateComponent adds a component to a distribution.
|
||||
func (s *Store) CreateComponent(ctx context.Context, distroID, name string) (*models.Component, error) {
|
||||
id := newID()
|
||||
now := s.now()
|
||||
_, err := s.exec(ctx, `INSERT INTO components (id, distribution_id, name, created_at) VALUES (?, ?, ?, ?)`,
|
||||
id, distroID, name, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert component: %w", err)
|
||||
}
|
||||
return &models.Component{ID: id, DistributionID: distroID, Name: name, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// GetComponentByName returns a component by name within a distribution.
|
||||
func (s *Store) GetComponentByName(ctx context.Context, distroID, name string) (*models.Component, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, distribution_id, name, created_at FROM components WHERE distribution_id = ? AND name = ?`, distroID, name)
|
||||
c := &models.Component{}
|
||||
err := row.Scan(&c.ID, &c.DistributionID, &c.Name, &c.CreatedAt)
|
||||
if isErrNoRows(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// ListComponents returns all components in a distribution.
|
||||
func (s *Store) ListComponents(ctx context.Context, distroID string) ([]*models.Component, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, distribution_id, name, created_at FROM components WHERE distribution_id = ? ORDER BY name`, distroID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list components: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.Component
|
||||
for rows.Next() {
|
||||
c := &models.Component{}
|
||||
if err := rows.Scan(&c.ID, &c.DistributionID, &c.Name, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteComponent removes a component. The schema blocks deletion while
|
||||
// packages reference it (ON DELETE RESTRICT); the handler checks first.
|
||||
func (s *Store) DeleteComponent(ctx context.Context, distroID, name string) error {
|
||||
res, err := s.exec(ctx, `DELETE FROM components WHERE distribution_id = ? AND name = ?`, distroID, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete component: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountComponentsByDistro reports how many components a distribution has.
|
||||
func (s *Store) CountComponentsByDistro(ctx context.Context, distroID string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM components WHERE distribution_id = ?`, distroID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// --- architectures ---
|
||||
|
||||
// CreateArchitecture adds an architecture to a distribution.
|
||||
func (s *Store) CreateArchitecture(ctx context.Context, distroID, name string) (*models.Architecture, error) {
|
||||
id := newID()
|
||||
now := s.now()
|
||||
_, err := s.exec(ctx, `INSERT INTO architectures (id, distribution_id, name, created_at) VALUES (?, ?, ?, ?)`,
|
||||
id, distroID, name, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert architecture: %w", err)
|
||||
}
|
||||
return &models.Architecture{ID: id, DistributionID: distroID, Name: name, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// ListArchitectures returns all architectures in a distribution.
|
||||
func (s *Store) ListArchitectures(ctx context.Context, distroID string) ([]*models.Architecture, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, distribution_id, name, created_at FROM architectures WHERE distribution_id = ? ORDER BY name`, distroID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list architectures: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.Architecture
|
||||
for rows.Next() {
|
||||
a := &models.Architecture{}
|
||||
if err := rows.Scan(&a.ID, &a.DistributionID, &a.Name, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteArchitecture removes an architecture from a distribution.
|
||||
func (s *Store) DeleteArchitecture(ctx context.Context, distroID, name string) error {
|
||||
res, err := s.exec(ctx, `DELETE FROM architectures WHERE distribution_id = ? AND name = ?`, distroID, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete architecture: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasArchitecture reports whether a distribution declares the given arch.
|
||||
func (s *Store) HasArchitecture(ctx context.Context, distroID, name string) (bool, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM architectures WHERE distribution_id = ? AND name = ?`, distroID, name).Scan(&n)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
return n > 0, err
|
||||
}
|
||||
Reference in New Issue
Block a user