dv-platform 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +1 -0
- app/config.py +28 -0
- app/db.py +55 -0
- app/engine.py +342 -0
- app/main.py +62 -0
- app/models.py +170 -0
- app/routers/__init__.py +1 -0
- app/routers/audit.py +28 -0
- app/routers/auth.py +81 -0
- app/routers/datasets.py +266 -0
- app/routers/storage_objects.py +96 -0
- app/routers/tokens.py +53 -0
- app/routers/versions.py +192 -0
- app/schemas.py +237 -0
- app/security.py +143 -0
- app/storage.py +217 -0
- dv/__init__.py +2 -0
- dv/cli.py +467 -0
- dv/client.py +63 -0
- dv/config.py +112 -0
- dv/server.py +355 -0
- dv/transfer.py +213 -0
- dv_platform-0.1.0.dist-info/METADATA +327 -0
- dv_platform-0.1.0.dist-info/RECORD +27 -0
- dv_platform-0.1.0.dist-info/WHEEL +5 -0
- dv_platform-0.1.0.dist-info/entry_points.txt +3 -0
- dv_platform-0.1.0.dist-info/top_level.txt +2 -0
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Dataset Version Management Platform - backend package."""
|
app/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Application settings (env-prefixed with DV_)."""
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Settings(BaseSettings):
|
|
6
|
+
model_config = SettingsConfigDict(env_prefix="DV_", env_file=".env", extra="ignore")
|
|
7
|
+
|
|
8
|
+
app_name: str = "Dataset Version Platform"
|
|
9
|
+
database_url: str = "sqlite:///./dv.db"
|
|
10
|
+
storage_backend: str = "local" # local | s3
|
|
11
|
+
storage_root: str = "./storage" # local backend object root
|
|
12
|
+
s3_endpoint: str = "http://localhost:9000"
|
|
13
|
+
s3_bucket: str = "datasets"
|
|
14
|
+
s3_access_key: str = ""
|
|
15
|
+
s3_secret_key: str = ""
|
|
16
|
+
s3_region: str = "us-east-1"
|
|
17
|
+
presign_ttl: int = 900 # seconds (15 min)
|
|
18
|
+
jwt_secret: str = "dev-secret-change-me-0123456789abcdef0123456789"
|
|
19
|
+
jwt_algorithm: str = "HS256"
|
|
20
|
+
jwt_expire_minutes: int = 10080 # 7 days
|
|
21
|
+
public_base_url: str = "http://localhost:8000"
|
|
22
|
+
web_dist: str = "" # optional: absolute path to a built web/dist to serve
|
|
23
|
+
first_admin_password: str = "admin123"
|
|
24
|
+
chunk_size: int = 8 * 1024 * 1024 # 8MB reference chunk for docs
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
settings = Settings()
|
|
28
|
+
|
app/db.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""SQLAlchemy engine / session factory.
|
|
2
|
+
|
|
3
|
+
SQLite notes (development default): we enable WAL journaling plus a busy
|
|
4
|
+
timeout and make every transaction start with ``BEGIN IMMEDIATE``. When many
|
|
5
|
+
clients upload/download concurrently the metadata writes (blob registration,
|
|
6
|
+
commit creation, ...) are short but frequent; ``BEGIN IMMEDIATE`` serialises
|
|
7
|
+
writers up-front so two concurrent requests never read a stale snapshot and
|
|
8
|
+
then fail with "database is locked" mid-transaction.
|
|
9
|
+
"""
|
|
10
|
+
from sqlalchemy import create_engine, event
|
|
11
|
+
from sqlalchemy.orm import declarative_base, sessionmaker
|
|
12
|
+
|
|
13
|
+
from .config import settings
|
|
14
|
+
|
|
15
|
+
connect_args = {}
|
|
16
|
+
if settings.database_url.startswith("sqlite"):
|
|
17
|
+
connect_args = {"check_same_thread": False}
|
|
18
|
+
|
|
19
|
+
engine = create_engine(settings.database_url, connect_args=connect_args, future=True)
|
|
20
|
+
|
|
21
|
+
if settings.database_url.startswith("sqlite") and ":memory:" not in settings.database_url:
|
|
22
|
+
@event.listens_for(engine, "connect")
|
|
23
|
+
def _sqlite_pragmas(dbapi_connection, _connection_record): # pragma: no cover - exercised via app
|
|
24
|
+
# Disable pysqlite's implicit BEGIN so SQLAlchemy controls transactions
|
|
25
|
+
# and we can start them with BEGIN IMMEDIATE (see recipe in SQLAlchemy docs).
|
|
26
|
+
dbapi_connection.isolation_level = None
|
|
27
|
+
cursor = dbapi_connection.cursor()
|
|
28
|
+
try:
|
|
29
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
30
|
+
cursor.execute("PRAGMA busy_timeout=30000")
|
|
31
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
32
|
+
finally:
|
|
33
|
+
cursor.close()
|
|
34
|
+
|
|
35
|
+
@event.listens_for(engine, "begin")
|
|
36
|
+
def _begin_immediate(conn):
|
|
37
|
+
# Take the write lock at transaction start instead of upgrading a
|
|
38
|
+
# read snapshot later (avoids SQLITE_BUSY_SNAPSHOT / lost updates).
|
|
39
|
+
conn.exec_driver_sql("BEGIN IMMEDIATE")
|
|
40
|
+
|
|
41
|
+
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
|
42
|
+
Base = declarative_base()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_db():
|
|
46
|
+
db = SessionLocal()
|
|
47
|
+
try:
|
|
48
|
+
yield db
|
|
49
|
+
finally:
|
|
50
|
+
db.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def init_db() -> None:
|
|
54
|
+
from . import models # noqa: F401 (register models)
|
|
55
|
+
Base.metadata.create_all(bind=engine)
|
app/engine.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Git-like version engine: blob/tree/commit, branches, tags, diff, rollback,
|
|
2
|
+
reference counting and dataset deletion (metadata + physical storage cleanup)."""
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from fastapi import HTTPException
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from .models import (
|
|
12
|
+
AuditLog, Blob, BlobRef, Branch, Commit, Dataset, DatasetAcl, StagedUpload,
|
|
13
|
+
Tag, Tree, User,
|
|
14
|
+
)
|
|
15
|
+
from .storage import get_storage, object_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ------------------------------------------------------------------ hashing
|
|
19
|
+
def canonical_json(obj) -> str:
|
|
20
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sha256_hex(data: bytes) -> str:
|
|
24
|
+
return hashlib.sha256(data).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def hash_tree(entries: dict) -> str:
|
|
28
|
+
return sha256_hex(canonical_json(entries).encode("utf-8"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hash_commit(tree_id: str, parents: list[str], author: str, message: str, ts: str) -> str:
|
|
32
|
+
obj = {"tree": tree_id, "parents": parents, "author": author, "message": message, "ts": ts}
|
|
33
|
+
return sha256_hex(canonical_json(obj).encode("utf-8"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_files(files: Iterable[dict]) -> dict:
|
|
37
|
+
"""Normalize [{path,hash,size}] -> {path: {"type":"blob","hash","size"}} sorted."""
|
|
38
|
+
entries = {}
|
|
39
|
+
for f in files:
|
|
40
|
+
path = f["path"].replace("\\", "/").lstrip("./")
|
|
41
|
+
path = "/".join(p for p in path.split("/") if p not in ("", ".", ".."))
|
|
42
|
+
if not path:
|
|
43
|
+
continue
|
|
44
|
+
entries[path] = {"type": "blob", "hash": f["hash"], "size": int(f.get("size", 0))}
|
|
45
|
+
return dict(sorted(entries.items()))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def entries_from_commit(db: Session, commit: Commit) -> dict:
|
|
49
|
+
tree = db.query(Tree).filter(Tree.content_hash == commit.tree_id).first()
|
|
50
|
+
return dict(tree.payload) if tree else {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------ refs
|
|
54
|
+
def resolve_ref(db: Session, dataset: Dataset, ref: str) -> Commit:
|
|
55
|
+
"""Resolve a ref to a commit: tag / branch / 'HEAD' / commit hash / seq / vN."""
|
|
56
|
+
ref = (ref or "HEAD").strip()
|
|
57
|
+
low = ref.lower()
|
|
58
|
+
# tag
|
|
59
|
+
tag = db.query(Tag).filter(Tag.dataset_id == dataset.id, Tag.name == ref).first()
|
|
60
|
+
if tag:
|
|
61
|
+
return db.query(Commit).filter(Commit.commit_hash == tag.commit_id).first()
|
|
62
|
+
# branch
|
|
63
|
+
if low in ("head", "latest", "main"):
|
|
64
|
+
branch = db.query(Branch).filter(Branch.dataset_id == dataset.id, Branch.name == "main").first()
|
|
65
|
+
if branch:
|
|
66
|
+
return db.query(Commit).filter(Commit.commit_hash == branch.head_commit_id).first()
|
|
67
|
+
branch = db.query(Branch).filter(Branch.dataset_id == dataset.id).first()
|
|
68
|
+
if branch:
|
|
69
|
+
return db.query(Commit).filter(Commit.commit_hash == branch.head_commit_id).first()
|
|
70
|
+
else:
|
|
71
|
+
branch = db.query(Branch).filter(Branch.dataset_id == dataset.id, Branch.name == ref).first()
|
|
72
|
+
if branch:
|
|
73
|
+
return db.query(Commit).filter(Commit.commit_hash == branch.head_commit_id).first()
|
|
74
|
+
# vN / seq number
|
|
75
|
+
if low.startswith("v"):
|
|
76
|
+
num = low[1:]
|
|
77
|
+
if num.isdigit():
|
|
78
|
+
ref = num
|
|
79
|
+
if ref.isdigit():
|
|
80
|
+
c = db.query(Commit).filter(Commit.dataset_id == dataset.id, Commit.seq == int(ref)).first()
|
|
81
|
+
if c:
|
|
82
|
+
return c
|
|
83
|
+
# commit hash (full or prefix)
|
|
84
|
+
q = db.query(Commit).filter(Commit.dataset_id == dataset.id, Commit.commit_hash.startswith(ref.lower()))
|
|
85
|
+
if ref.lower() and q.count() > 0:
|
|
86
|
+
return q.first()
|
|
87
|
+
# latest
|
|
88
|
+
return db.query(Commit).filter(Commit.dataset_id == dataset.id).order_by(Commit.seq.desc()).first()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def commit_or_404(db: Session, dataset: Dataset, ref: str) -> Commit:
|
|
92
|
+
c = resolve_ref(db, dataset, ref)
|
|
93
|
+
if c is None:
|
|
94
|
+
raise HTTPException(404, f"Version/ref '{ref}' not found")
|
|
95
|
+
return c
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------ commits
|
|
99
|
+
def create_commit(
|
|
100
|
+
db: Session,
|
|
101
|
+
dataset: Dataset,
|
|
102
|
+
author: User,
|
|
103
|
+
files: Iterable[dict],
|
|
104
|
+
message: str,
|
|
105
|
+
branch_name: str = "main",
|
|
106
|
+
parent_ref: str | None = None,
|
|
107
|
+
) -> Commit:
|
|
108
|
+
entries = normalize_files(files)
|
|
109
|
+
# validate declared blobs are present in object storage; register if missing from DB
|
|
110
|
+
missing = []
|
|
111
|
+
for path, meta in entries.items():
|
|
112
|
+
blob = db.get(Blob, meta["hash"])
|
|
113
|
+
if blob is None:
|
|
114
|
+
if get_storage().exists(object_key(meta["hash"])):
|
|
115
|
+
db.add(Blob(
|
|
116
|
+
content_hash=meta["hash"], size=meta["size"],
|
|
117
|
+
storage_key=object_key(meta["hash"]), ref_count=0,
|
|
118
|
+
))
|
|
119
|
+
else:
|
|
120
|
+
missing.append(path)
|
|
121
|
+
if missing:
|
|
122
|
+
raise HTTPException(400, f"Blobs not uploaded yet: {missing[:20]}")
|
|
123
|
+
db.flush()
|
|
124
|
+
|
|
125
|
+
tree_id = hash_tree(entries)
|
|
126
|
+
if db.query(Tree).filter(Tree.content_hash == tree_id).first() is None:
|
|
127
|
+
db.add(Tree(dataset_id=dataset.id, content_hash=tree_id, payload=entries))
|
|
128
|
+
|
|
129
|
+
branch = db.query(Branch).filter(Branch.dataset_id == dataset.id, Branch.name == branch_name).first()
|
|
130
|
+
parents: list[str] = []
|
|
131
|
+
if parent_ref:
|
|
132
|
+
parent = resolve_ref(db, dataset, parent_ref)
|
|
133
|
+
if parent:
|
|
134
|
+
parents = [parent.commit_hash]
|
|
135
|
+
elif branch is not None:
|
|
136
|
+
parents = [branch.head_commit_id]
|
|
137
|
+
|
|
138
|
+
ts = datetime.utcnow().isoformat()
|
|
139
|
+
commit_hash = hash_commit(tree_id, parents, author.name, message, ts)
|
|
140
|
+
while db.query(Commit).filter(Commit.commit_hash == commit_hash).first():
|
|
141
|
+
ts = datetime.utcnow().isoformat() + "-" + str(len(parents))
|
|
142
|
+
commit_hash = hash_commit(tree_id, parents, author.name, message, ts)
|
|
143
|
+
|
|
144
|
+
last = db.query(Commit).filter(Commit.dataset_id == dataset.id).order_by(Commit.seq.desc()).first()
|
|
145
|
+
commit = Commit(
|
|
146
|
+
dataset_id=dataset.id,
|
|
147
|
+
commit_hash=commit_hash,
|
|
148
|
+
tree_id=tree_id,
|
|
149
|
+
parent_hashes=parents,
|
|
150
|
+
author_id=author.id,
|
|
151
|
+
message=message,
|
|
152
|
+
seq=(last.seq + 1 if last else 1),
|
|
153
|
+
)
|
|
154
|
+
db.add(commit)
|
|
155
|
+
|
|
156
|
+
if branch is None:
|
|
157
|
+
branch = Branch(dataset_id=dataset.id, name=branch_name, head_commit_id=commit_hash)
|
|
158
|
+
db.add(branch)
|
|
159
|
+
else:
|
|
160
|
+
branch.head_commit_id = commit_hash
|
|
161
|
+
|
|
162
|
+
# reference counting (dataset-level): +1 only for newly referenced blobs
|
|
163
|
+
for path, meta in entries.items():
|
|
164
|
+
existing = (
|
|
165
|
+
db.query(BlobRef)
|
|
166
|
+
.filter(BlobRef.dataset_id == dataset.id, BlobRef.blob_hash == meta["hash"])
|
|
167
|
+
.first()
|
|
168
|
+
)
|
|
169
|
+
if existing is None:
|
|
170
|
+
blob = db.get(Blob, meta["hash"])
|
|
171
|
+
if blob:
|
|
172
|
+
blob.ref_count += 1
|
|
173
|
+
db.add(BlobRef(dataset_id=dataset.id, blob_hash=meta["hash"]))
|
|
174
|
+
db.commit()
|
|
175
|
+
db.refresh(commit)
|
|
176
|
+
return commit
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def rollback(db: Session, dataset: Dataset, author: User, target_ref: str, message: str) -> Commit:
|
|
180
|
+
target = commit_or_404(db, dataset, target_ref)
|
|
181
|
+
entries = entries_from_commit(db, target)
|
|
182
|
+
files = [
|
|
183
|
+
{"path": p, "hash": m["hash"], "size": m["size"]}
|
|
184
|
+
for p, m in entries.items()
|
|
185
|
+
]
|
|
186
|
+
branch = db.query(Branch).filter(Branch.dataset_id == dataset.id, Branch.name == "main").first()
|
|
187
|
+
parent_ref = branch.head_commit_id if branch else None
|
|
188
|
+
msg = message or f"rollback to {target_ref} ({target.commit_hash[:8]})"
|
|
189
|
+
return create_commit(
|
|
190
|
+
db, dataset, author, files, msg, branch_name=branch.name if branch else "main",
|
|
191
|
+
parent_ref=parent_ref,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------ diff
|
|
196
|
+
def diff_commits(db: Session, base: Commit, head: Commit) -> list[dict]:
|
|
197
|
+
base_entries = entries_from_commit(db, base)
|
|
198
|
+
head_entries = entries_from_commit(db, head)
|
|
199
|
+
changes = []
|
|
200
|
+
for path in sorted(set(base_entries) | set(head_entries)):
|
|
201
|
+
old = base_entries.get(path)
|
|
202
|
+
new = head_entries.get(path)
|
|
203
|
+
if old is None:
|
|
204
|
+
changes.append({"path": path, "status": "added", "old": None, "new": new})
|
|
205
|
+
elif new is None:
|
|
206
|
+
changes.append({"path": path, "status": "removed", "old": old, "new": None})
|
|
207
|
+
elif old["hash"] != new["hash"]:
|
|
208
|
+
changes.append({"path": path, "status": "modified", "old": old, "new": new})
|
|
209
|
+
return changes
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------ delete
|
|
213
|
+
def storage_info(db: Session, dataset: Dataset) -> dict:
|
|
214
|
+
refs = db.query(BlobRef).filter(BlobRef.dataset_id == dataset.id).all()
|
|
215
|
+
total_bytes = 0
|
|
216
|
+
shared = 0
|
|
217
|
+
shared_bytes = 0
|
|
218
|
+
for r in refs:
|
|
219
|
+
blob = db.get(Blob, r.blob_hash)
|
|
220
|
+
if blob:
|
|
221
|
+
total_bytes += blob.size
|
|
222
|
+
if blob.ref_count > 1:
|
|
223
|
+
shared += 1
|
|
224
|
+
shared_bytes += blob.size
|
|
225
|
+
versions = db.query(Commit).filter(Commit.dataset_id == dataset.id).count()
|
|
226
|
+
latest = db.query(Commit).filter(Commit.dataset_id == dataset.id).order_by(Commit.seq.desc()).first()
|
|
227
|
+
file_count = len(entries_from_commit(db, latest)) if latest else 0
|
|
228
|
+
return {
|
|
229
|
+
"dataset_id": dataset.id,
|
|
230
|
+
"versions": versions,
|
|
231
|
+
"file_count": file_count,
|
|
232
|
+
"storage_bytes": total_bytes,
|
|
233
|
+
"shared_blobs": shared,
|
|
234
|
+
"shared_bytes": shared_bytes,
|
|
235
|
+
"unique_blobs": len(refs),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def delete_dataset(
|
|
240
|
+
db: Session, dataset: Dataset, user: User, ip: str = ""
|
|
241
|
+
) -> dict:
|
|
242
|
+
"""Metadata deletion + reference counting + physical storage cleanup."""
|
|
243
|
+
info = storage_info(db, dataset)
|
|
244
|
+
blob_hashes = [r.blob_hash for r in db.query(BlobRef).filter(BlobRef.dataset_id == dataset.id).all()]
|
|
245
|
+
zero_ref: list[Blob] = []
|
|
246
|
+
for h in blob_hashes:
|
|
247
|
+
blob = db.get(Blob, h)
|
|
248
|
+
if blob:
|
|
249
|
+
blob.ref_count -= 1
|
|
250
|
+
if blob.ref_count <= 0:
|
|
251
|
+
zero_ref.append(blob)
|
|
252
|
+
|
|
253
|
+
# collect tree hashes this dataset's commits use
|
|
254
|
+
tree_ids = {c.tree_id for c in db.query(Commit).filter(Commit.dataset_id == dataset.id).all()}
|
|
255
|
+
commit_hashes = {c.commit_hash for c in db.query(Commit).filter(Commit.dataset_id == dataset.id).all()}
|
|
256
|
+
|
|
257
|
+
# delete dataset-scoped rows
|
|
258
|
+
db.query(Commit).filter(Commit.dataset_id == dataset.id).delete()
|
|
259
|
+
db.query(Branch).filter(Branch.dataset_id == dataset.id).delete()
|
|
260
|
+
db.query(Tag).filter(Tag.dataset_id == dataset.id).delete()
|
|
261
|
+
db.query(DatasetAcl).filter(DatasetAcl.dataset_id == dataset.id).delete()
|
|
262
|
+
db.query(BlobRef).filter(BlobRef.dataset_id == dataset.id).delete()
|
|
263
|
+
db.query(StagedUpload).filter(StagedUpload.dataset_id == dataset.id).delete()
|
|
264
|
+
ds_id = dataset.id
|
|
265
|
+
ds_name = f"{dataset.namespace}/{dataset.name}"
|
|
266
|
+
db.delete(dataset)
|
|
267
|
+
|
|
268
|
+
# remove tree rows no longer referenced by any commit (any dataset)
|
|
269
|
+
for tree_id in tree_ids:
|
|
270
|
+
still_used = db.query(Commit).filter(Commit.tree_id == tree_id).count()
|
|
271
|
+
if still_used == 0:
|
|
272
|
+
db.query(Tree).filter(Tree.content_hash == tree_id).delete()
|
|
273
|
+
|
|
274
|
+
db.commit()
|
|
275
|
+
|
|
276
|
+
# physical deletion of unreferenced blobs (after metadata tx)
|
|
277
|
+
released_bytes = 0
|
|
278
|
+
deleted_objects = []
|
|
279
|
+
storage = get_storage()
|
|
280
|
+
for blob in zero_ref:
|
|
281
|
+
try:
|
|
282
|
+
storage.delete(object_key(blob.content_hash))
|
|
283
|
+
released_bytes += blob.size
|
|
284
|
+
deleted_objects.append(blob.content_hash)
|
|
285
|
+
except Exception:
|
|
286
|
+
pass # retryable via GC job
|
|
287
|
+
for h in deleted_objects:
|
|
288
|
+
db.query(Blob).filter(Blob.content_hash == h).delete()
|
|
289
|
+
db.commit()
|
|
290
|
+
|
|
291
|
+
from .models import GcJob
|
|
292
|
+
if len(deleted_objects) != len(zero_ref):
|
|
293
|
+
db.add(GcJob(
|
|
294
|
+
dataset_id=ds_id,
|
|
295
|
+
type="dataset_delete",
|
|
296
|
+
status="pending",
|
|
297
|
+
total=len(zero_ref),
|
|
298
|
+
done=len(deleted_objects),
|
|
299
|
+
detail={"note": "Some objects pending retry"},
|
|
300
|
+
created_by=user.id,
|
|
301
|
+
))
|
|
302
|
+
db.commit()
|
|
303
|
+
|
|
304
|
+
write_audit(
|
|
305
|
+
db, user, ds_id, "dataset.delete",
|
|
306
|
+
{
|
|
307
|
+
"name": ds_name,
|
|
308
|
+
"versions": info["versions"],
|
|
309
|
+
"released_bytes": released_bytes,
|
|
310
|
+
"objects_deleted": len(deleted_objects),
|
|
311
|
+
},
|
|
312
|
+
ip,
|
|
313
|
+
)
|
|
314
|
+
return {
|
|
315
|
+
"deleted": True,
|
|
316
|
+
"released_bytes": released_bytes,
|
|
317
|
+
"versions_deleted": info["versions"],
|
|
318
|
+
"objects_deleted": len(deleted_objects),
|
|
319
|
+
"shared_blobs_kept": info["shared_blobs"],
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ------------------------------------------------------------------ audit
|
|
324
|
+
def write_audit(
|
|
325
|
+
db: Session, user: User | None, dataset_id: int | None,
|
|
326
|
+
action: str, detail: dict, ip: str = "",
|
|
327
|
+
) -> None:
|
|
328
|
+
db.add(AuditLog(
|
|
329
|
+
user_id=user.id if user else None,
|
|
330
|
+
dataset_id=dataset_id,
|
|
331
|
+
action=action,
|
|
332
|
+
detail=detail or {},
|
|
333
|
+
ip=ip,
|
|
334
|
+
))
|
|
335
|
+
db.commit()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def client_ip(request) -> str:
|
|
339
|
+
return request.client.host if request.client else ""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
app/main.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""FastAPI application entrypoint."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
|
|
8
|
+
from .config import settings
|
|
9
|
+
from .db import init_db
|
|
10
|
+
from .routers import audit, auth, datasets, storage_objects, tokens, versions
|
|
11
|
+
|
|
12
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
13
|
+
# DV_WEB_DIST lets a pip-installed deployment serve a pre-built web UI from an
|
|
14
|
+
# arbitrary location; when unset we fall back to the in-repo build (dev).
|
|
15
|
+
WEB_DIST = Path(settings.web_dist).expanduser() if settings.web_dist else REPO_ROOT / "web" / "dist"
|
|
16
|
+
|
|
17
|
+
app = FastAPI(
|
|
18
|
+
title=settings.app_name,
|
|
19
|
+
version="0.1.0",
|
|
20
|
+
description="Git-like dataset version management platform "
|
|
21
|
+
"(control plane stores metadata only; data plane is object storage).",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app.add_middleware(
|
|
25
|
+
CORSMiddleware,
|
|
26
|
+
allow_origins=["*"],
|
|
27
|
+
allow_credentials=True,
|
|
28
|
+
allow_methods=["*"],
|
|
29
|
+
allow_headers=["*"],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.middleware("http")
|
|
34
|
+
async def no_store_api(request, call_next):
|
|
35
|
+
response = await call_next(request)
|
|
36
|
+
if request.url.path.startswith("/api/"):
|
|
37
|
+
response.headers["Cache-Control"] = "no-store"
|
|
38
|
+
return response
|
|
39
|
+
|
|
40
|
+
app.include_router(auth.router)
|
|
41
|
+
app.include_router(tokens.router)
|
|
42
|
+
app.include_router(datasets.router)
|
|
43
|
+
app.include_router(versions.router)
|
|
44
|
+
app.include_router(storage_objects.router)
|
|
45
|
+
app.include_router(audit.router)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.on_event("startup")
|
|
49
|
+
def on_startup():
|
|
50
|
+
init_db()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.get("/api/v1/health")
|
|
54
|
+
def health():
|
|
55
|
+
return {"status": "ok", "app": settings.app_name}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Serve the built web UI (if present) - API routes above take precedence.
|
|
59
|
+
if WEB_DIST.is_dir():
|
|
60
|
+
app.mount("/", StaticFiles(directory=str(WEB_DIST), html=True), name="web")
|
|
61
|
+
|
|
62
|
+
|
app/models.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""ORM models. Only metadata is stored here - never file contents."""
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import (
|
|
5
|
+
JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint,
|
|
6
|
+
)
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
8
|
+
|
|
9
|
+
from .db import Base
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _now() -> datetime:
|
|
13
|
+
return datetime.utcnow()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class User(Base):
|
|
17
|
+
__tablename__ = "users"
|
|
18
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
19
|
+
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
|
20
|
+
email: Mapped[str] = mapped_column(String(255), default="")
|
|
21
|
+
password_hash: Mapped[str] = mapped_column(String(255))
|
|
22
|
+
role: Mapped[str] = mapped_column(String(16), default="user") # admin | user
|
|
23
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
|
24
|
+
|
|
25
|
+
tokens = relationship("ApiToken", back_populates="user", cascade="all, delete-orphan")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiToken(Base):
|
|
29
|
+
__tablename__ = "api_tokens"
|
|
30
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
31
|
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
|
32
|
+
name: Mapped[str] = mapped_column(String(128))
|
|
33
|
+
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
|
34
|
+
scope: Mapped[list] = mapped_column(JSON, default=list) # ["*"] or [dataset_id,...]
|
|
35
|
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
36
|
+
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
37
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
|
38
|
+
|
|
39
|
+
user = relationship("User", back_populates="tokens")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Dataset(Base):
|
|
43
|
+
__tablename__ = "datasets"
|
|
44
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
45
|
+
name: Mapped[str] = mapped_column(String(128), index=True)
|
|
46
|
+
namespace: Mapped[str] = mapped_column(String(128), default="default", index=True)
|
|
47
|
+
description: Mapped[str] = mapped_column(Text, default="")
|
|
48
|
+
visibility: Mapped[str] = mapped_column(String(16), default="private") # public|private
|
|
49
|
+
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
|
50
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
|
51
|
+
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
52
|
+
|
|
53
|
+
__table_args__ = (UniqueConstraint("namespace", "name", "deleted_at"),)
|
|
54
|
+
|
|
55
|
+
acls = relationship("DatasetAcl", back_populates="dataset", cascade="all, delete-orphan")
|
|
56
|
+
branches = relationship("Branch", back_populates="dataset", cascade="all, delete-orphan")
|
|
57
|
+
tags = relationship("Tag", back_populates="dataset", cascade="all, delete-orphan")
|
|
58
|
+
commits = relationship("Commit", back_populates="dataset", cascade="all, delete-orphan")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DatasetAcl(Base):
|
|
62
|
+
__tablename__ = "dataset_acls"
|
|
63
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
64
|
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
|
65
|
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
|
66
|
+
role: Mapped[str] = mapped_column(String(16), default="reader") # owner|editor|reader
|
|
67
|
+
|
|
68
|
+
__table_args__ = (UniqueConstraint("dataset_id", "user_id"),)
|
|
69
|
+
dataset = relationship("Dataset", back_populates="acls")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Tree(Base):
|
|
73
|
+
__tablename__ = "trees"
|
|
74
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
75
|
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
|
76
|
+
content_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
77
|
+
payload: Mapped[dict] = mapped_column(JSON) # {path: {"type","hash","size"}}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Commit(Base):
|
|
81
|
+
__tablename__ = "commits"
|
|
82
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
83
|
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
|
84
|
+
commit_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
85
|
+
tree_id: Mapped[str] = mapped_column(String(64), index=True)
|
|
86
|
+
parent_hashes: Mapped[list] = mapped_column(JSON, default=list)
|
|
87
|
+
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
|
88
|
+
message: Mapped[str] = mapped_column(Text, default="")
|
|
89
|
+
seq: Mapped[int] = mapped_column(Integer, default=0) # human friendly version number
|
|
90
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
|
91
|
+
|
|
92
|
+
dataset = relationship("Dataset", back_populates="commits")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Blob(Base):
|
|
96
|
+
__tablename__ = "blobs"
|
|
97
|
+
content_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
98
|
+
size: Mapped[int] = mapped_column(Integer, default=0)
|
|
99
|
+
storage_key: Mapped[str] = mapped_column(String(512))
|
|
100
|
+
ref_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
101
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BlobRef(Base):
|
|
105
|
+
"""Which datasets reference a blob (dataset-level ref counting)."""
|
|
106
|
+
__tablename__ = "blob_refs"
|
|
107
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
108
|
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
|
109
|
+
blob_hash: Mapped[str] = mapped_column(ForeignKey("blobs.content_hash"), index=True)
|
|
110
|
+
|
|
111
|
+
__table_args__ = (UniqueConstraint("dataset_id", "blob_hash"),)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Branch(Base):
|
|
115
|
+
__tablename__ = "branches"
|
|
116
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
117
|
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
|
118
|
+
name: Mapped[str] = mapped_column(String(128))
|
|
119
|
+
head_commit_id: Mapped[str] = mapped_column(String(64))
|
|
120
|
+
|
|
121
|
+
__table_args__ = (UniqueConstraint("dataset_id", "name"),)
|
|
122
|
+
dataset = relationship("Dataset", back_populates="branches")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Tag(Base):
|
|
126
|
+
__tablename__ = "tags"
|
|
127
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
128
|
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
|
129
|
+
name: Mapped[str] = mapped_column(String(128))
|
|
130
|
+
commit_id: Mapped[str] = mapped_column(String(64))
|
|
131
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
|
132
|
+
|
|
133
|
+
__table_args__ = (UniqueConstraint("dataset_id", "name"),)
|
|
134
|
+
dataset = relationship("Dataset", back_populates="tags")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class StagedUpload(Base):
|
|
138
|
+
__tablename__ = "staged_uploads"
|
|
139
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
140
|
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
|
141
|
+
uploader_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
|
142
|
+
blob_hashes: Mapped[list] = mapped_column(JSON, default=list)
|
|
143
|
+
status: Mapped[str] = mapped_column(String(16), default="pending")
|
|
144
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
|
145
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class GcJob(Base):
|
|
149
|
+
__tablename__ = "gc_jobs"
|
|
150
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
151
|
+
dataset_id: Mapped[int] = mapped_column(Integer, nullable=True)
|
|
152
|
+
type: Mapped[str] = mapped_column(String(32)) # dataset_delete | gc
|
|
153
|
+
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|running|done|failed
|
|
154
|
+
total: Mapped[int] = mapped_column(Integer, default=0)
|
|
155
|
+
done: Mapped[int] = mapped_column(Integer, default=0)
|
|
156
|
+
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
157
|
+
created_by: Mapped[int] = mapped_column(Integer, nullable=True)
|
|
158
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
|
159
|
+
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AuditLog(Base):
|
|
163
|
+
__tablename__ = "audit_logs"
|
|
164
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
165
|
+
user_id: Mapped[int] = mapped_column(Integer, nullable=True, index=True)
|
|
166
|
+
dataset_id: Mapped[int] = mapped_column(Integer, nullable=True, index=True)
|
|
167
|
+
action: Mapped[str] = mapped_column(String(64), index=True)
|
|
168
|
+
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
169
|
+
ip: Mapped[str] = mapped_column(String(64), default="")
|
|
170
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now, index=True)
|
app/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API routers."""
|