mirror of
https://github.com/peridotbuild/mothership.git
synced 2024-12-03 10:26:25 +00:00
Initial commit
This commit is contained in:
commit
2e94e2dff1
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
/.venv
|
22
README.md
Normal file
22
README.md
Normal file
@ -0,0 +1,22 @@
|
||||
# mothership
|
||||
Tool to archive RPM packages and attest to their authenticity
|
||||
|
||||
# Introduction
|
||||
This tool is designed to be used by the Rocky Linux project to archive RPM packages and attest to their authenticity.
|
||||
|
||||
The sources are first staged, then the fully debranded sources are pushed (Optional).
|
||||
|
||||
The import/debrand process is connected to Peridot, while the attestation and archival is fully done by this tool.
|
||||
|
||||
# Configuration
|
||||
```
|
||||
port: 8080
|
||||
rekor_endpoint: http://rekor:8090
|
||||
git_endpoint: ssh://git@git.rockylinux.org:22220/srpm-attest-test
|
||||
git_ssh_key_path: PATH_TO_SSH_KEY
|
||||
peridot:
|
||||
endpoint: https://peridot-api.build.resf.org
|
||||
client_id: CLIENT_ID
|
||||
client_secret: CLIENT_SECRET
|
||||
project_id: PROJECT_ID
|
||||
```
|
110
alembic.ini
Normal file
110
alembic.ini
Normal file
@ -0,0 +1,110 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = postgresql://postgres:postgres@localhost/mothership
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
1
alembic/README
Normal file
1
alembic/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
78
alembic/env.py
Normal file
78
alembic/env.py
Normal file
@ -0,0 +1,78 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = None
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
alembic/script.py.mako
Normal file
24
alembic/script.py.mako
Normal file
@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
33
alembic/versions/9fbf9dd04e30_create_entries_table.py
Normal file
33
alembic/versions/9fbf9dd04e30_create_entries_table.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""create entries table
|
||||
|
||||
Revision ID: 9fbf9dd04e30
|
||||
Revises:
|
||||
Create Date: 2023-06-26 05:24:50.925093
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "9fbf9dd04e30"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"entries",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("entry_uuid", sa.String(), nullable=False),
|
||||
sa.Column("package_name", sa.String(), nullable=False),
|
||||
sa.Column("package_version", sa.String(), nullable=False),
|
||||
sa.Column("package_release", sa.String(), nullable=False),
|
||||
sa.Column("package_epoch", sa.String(), nullable=False),
|
||||
sa.Column("os_release", sa.String(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("entries")
|
29
data/rh_public_key.asc
Normal file
29
data/rh_public_key.asc
Normal file
@ -0,0 +1,29 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: GnuPG v1.4.5 (GNU/Linux)
|
||||
|
||||
mQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF
|
||||
0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF
|
||||
0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c
|
||||
u7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh
|
||||
XGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H
|
||||
5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW
|
||||
9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj
|
||||
/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1
|
||||
PcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY
|
||||
HVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF
|
||||
buhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB
|
||||
tDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0
|
||||
LmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK
|
||||
CRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC
|
||||
2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf
|
||||
C/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5
|
||||
un3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E
|
||||
0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE
|
||||
IGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh
|
||||
8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL
|
||||
Ght5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki
|
||||
JUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25
|
||||
OFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq
|
||||
dzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==
|
||||
=zbHE
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
7
mothership/db/__init__.py
Normal file
7
mothership/db/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
|
||||
def new_engine() -> AsyncEngine:
|
||||
return create_async_engine(
|
||||
"postgresql+asyncpg://postgres:postgres@localhost:5432/mothership"
|
||||
)
|
10
mothership/models/__init__.py
Normal file
10
mothership/models/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
import pydantic
|
||||
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
|
||||
|
||||
|
||||
class Base(
|
||||
MappedAsDataclass,
|
||||
DeclarativeBase,
|
||||
dataclass_callable=pydantic.dataclasses.dataclass,
|
||||
):
|
||||
pass
|
20
mothership/models/entry.py
Normal file
20
mothership/models/entry.py
Normal file
@ -0,0 +1,20 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from mothership.models import Base
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
|
||||
id: Mapped[Optional[int]] = mapped_column(primary_key=True)
|
||||
entry_uuid: Mapped[str] = mapped_column(nullable=False)
|
||||
package_name: Mapped[str] = mapped_column(nullable=False)
|
||||
package_version: Mapped[str] = mapped_column(nullable=False)
|
||||
package_release: Mapped[str] = mapped_column(nullable=False)
|
||||
package_epoch: Mapped[str] = mapped_column(nullable=False)
|
||||
os_release: Mapped[str] = mapped_column(nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Entry(id={self.id}, entry_uuid={self.entry_uuid}, package_name={self.package_name}, package_version={self.package_version}, package_release={self.package_release}, package_epoch={self.package_epoch})"
|
81
mothership_coordinator/route_entries.py
Normal file
81
mothership_coordinator/route_entries.py
Normal file
@ -0,0 +1,81 @@
|
||||
from typing import TypeVar, Generic
|
||||
from dataclasses import asdict
|
||||
from json import loads
|
||||
from base64 import b64decode
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi_pagination import Page, Params
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import rekor_sdk
|
||||
|
||||
from mothership.models.entry import Entry
|
||||
|
||||
router = APIRouter(prefix="/entries")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Pagination(Page[T], Generic[T]):
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
fields = {"items": {"alias": "entries"}}
|
||||
|
||||
|
||||
class DetailedEntry(BaseModel):
|
||||
entry: Entry
|
||||
rekor_entry: dict
|
||||
|
||||
|
||||
def paginate_entries(session, params):
|
||||
return paginate(session.query(Entry), params=params)
|
||||
|
||||
|
||||
# This method has a lot of hacky stuff, maybe SQLAlchemy was a mistake.
|
||||
# I miss Tortoise
|
||||
@router.get("/", response_model=Pagination[Entry], response_model_exclude_none=True)
|
||||
async def get_entries(req: Request, params: Params = Depends()):
|
||||
async with AsyncSession(req.app.state.db) as session:
|
||||
page = await session.run_sync(paginate_entries, params=params)
|
||||
# Convert items to dict
|
||||
page.items = [asdict(item) for item in page.items]
|
||||
# Delete ID field
|
||||
for item in page.items:
|
||||
del item["id"]
|
||||
return JSONResponse(content=page.dict())
|
||||
|
||||
|
||||
@router.get("/{entry_id}", response_class=DetailedEntry)
|
||||
async def get_entry(req: Request, entry_id: str):
|
||||
async with AsyncSession(req.app.state.db) as session:
|
||||
result = await session.execute(
|
||||
select(Entry).where(Entry.entry_uuid == entry_id)
|
||||
)
|
||||
entry = result.scalars().first()
|
||||
|
||||
# Fetch entry from Rekor
|
||||
try:
|
||||
res = req.app.state.entries_api.get_log_entry_by_uuid(entry_id)
|
||||
|
||||
# Get first value
|
||||
val = list(res.values())[0]
|
||||
|
||||
# Get base64 encoded RPM body
|
||||
body = loads(b64decode(val.get("body")).decode())
|
||||
|
||||
entry_dict = asdict(entry)
|
||||
del entry_dict["id"]
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"entry": entry_dict,
|
||||
"rekor_entry": body,
|
||||
},
|
||||
)
|
||||
except rekor_sdk.rest.ApiException as exc:
|
||||
err = loads(exc.body.decode())
|
||||
raise HTTPException(status_code=400, detail=err.get("message")) from exc
|
68
mothership_coordinator/route_upload_srpm.py
Normal file
68
mothership_coordinator/route_upload_srpm.py
Normal file
@ -0,0 +1,68 @@
|
||||
from typing import Annotated
|
||||
from base64 import b64encode, b64decode
|
||||
from json import loads
|
||||
|
||||
import rekor_sdk
|
||||
|
||||
from fastapi import APIRouter, Form, File, Request, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from mothership.models.entry import Entry
|
||||
|
||||
router = APIRouter(prefix="/upload_srpm")
|
||||
|
||||
|
||||
@router.post("/", response_model=Entry)
|
||||
async def upload_srpm(
|
||||
file: Annotated[bytes, File()],
|
||||
os_release: Annotated[str, Form()],
|
||||
req: Request,
|
||||
) -> Entry:
|
||||
entry = {
|
||||
"kind": "rpm",
|
||||
"apiVersion": "0.0.1",
|
||||
"spec": {
|
||||
"package": {"content": b64encode(file).decode()},
|
||||
"publicKey": {"content": req.app.state.public_key},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
res: rekor_sdk.LogEntry = req.app.state.entries_api.create_log_entry(entry)
|
||||
except rekor_sdk.rest.ApiException as exc:
|
||||
err = loads(exc.body.decode())
|
||||
raise HTTPException(status_code=400, detail=err["message"])
|
||||
|
||||
# Entry uuid is the key
|
||||
entry_uuid: str = list(res.keys())[0]
|
||||
|
||||
# Res should have one value
|
||||
val: dict = list(res.values())[0]
|
||||
|
||||
# Get base64 encoded RPM body
|
||||
body = loads(b64decode(val.get("body")).decode())
|
||||
|
||||
# From body get the headers (spec.package.headers)
|
||||
headers = body.get("spec").get("package").get("headers")
|
||||
|
||||
# Get the name, version, release, and epoch from the headers
|
||||
name = headers.get("Name")
|
||||
version = headers.get("Version")
|
||||
release = headers.get("Release")
|
||||
epoch = headers.get("Epoch")
|
||||
|
||||
entry_db = Entry(
|
||||
id=None,
|
||||
entry_uuid=entry_uuid,
|
||||
package_name=name,
|
||||
package_version=version,
|
||||
package_release=release,
|
||||
package_epoch=epoch,
|
||||
os_release=os_release,
|
||||
)
|
||||
|
||||
async with AsyncSession(req.app.state.db, expire_on_commit=False) as session:
|
||||
session.add(entry_db)
|
||||
await session.commit()
|
||||
|
||||
return entry_db
|
35
mothership_coordinator/server.py
Normal file
35
mothership_coordinator/server.py
Normal file
@ -0,0 +1,35 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from base64 import b64encode
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi_pagination import add_pagination
|
||||
|
||||
import rekor_sdk
|
||||
|
||||
from mothership.db import new_engine
|
||||
from mothership_coordinator.route_upload_srpm import router as upload_srpm_router
|
||||
from mothership_coordinator.route_entries import router as entries_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
rekor_conf = rekor_sdk.Configuration()
|
||||
rekor_conf.host = "http://localhost:3000"
|
||||
entries_api = rekor_sdk.EntriesApi(rekor_sdk.ApiClient(rekor_conf))
|
||||
app.state.entries_api = entries_api
|
||||
|
||||
with open("data/rh_public_key.asc", "rb") as f:
|
||||
app.state.public_key = b64encode(f.read()).decode()
|
||||
|
||||
engine = new_engine()
|
||||
app.state.db = engine
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
app.include_router(upload_srpm_router)
|
||||
app.include_router(entries_router)
|
||||
|
||||
add_pagination(app)
|
80
mothership_ui/server.py
Normal file
80
mothership_ui/server.py
Normal file
@ -0,0 +1,80 @@
|
||||
from typing import List
|
||||
from json import dumps
|
||||
|
||||
import aiohttp
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
import pv2_ui_base
|
||||
from mothership.models.entry import Entry
|
||||
from mothership_coordinator.route_entries import DetailedEntry
|
||||
|
||||
from mothership_ui.utils import templates
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
css_response = FileResponse(pv2_ui_base.get_css_min_path())
|
||||
|
||||
|
||||
@app.get("/_/healthz")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/pv2-ui/pv2.min.css", response_class=FileResponse)
|
||||
def get_css():
|
||||
return css_response
|
||||
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
def get_favicon():
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get("http://127.0.0.1:8000/entries/") as response:
|
||||
body = await response.json()
|
||||
entry_list: List[Entry] = []
|
||||
for item in body.get("items"):
|
||||
entry_list.append(Entry(id=None, **item))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"index.jinja",
|
||||
{
|
||||
"request": request,
|
||||
"entries": entry_list,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/{entry_id}", response_class=HTMLResponse)
|
||||
async def index(request: Request, entry_id: str):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"http://127.0.0.1:8000/entries/{entry_id}") as response:
|
||||
if response.status != 200:
|
||||
return templates.TemplateResponse(
|
||||
"error.jinja",
|
||||
{
|
||||
"request": request,
|
||||
"status_code": response.status,
|
||||
"reason": response.reason,
|
||||
},
|
||||
)
|
||||
|
||||
body = await response.json()
|
||||
body["entry"]["id"] = None
|
||||
detailed_entry = DetailedEntry(**body)
|
||||
detailed_dict = detailed_entry.dict()
|
||||
del detailed_dict["rekor_entry"]["spec"]["publicKey"]
|
||||
rekor_entry = dumps(detailed_dict.get("rekor_entry"), indent=4)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"details.jinja",
|
||||
{
|
||||
"request": request,
|
||||
"entry": detailed_entry,
|
||||
"rekor_entry": rekor_entry,
|
||||
},
|
||||
)
|
50
mothership_ui/templates/details.jinja
Normal file
50
mothership_ui/templates/details.jinja
Normal file
@ -0,0 +1,50 @@
|
||||
{% extends "layout.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pv2-reverse-content">
|
||||
<div className="flex h-full w-full">
|
||||
<div class="h-full fixed w-64 bg-green-50 border-r border-black border-opacity-10 px-4 py-2 text-sm">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<p>
|
||||
{{ entry.entry.package_name }}-{{ entry.entry.package_version }}-{{ entry.entry.package_release
|
||||
}}.src.rpm
|
||||
</p>
|
||||
<div class="flex justify-between">
|
||||
<p class="font-bold">Name:</p>
|
||||
<p>
|
||||
{{ entry.entry.package_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<p class="font-bold">Package version:</p>
|
||||
<p>
|
||||
{{ entry.entry.package_version }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<p class="font-bold">Package release:</p>
|
||||
<p>
|
||||
{{ entry.entry.package_release }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<p class="font-bold">Package epoch:</p>
|
||||
<p>
|
||||
{{ entry.entry.package_epoch }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<p class="text-xs">
|
||||
{{ entry.entry.os_release }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full w-full pl-64">
|
||||
<div class="p-4">
|
||||
<pre>{{ rekor_entry }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
6
mothership_ui/templates/error.jinja
Normal file
6
mothership_ui/templates/error.jinja
Normal file
@ -0,0 +1,6 @@
|
||||
{% extends "layout.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{{ status_code }}</h2>
|
||||
<h4>{{ reason }}</h4>
|
||||
{% endblock %}
|
22
mothership_ui/templates/index.jinja
Normal file
22
mothership_ui/templates/index.jinja
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "layout.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="border-b bg-white text-blue-600 flex py-2 px-4 font-bold text-sm">
|
||||
<span class="w-2/6">Supply Chain Entry ID</span>
|
||||
<span class="w-1/6">Package name</span>
|
||||
<span class="w-1/6">Package version</span>
|
||||
<span class="w-1/6">Package release</span>
|
||||
<span class="w-1/6">OS Release</span>
|
||||
</div>
|
||||
{% for entry in entries %}
|
||||
<div class="text-sm py-2 px-4 border-b bg-white">
|
||||
<div class="flex justify-between">
|
||||
<span class="w-2/6 truncate text-xs"><a href="/{{ entry.entry_uuid }}">{{ entry.entry_uuid }}</a></span>
|
||||
<span class="w-1/6 truncate">{{ entry.package_name }}</span>
|
||||
<span class="w-1/6 truncate">{{ entry.package_version }}</span>
|
||||
<span class="w-1/6 truncate">{{ entry.package_release }}</span>
|
||||
<span class="w-1/6 truncate">{{ entry.os_release }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
33
mothership_ui/templates/layout.jinja
Normal file
33
mothership_ui/templates/layout.jinja
Normal file
@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>{% block title %}Peridot Mothership{% endblock %}</title>
|
||||
|
||||
<link rel="stylesheet" href="/pv2-ui/pv2.min.css">
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body class="font-sans antialiased bg-slate-100 min-h-screen w-screen h-full">
|
||||
<div class="pv2-top-bar">
|
||||
<div id="logo">
|
||||
<a href="/">Peridot Mothership</a>
|
||||
</div>
|
||||
<div id="search">
|
||||
<form action="/search">
|
||||
<input type="text" name="q" placeholder="Search..." />
|
||||
</form>
|
||||
</div>
|
||||
<div id="links"></div>
|
||||
</div>
|
||||
<div class="pv2-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
3
mothership_ui/utils.py
Normal file
3
mothership_ui/utils.py
Normal file
@ -0,0 +1,3 @@
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="mothership_ui/templates")
|
0
mothership_worker/.keep
Normal file
0
mothership_worker/.keep
Normal file
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@ -0,0 +1,11 @@
|
||||
fastapi==0.98.0
|
||||
fastapi-pagination==0.12.4
|
||||
python-multipart==0.0.6
|
||||
uvicorn[standard]==0.22.0
|
||||
Jinja2==3.1.2
|
||||
psycopg2==2.9.6
|
||||
asyncpg==0.27.0
|
||||
sqlalchemy[asyncio]==2.0.17
|
||||
alembic==1.11.1
|
||||
aiohttp==3.8.4
|
||||
rekor-python-sdk @ git+https://github.com/peridotbuild/rekor-python-sdk.git@main
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Loading…
Reference in New Issue
Block a user