dirigent-core 0.27.0__tar.gz → 0.28.0__tar.gz
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.
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/PKG-INFO +5 -4
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/pyproject.toml +5 -4
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/pyproject.toml.orig +5 -4
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +35 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/alerting.py +7 -2
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/artifacts.py +46 -6
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/auth.py +41 -11
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/config.py +203 -20
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/configdocs.py +23 -6
- dirigent_core-0.28.0/src/dirigent_core/connections.py +104 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/database.py +34 -1
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/directory.py +6 -8
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/documents.py +92 -8
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/claim.py +3 -1
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/context.py +62 -7
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/definition.py +133 -7
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/executor.py +153 -79
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/failure.py +23 -7
- dirigent_core-0.28.0/src/dirigent_core/engine/quota.py +186 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/recovery.py +1 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/references.py +248 -49
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/replies.py +42 -5
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/runs.py +154 -3
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/services.py +2 -2
- dirigent_core-0.28.0/src/dirigent_core/engine/shell.py +802 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/messages.py +181 -4
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/models.py +56 -18
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/pipelines.py +38 -17
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/ratelimit.py +17 -4
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/scheduler.py +43 -23
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/schemas.py +12 -2
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/secrets.py +96 -17
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/endpoint.py +64 -3
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/holding.py +25 -11
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/intake.py +156 -38
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/polling.py +29 -8
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/rows.py +40 -7
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/rules.py +19 -3
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/waiter.py +0 -2
- dirigent_core-0.28.0/src/dirigent_core/statedir.py +296 -0
- dirigent_core-0.28.0/src/dirigent_core/stats.py +223 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/storage.py +160 -28
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/trigger_documents.py +10 -4
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/triggers/materialize.py +15 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/triggers/schedules.py +8 -2
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/types.py +44 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/LICENSE +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/README.md +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/__init__.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/alembic/env.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/alembic/script.py.mako +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/blockdocs.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/documentschema.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/__init__.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/expansion.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/state.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/errors.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/examples.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/ids.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/logging.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/migrations.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/plugins.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/protocol.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/py.typed +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/registry.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/reporting.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/retention.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/__init__.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/entries.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/kinds.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/sensor.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/storage.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/telemetry.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/triggers/__init__.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/triggers/backfill.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/wakeups.py +0 -0
- {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.0
|
|
4
4
|
Summary: Dirigent engine core: schema, configuration, and plugin host.
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
@@ -12,12 +12,13 @@ Requires-Dist: argon2-cffi>=25.1.0
|
|
|
12
12
|
Requires-Dist: asyncpg>=0.31.0
|
|
13
13
|
Requires-Dist: cronsim>=2.7
|
|
14
14
|
Requires-Dist: cryptography>=46.0.5
|
|
15
|
-
Requires-Dist: dirigent-client==0.
|
|
16
|
-
Requires-Dist: dirigent-common==0.
|
|
17
|
-
Requires-Dist: dirigent-plugin==0.
|
|
15
|
+
Requires-Dist: dirigent-client==0.28.0
|
|
16
|
+
Requires-Dist: dirigent-common==0.28.0
|
|
17
|
+
Requires-Dist: dirigent-plugin==0.28.0
|
|
18
18
|
Requires-Dist: jsonschema>=4.26.0
|
|
19
19
|
Requires-Dist: opentelemetry-api>=1.44.0
|
|
20
20
|
Requires-Dist: pydantic-settings>=2.15.0
|
|
21
|
+
Requires-Dist: python-dotenv>=1.1.0
|
|
21
22
|
Requires-Dist: pyyaml>=6.0.3
|
|
22
23
|
Requires-Dist: sqlalchemy[asyncio]>=2.1.3
|
|
23
24
|
Requires-Dist: structlog>=25.5.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.28.0"
|
|
4
4
|
description = "Dirigent engine core: schema, configuration, and plugin host."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -17,12 +17,13 @@ dependencies = [
|
|
|
17
17
|
"asyncpg>=0.31.0",
|
|
18
18
|
"cronsim>=2.7",
|
|
19
19
|
"cryptography>=46.0.5",
|
|
20
|
-
"dirigent-client==0.
|
|
21
|
-
"dirigent-common==0.
|
|
22
|
-
"dirigent-plugin==0.
|
|
20
|
+
"dirigent-client==0.28.0",
|
|
21
|
+
"dirigent-common==0.28.0",
|
|
22
|
+
"dirigent-plugin==0.28.0",
|
|
23
23
|
"jsonschema>=4.26.0",
|
|
24
24
|
"opentelemetry-api>=1.44.0",
|
|
25
25
|
"pydantic-settings>=2.15.0",
|
|
26
|
+
"python-dotenv>=1.1.0",
|
|
26
27
|
"pyyaml>=6.0.3",
|
|
27
28
|
"sqlalchemy[asyncio]>=2.1.3",
|
|
28
29
|
"structlog>=25.5.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.28.0"
|
|
4
4
|
description = "Dirigent engine core: schema, configuration, and plugin host."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -17,12 +17,13 @@ dependencies = [
|
|
|
17
17
|
"asyncpg>=0.31.0",
|
|
18
18
|
"cronsim>=2.7",
|
|
19
19
|
"cryptography>=46.0.5",
|
|
20
|
-
"dirigent-client==0.
|
|
21
|
-
"dirigent-common==0.
|
|
22
|
-
"dirigent-plugin==0.
|
|
20
|
+
"dirigent-client==0.28.0",
|
|
21
|
+
"dirigent-common==0.28.0",
|
|
22
|
+
"dirigent-plugin==0.28.0",
|
|
23
23
|
"jsonschema>=4.26.0",
|
|
24
24
|
"opentelemetry-api>=1.44.0",
|
|
25
25
|
"pydantic-settings>=2.15.0",
|
|
26
|
+
"python-dotenv>=1.1.0",
|
|
26
27
|
"pyyaml>=6.0.3",
|
|
27
28
|
"sqlalchemy[asyncio]>=2.1.3",
|
|
28
29
|
"structlog>=25.5.0",
|
|
@@ -450,6 +450,7 @@ def upgrade() -> None:
|
|
|
450
450
|
sa.Column("token_hash", sa.String(length=128), nullable=True),
|
|
451
451
|
sa.Column("token_prefix", sa.String(length=16), nullable=True),
|
|
452
452
|
sa.Column("signing_secret", sa.LargeBinary(), nullable=True),
|
|
453
|
+
sa.Column("secret_fingerprint", sa.String(length=16), nullable=True),
|
|
453
454
|
sa.Column("signing_key_id", sa.String(length=64), nullable=True),
|
|
454
455
|
sa.Column("paused", sa.Boolean(), server_default=sa.false(), nullable=False),
|
|
455
456
|
sa.Column("accepted", sa.Integer(), server_default=sa.text("0"), nullable=False),
|
|
@@ -524,6 +525,7 @@ def upgrade() -> None:
|
|
|
524
525
|
sa.Enum(
|
|
525
526
|
"queued",
|
|
526
527
|
"running",
|
|
528
|
+
"paused",
|
|
527
529
|
"succeeded",
|
|
528
530
|
"completed_with_errors",
|
|
529
531
|
"failed",
|
|
@@ -550,6 +552,8 @@ def upgrade() -> None:
|
|
|
550
552
|
"user",
|
|
551
553
|
"pipeline",
|
|
552
554
|
"backfill",
|
|
555
|
+
"source",
|
|
556
|
+
"rerun",
|
|
553
557
|
name="trigger_kind",
|
|
554
558
|
native_enum=False,
|
|
555
559
|
length=32,
|
|
@@ -567,6 +571,7 @@ def upgrade() -> None:
|
|
|
567
571
|
),
|
|
568
572
|
sa.Column("started_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
|
|
569
573
|
sa.Column("finished_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
|
|
574
|
+
sa.Column("paused_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
|
|
570
575
|
sa.Column("window_start", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
|
|
571
576
|
sa.Column("window_end", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
|
|
572
577
|
sa.Column(
|
|
@@ -578,6 +583,18 @@ def upgrade() -> None:
|
|
|
578
583
|
sa.Column(
|
|
579
584
|
"schemas", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=False
|
|
580
585
|
),
|
|
586
|
+
sa.Column(
|
|
587
|
+
"stored_bytes",
|
|
588
|
+
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
|
|
589
|
+
server_default=sa.text("0"),
|
|
590
|
+
nullable=False,
|
|
591
|
+
),
|
|
592
|
+
sa.Column(
|
|
593
|
+
"chain_stored_bytes",
|
|
594
|
+
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
|
|
595
|
+
server_default=sa.text("0"),
|
|
596
|
+
nullable=False,
|
|
597
|
+
),
|
|
581
598
|
sa.Column(
|
|
582
599
|
"created_at",
|
|
583
600
|
dirigent_core.types.UtcDateTime(timezone=True),
|
|
@@ -808,6 +825,19 @@ def upgrade() -> None:
|
|
|
808
825
|
batch_op.create_index("ix_source_inputs_created_at", ["created_at"], unique=False)
|
|
809
826
|
batch_op.create_index("ix_source_inputs_source_id_id", ["source_id", "id"], unique=False)
|
|
810
827
|
|
|
828
|
+
op.create_table(
|
|
829
|
+
"source_signatures",
|
|
830
|
+
sa.Column("source_id", sa.Uuid(), nullable=False),
|
|
831
|
+
sa.Column("signature", sa.String(length=64), nullable=False),
|
|
832
|
+
sa.Column("signed_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=False),
|
|
833
|
+
sa.ForeignKeyConstraint(
|
|
834
|
+
["source_id"], ["sources.id"], name=op.f("fk_source_signatures_source_id_sources"), ondelete="CASCADE"
|
|
835
|
+
),
|
|
836
|
+
sa.PrimaryKeyConstraint("source_id", "signature", name=op.f("pk_source_signatures")),
|
|
837
|
+
)
|
|
838
|
+
with op.batch_alter_table("source_signatures", schema=None) as batch_op:
|
|
839
|
+
batch_op.create_index("ix_source_signatures_signed_at", ["signed_at"], unique=False)
|
|
840
|
+
|
|
811
841
|
op.create_table(
|
|
812
842
|
"step_attempts",
|
|
813
843
|
sa.Column("id", sa.Uuid(), nullable=False),
|
|
@@ -843,6 +873,7 @@ def upgrade() -> None:
|
|
|
843
873
|
sa.Column("lease_token", sa.Uuid(), nullable=True),
|
|
844
874
|
sa.Column("lease_expires_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
|
|
845
875
|
sa.Column("heartbeat_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
|
|
876
|
+
sa.Column("lease_recoveries", sa.Integer(), server_default=sa.text("0"), nullable=False),
|
|
846
877
|
sa.Column(
|
|
847
878
|
"remote_handle", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
|
|
848
879
|
),
|
|
@@ -999,6 +1030,10 @@ def downgrade() -> None:
|
|
|
999
1030
|
batch_op.drop_index("ix_step_attempts_lease_expires_at")
|
|
1000
1031
|
|
|
1001
1032
|
op.drop_table("step_attempts")
|
|
1033
|
+
with op.batch_alter_table("source_signatures", schema=None) as batch_op:
|
|
1034
|
+
batch_op.drop_index("ix_source_signatures_signed_at")
|
|
1035
|
+
|
|
1036
|
+
op.drop_table("source_signatures")
|
|
1002
1037
|
with op.batch_alter_table("source_inputs", schema=None) as batch_op:
|
|
1003
1038
|
batch_op.drop_index("ix_source_inputs_source_id_id")
|
|
1004
1039
|
batch_op.drop_index("ix_source_inputs_created_at")
|
|
@@ -35,6 +35,8 @@ from dirigent_common import (
|
|
|
35
35
|
compile_template,
|
|
36
36
|
format_duration,
|
|
37
37
|
render,
|
|
38
|
+
scrub,
|
|
39
|
+
secrets_of,
|
|
38
40
|
)
|
|
39
41
|
from dirigent_core.database import is_deadlock, session_scope
|
|
40
42
|
from dirigent_core.engine.definition import load_definition
|
|
@@ -786,18 +788,20 @@ async def send_notification(
|
|
|
786
788
|
has to be held anyway, because the fence below re-reads the row it writes.
|
|
787
789
|
"""
|
|
788
790
|
moment = now or utcnow()
|
|
791
|
+
secrets: set[str] = set()
|
|
789
792
|
try:
|
|
790
793
|
notifier = services.host.notifiers.get(notification.notifier)
|
|
791
794
|
if notifier is None:
|
|
792
795
|
raise AlertError(NOTIFIER_NOT_ON_WORKER, notifier=repr(notification.notifier))
|
|
793
796
|
connection = await session.get(Connection, notification.connection_id) if notification.connection_id else None
|
|
794
797
|
config = notifier_config(services, notifier, connection)
|
|
798
|
+
secrets = secrets_of(config)
|
|
795
799
|
await session.commit()
|
|
796
800
|
await notifier.send(_message(notification), config)
|
|
797
801
|
except Exception as error:
|
|
798
802
|
if not await _holds_lease(session, notification, owner, lease_token):
|
|
799
803
|
return False
|
|
800
|
-
return await _record_failure(session, services, notification, error, moment)
|
|
804
|
+
return await _record_failure(session, services, notification, error, moment, secrets)
|
|
801
805
|
if not await _holds_lease(session, notification, owner, lease_token):
|
|
802
806
|
return False
|
|
803
807
|
notification.status = NotificationStatus.SENT
|
|
@@ -896,9 +900,10 @@ async def _record_failure(
|
|
|
896
900
|
notification: Notification,
|
|
897
901
|
error: Exception,
|
|
898
902
|
moment: datetime,
|
|
903
|
+
secrets: set[str],
|
|
899
904
|
) -> bool:
|
|
900
905
|
"""Schedule another delivery, or record that this one is never going to arrive."""
|
|
901
|
-
message = f"{type(error).__name__}: {error}"
|
|
906
|
+
message = scrub(f"{type(error).__name__}: {error}", secrets)
|
|
902
907
|
notification.error = message
|
|
903
908
|
notification.lease_owner = None
|
|
904
909
|
notification.lease_token = None
|
|
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
11
11
|
|
|
12
12
|
from dirigent_common import JsonMap
|
|
13
13
|
from dirigent_core.errors import DomainError
|
|
14
|
-
from dirigent_core.messages import OBJECT_MISSING
|
|
14
|
+
from dirigent_core.messages import OBJECT_ALTERED, OBJECT_MISSING
|
|
15
15
|
from dirigent_core.models import ArtifactRef, StepAttempt
|
|
16
16
|
from dirigent_core.storage import Storage, StorageError, join_uri, parse_uri
|
|
17
17
|
|
|
@@ -41,6 +41,21 @@ class ObjectMissing(DomainError):
|
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
class ArtifactAltered(DomainError):
|
|
45
|
+
"""The object in storage is not the one this row recorded when it was written."""
|
|
46
|
+
|
|
47
|
+
status = 500
|
|
48
|
+
message = OBJECT_ALTERED
|
|
49
|
+
|
|
50
|
+
def __init__(self, reference: ArtifactRef) -> None:
|
|
51
|
+
"""Name the object, and the run and attempt whose row recorded something else."""
|
|
52
|
+
super().__init__(
|
|
53
|
+
uri=reference.uri or "",
|
|
54
|
+
run=str(reference.run_id),
|
|
55
|
+
attempt=str(reference.step_attempt_id) if reference.step_attempt_id else NO_ATTEMPT,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
44
59
|
def canonical_json(value: object) -> bytes:
|
|
45
60
|
"""Serialize a value the one way the engine hashes and stores it."""
|
|
46
61
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
|
|
@@ -137,14 +152,39 @@ async def _present_uri(storage: Storage, reference: ArtifactRef) -> str:
|
|
|
137
152
|
where one that opened the stream first would break mid-body with the status already sent.
|
|
138
153
|
"""
|
|
139
154
|
uri = reference.uri or ""
|
|
140
|
-
|
|
155
|
+
found = await storage.stat(uri)
|
|
156
|
+
if found is None:
|
|
141
157
|
raise ObjectMissing(reference)
|
|
158
|
+
if reference.size_bytes is not None and found.size != reference.size_bytes:
|
|
159
|
+
raise ArtifactAltered(reference)
|
|
142
160
|
return uri
|
|
143
161
|
|
|
144
162
|
|
|
163
|
+
async def _verified(chunks: AsyncGenerator[bytes], reference: ArtifactRef) -> AsyncGenerator[bytes]:
|
|
164
|
+
"""Pass a stored object through, and fail at its end if it is not what the row recorded.
|
|
165
|
+
|
|
166
|
+
A stream's status has gone by then, so what this can do is end the response as an error
|
|
167
|
+
rather than a complete body. A row with no digest has nothing to check against.
|
|
168
|
+
"""
|
|
169
|
+
hashed = hashlib.sha256()
|
|
170
|
+
async for chunk in chunks:
|
|
171
|
+
hashed.update(chunk)
|
|
172
|
+
yield chunk
|
|
173
|
+
if reference.digest is not None and f"sha256:{hashed.hexdigest()}" != reference.digest:
|
|
174
|
+
raise ArtifactAltered(reference)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _read_verified(storage: Storage, reference: ArtifactRef) -> bytes:
|
|
178
|
+
"""Read a whole stored object, refusing one that is not what the row recorded."""
|
|
179
|
+
payload = await storage.read_bytes(await _present_uri(storage, reference))
|
|
180
|
+
if reference.digest is not None and digest_of(payload) != reference.digest:
|
|
181
|
+
raise ArtifactAltered(reference)
|
|
182
|
+
return payload
|
|
183
|
+
|
|
184
|
+
|
|
145
185
|
async def open_artifact(storage: Storage, reference: ArtifactRef) -> AsyncGenerator[bytes]:
|
|
146
|
-
"""Open the stored object a row names for streaming, refusing before the first byte."""
|
|
147
|
-
return storage.open_read(await _present_uri(storage, reference))
|
|
186
|
+
"""Open the stored object a row names for streaming, refusing what it can before the first byte."""
|
|
187
|
+
return _verified(storage.open_read(await _present_uri(storage, reference)), reference)
|
|
148
188
|
|
|
149
189
|
|
|
150
190
|
async def load_document(storage: Storage, reference: ArtifactRef) -> str:
|
|
@@ -154,7 +194,7 @@ async def load_document(storage: Storage, reference: ArtifactRef) -> str:
|
|
|
154
194
|
return inlined if isinstance(inlined, str) else ""
|
|
155
195
|
if reference.uri is None:
|
|
156
196
|
return ""
|
|
157
|
-
return (await
|
|
197
|
+
return (await _read_verified(storage, reference)).decode()
|
|
158
198
|
|
|
159
199
|
|
|
160
200
|
async def load_artifact(session: AsyncSession, storage: Storage, artifact_id: UUID) -> JsonMap | None:
|
|
@@ -166,6 +206,6 @@ async def load_artifact(session: AsyncSession, storage: Storage, artifact_id: UU
|
|
|
166
206
|
return reference.inline_value
|
|
167
207
|
if reference.uri is None:
|
|
168
208
|
return None
|
|
169
|
-
payload = await
|
|
209
|
+
payload = await _read_verified(storage, reference)
|
|
170
210
|
loaded: JsonMap = json.loads(payload)
|
|
171
211
|
return loaded
|
|
@@ -251,6 +251,15 @@ async def create_user(
|
|
|
251
251
|
return user
|
|
252
252
|
|
|
253
253
|
|
|
254
|
+
async def create_fixture_admin(session: AsyncSession, username: str, password: str) -> User:
|
|
255
|
+
"""Create the admin of a throwaway instance, whose fixed password need not meet the length rule."""
|
|
256
|
+
user = User(username=username, password_hash=_hasher.hash(password), role=UserRole.ADMIN)
|
|
257
|
+
session.add(user)
|
|
258
|
+
await session.flush()
|
|
259
|
+
_logger.info("fixture admin created", username=username)
|
|
260
|
+
return user
|
|
261
|
+
|
|
262
|
+
|
|
254
263
|
async def reset_password(session: AsyncSession, user: User, password: str) -> User:
|
|
255
264
|
"""Replace an account's password without its old one, ending every session it holds and no API token."""
|
|
256
265
|
user.password_hash = hash_password(password)
|
|
@@ -467,6 +476,28 @@ async def resolve_token(session: AsyncSession, secret: str, *, now: datetime | N
|
|
|
467
476
|
)
|
|
468
477
|
|
|
469
478
|
|
|
479
|
+
async def principal_stands(session: AsyncSession, principal: Principal, *, now: datetime | None = None) -> bool:
|
|
480
|
+
"""Say whether what authenticated a principal would still authenticate it, in one indexed read.
|
|
481
|
+
|
|
482
|
+
What a long-lived response asks on every poll, inside that poll's own transaction, so the
|
|
483
|
+
credential it was opened with is held to the same rule a new request is.
|
|
484
|
+
"""
|
|
485
|
+
moment = now or utcnow()
|
|
486
|
+
if principal.token_id is None:
|
|
487
|
+
statement = sa.select(User.active).where(User.id == principal.user_id)
|
|
488
|
+
else:
|
|
489
|
+
statement = (
|
|
490
|
+
sa.select(User.active)
|
|
491
|
+
.join(ApiToken, ApiToken.user_id == User.id)
|
|
492
|
+
.where(
|
|
493
|
+
ApiToken.id == principal.token_id,
|
|
494
|
+
ApiToken.revoked_at.is_(None),
|
|
495
|
+
sa.or_(ApiToken.expires_at.is_(None), ApiToken.expires_at > moment),
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
return (await session.execute(statement)).scalar_one_or_none() is True
|
|
499
|
+
|
|
500
|
+
|
|
470
501
|
async def list_tokens(
|
|
471
502
|
session: AsyncSession,
|
|
472
503
|
*,
|
|
@@ -503,23 +534,22 @@ async def list_tokens(
|
|
|
503
534
|
]
|
|
504
535
|
|
|
505
536
|
|
|
506
|
-
async def revoke_token(session: AsyncSession,
|
|
507
|
-
"""Revoke one
|
|
537
|
+
async def revoke_token(session: AsyncSession, token_id: UUID) -> bool:
|
|
538
|
+
"""Revoke one live API token by its id, reporting whether there was one to revoke.
|
|
508
539
|
|
|
509
|
-
A
|
|
540
|
+
A browser session shares the table and is not reached from here.
|
|
510
541
|
"""
|
|
511
542
|
statement = sa.select(ApiToken).where(
|
|
543
|
+
ApiToken.id == token_id,
|
|
544
|
+
ApiToken.kind == TokenKind.API,
|
|
512
545
|
ApiToken.revoked_at.is_(None),
|
|
513
|
-
ApiToken.user_id == user_id,
|
|
514
|
-
ApiToken.name == name,
|
|
515
546
|
)
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
revoked += 1
|
|
547
|
+
token = (await session.execute(statement)).scalar_one_or_none()
|
|
548
|
+
if token is None:
|
|
549
|
+
return False
|
|
550
|
+
token.revoked_at = utcnow()
|
|
521
551
|
await session.flush()
|
|
522
|
-
return
|
|
552
|
+
return True
|
|
523
553
|
|
|
524
554
|
|
|
525
555
|
async def revoke_session(session: AsyncSession, secret: str) -> None:
|