mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
feat: wire ekosystem UUID system into CNPG manifests (Tasks 1, 3, 4)
Task 1 — ConfigMap + CNPG wiring:
- Add k8s/knoe/knoe-ekosystem-sql.yaml: ConfigMap embedding ekosystem.sql
and ekosystem_objects.sql for CNPG postInitApplicationSQLRefs
- Add scripts/gen-ekosystem-configmap.py: generation script to keep the
ConfigMap in sync with knoe-db/schema/ekosystem*.sql source files
- Add Makefile target: make k8s/knoe/knoe-ekosystem-sql.yaml
- Wire postInitApplicationSQLRefs into all three CNPG cluster manifests:
k8s/knoe/knoe-db.yaml (k3s / prole-service-context production)
deploy/gcp/gke/knoe-db.yaml (GKE)
deploy/opentofu/k3s/manifests/knoe/knoe-db.yaml (OpenTofu k3s)
- Add knoe-ekosystem-sql.yaml to k8s/knoe/kustomization.yaml
Task 3 — Python counterpart utility:
- Add knoe/ekosystem.py: thread-safe EkosystemID generator matching the
PostgreSQL bit layout [49:ts_ms|12:tenant|10:shard|11:seq], with
decode() and can_access() helpers
- Add tests/test_ekosystem.py: 23 tests covering base36 encoding,
round-trips, thread safety, can_access, and the spec round-trip assertion
Task 4 — knoe.user ekosystem_uuid column:
- Add ALTER TABLE knoe.user ADD COLUMN IF NOT EXISTS ekosystem_uuid text UNIQUE
to postInitSQL in all three CNPG manifests
Task 2 (register prole tenant) requires a live DB connection — manual step.
Task 5 (LDAP/AD reconciler) is design-only per spec.
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
parent
9c5c56beb5
commit
d114801758
5
Makefile
5
Makefile
@ -127,6 +127,11 @@ k3d-knoe-pf:
|
||||
k3d-knoe-down:
|
||||
@./scripts/k3d-knoe-down.sh
|
||||
|
||||
k8s/knoe/knoe-ekosystem-sql.yaml:
|
||||
@echo "Generating ConfigMap from knoe-db/schema/ekosystem*.sql..."
|
||||
@$(PYTHON) scripts/gen-ekosystem-configmap.py
|
||||
@echo "✓ k8s/knoe/knoe-ekosystem-sql.yaml updated"
|
||||
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
rm -rf $(BUILD_DIR) $(DIST_DIR) *.spec
|
||||
|
||||
@ -133,6 +133,14 @@ spec:
|
||||
- GRANT SELECT, INSERT, UPDATE ON knoe.user TO knoe;
|
||||
- GRANT SELECT, INSERT, UPDATE ON knoe.user_role TO knoe;
|
||||
- GRANT USAGE, SELECT ON SEQUENCE knoe.user_id_seq TO knoe;
|
||||
# Task 4: align knoe.user with ekosystem user UUIDs
|
||||
- ALTER TABLE knoe.user ADD COLUMN IF NOT EXISTS ekosystem_uuid text UNIQUE;
|
||||
postInitApplicationSQLRefs:
|
||||
configMapRefs:
|
||||
- name: knoe-ekosystem-sql
|
||||
key: ekosystem.sql
|
||||
- name: knoe-ekosystem-sql
|
||||
key: ekosystem_objects.sql
|
||||
|
||||
managed:
|
||||
roles:
|
||||
|
||||
@ -98,6 +98,14 @@ spec:
|
||||
- GRANT SELECT, INSERT, UPDATE ON knoe.user TO knoe;
|
||||
- GRANT SELECT, INSERT, UPDATE ON knoe.user_role TO knoe;
|
||||
- GRANT USAGE, SELECT ON SEQUENCE knoe.user_id_seq TO knoe;
|
||||
# Task 4: align knoe.user with ekosystem user UUIDs
|
||||
- ALTER TABLE knoe.user ADD COLUMN IF NOT EXISTS ekosystem_uuid text UNIQUE;
|
||||
postInitApplicationSQLRefs:
|
||||
configMapRefs:
|
||||
- name: knoe-ekosystem-sql
|
||||
key: ekosystem.sql
|
||||
- name: knoe-ekosystem-sql
|
||||
key: ekosystem_objects.sql
|
||||
|
||||
managed:
|
||||
roles:
|
||||
|
||||
@ -99,6 +99,14 @@ spec:
|
||||
- GRANT USAGE ON SCHEMA storage TO anon;
|
||||
- GRANT USAGE ON SCHEMA graphql_public TO anon;
|
||||
- GRANT anon TO authenticator;
|
||||
# Task 4: align knoe.user with ekosystem user UUIDs
|
||||
- ALTER TABLE knoe.user ADD COLUMN IF NOT EXISTS ekosystem_uuid text UNIQUE;
|
||||
postInitApplicationSQLRefs:
|
||||
configMapRefs:
|
||||
- name: knoe-ekosystem-sql
|
||||
key: ekosystem.sql
|
||||
- name: knoe-ekosystem-sql
|
||||
key: ekosystem_objects.sql
|
||||
|
||||
certificates:
|
||||
serverAltDNSNames:
|
||||
|
||||
500
k8s/knoe/knoe-ekosystem-sql.yaml
Normal file
500
k8s/knoe/knoe-ekosystem-sql.yaml
Normal file
@ -0,0 +1,500 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: knoe-ekosystem-sql
|
||||
namespace: knoe-db
|
||||
data:
|
||||
ekosystem.sql: |
|
||||
-- =============================================================================
|
||||
-- knoe.ekosystem — Stable routable base36 UUID system for federated clusters
|
||||
--
|
||||
-- Bit layout (82 bits → 16 base36 chars):
|
||||
-- [49: timestamp_ms][12: tenant_id][10: shard_id][11: seq]
|
||||
--
|
||||
-- Epoch: 2026-01-01 00:00:00 UTC
|
||||
-- Range: ~17,839 years (to ~19,865 AD)
|
||||
-- Tenants: 4,096 (tenant_id 0 = knoe-db root authority)
|
||||
-- Shards: 1,024 per tenant
|
||||
-- Seq: 2,048 IDs/ms/shard (~2M IDs/sec/shard)
|
||||
-- Alphabet: 0-9 a-z (base36 lowercase — safe for DNS labels and PG identifiers)
|
||||
--
|
||||
-- Object naming pattern: p_{16-char-uuid}_{table_name}
|
||||
-- Prefix is 19 chars. PostgreSQL limit 63 bytes → table_name budget: 44 chars.
|
||||
-- SQLite and ClickHouse have no meaningful limit.
|
||||
--
|
||||
-- LDAP integration point: knoe.tenants is the authority for access control.
|
||||
-- Routing: knoe.ekosystem_tenant(table_name) extracts tenant_id from any
|
||||
-- project-namespaced object name without a registry lookup.
|
||||
-- =============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tenant registry — root of trust for all ekosystems
|
||||
-- knoe-db is tenant 0 and the sole issuer of tenant UUIDs.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS knoe.tenants (
|
||||
tenant_id smallint PRIMARY KEY CHECK (tenant_id BETWEEN 0 AND 4095),
|
||||
uuid text UNIQUE, -- 16-char ekosystem UUID; NULL for root
|
||||
name text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO knoe.tenants (tenant_id, uuid, name)
|
||||
VALUES (0, NULL, 'knoe-db')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Monotonic counter for the seq field; nextval() is O(1) with no row locking.
|
||||
-- Wraps modulo 2048 (11 bits) inside the function.
|
||||
CREATE SEQUENCE IF NOT EXISTS knoe.ekosystem_seq
|
||||
AS bigint MINVALUE 0 NO CYCLE;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.ekosystem_id(tenant_id, shard_id) → 16-char base36
|
||||
--
|
||||
-- Primary generator. numeric arithmetic handles the 82-bit value safely
|
||||
-- since PostgreSQL bigint is capped at 63 bits.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.ekosystem_id(
|
||||
p_tenant_id integer DEFAULT 0,
|
||||
p_shard_id integer DEFAULT 0
|
||||
) RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
c_epoch_ms constant bigint := 1767225600000; -- 2026-01-01 00:00:00 UTC
|
||||
c_alphabet constant text := '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
v_ts_ms bigint;
|
||||
v_seq integer;
|
||||
v_packed numeric;
|
||||
v_result text := '';
|
||||
v_rem integer;
|
||||
i integer;
|
||||
BEGIN
|
||||
IF p_tenant_id NOT BETWEEN 0 AND 4095 THEN
|
||||
RAISE EXCEPTION 'tenant_id must be 0–4095, got %', p_tenant_id;
|
||||
END IF;
|
||||
IF p_shard_id NOT BETWEEN 0 AND 1023 THEN
|
||||
RAISE EXCEPTION 'shard_id must be 0–1023, got %', p_shard_id;
|
||||
END IF;
|
||||
|
||||
v_ts_ms := (extract(epoch from clock_timestamp()) * 1000)::bigint - c_epoch_ms;
|
||||
v_seq := nextval('knoe.ekosystem_seq')::integer % 2048;
|
||||
|
||||
-- [49: ts_ms | 12: tenant | 10: shard | 11: seq] packed as numeric
|
||||
-- Power-of-2 multipliers avoid floating-point error in numeric division.
|
||||
v_packed := v_ts_ms::numeric * 8589934592::numeric -- 2^33
|
||||
+ p_tenant_id::numeric * 2097152::numeric -- 2^21
|
||||
+ p_shard_id::numeric * 2048::numeric -- 2^11
|
||||
+ v_seq::numeric;
|
||||
|
||||
FOR i IN 1..16 LOOP
|
||||
v_rem := mod(v_packed, 36)::integer;
|
||||
v_result := substr(c_alphabet, v_rem + 1, 1) || v_result;
|
||||
v_packed := floor(v_packed / 36);
|
||||
END LOOP;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.ekosystem_decode(id) → (tenant_id, shard_id, ts_ms, seq, issued_at)
|
||||
--
|
||||
-- Unpack any UUID back to its constituent fields. IMMUTABLE — safe to index.
|
||||
-- Primary use: routing queries to the correct ekosystem shard without a
|
||||
-- registry lookup, and LDAP principal resolution.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.ekosystem_decode(p_id text)
|
||||
RETURNS TABLE(
|
||||
tenant_id integer,
|
||||
shard_id integer,
|
||||
ts_ms bigint,
|
||||
seq integer,
|
||||
issued_at timestamptz
|
||||
)
|
||||
LANGUAGE plpgsql IMMUTABLE STRICT
|
||||
AS $$
|
||||
DECLARE
|
||||
c_epoch_ms constant bigint := 1767225600000;
|
||||
c_alphabet constant text := '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
v_n numeric := 0;
|
||||
v_pos integer;
|
||||
i integer;
|
||||
BEGIN
|
||||
IF length(p_id) != 16 THEN
|
||||
RAISE EXCEPTION 'ekosystem UUID must be exactly 16 chars, got %', length(p_id);
|
||||
END IF;
|
||||
|
||||
FOR i IN 1..16 LOOP
|
||||
v_pos := position(substr(p_id, i, 1) IN c_alphabet) - 1;
|
||||
IF v_pos < 0 THEN
|
||||
RAISE EXCEPTION 'Invalid base36 character at position %: ''%''',
|
||||
i, substr(p_id, i, 1);
|
||||
END IF;
|
||||
v_n := v_n * 36 + v_pos;
|
||||
END LOOP;
|
||||
|
||||
seq := mod(v_n, 2048)::integer; v_n := floor(v_n / 2048);
|
||||
shard_id := mod(v_n, 1024)::integer; v_n := floor(v_n / 1024);
|
||||
tenant_id := mod(v_n, 4096)::integer; v_n := floor(v_n / 4096);
|
||||
ts_ms := v_n::bigint;
|
||||
issued_at := to_timestamp((ts_ms + c_epoch_ms) / 1000.0);
|
||||
|
||||
RETURN NEXT;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.ekosystem_tenant(table_name) → tenant_id
|
||||
--
|
||||
-- Extract the owning tenant from a project-namespaced object name without any
|
||||
-- registry join. Extracts the UUID from position 3 (past 'p_') for 16 chars.
|
||||
--
|
||||
-- Usage in triggers: SELECT knoe.ekosystem_tenant(TG_TABLE_NAME)
|
||||
-- Usage in routing: SELECT knoe.ekosystem_tenant('p_3k9mxqt2f8vn0r7b_accounts')
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.ekosystem_tenant(p_object_name text)
|
||||
RETURNS integer
|
||||
LANGUAGE sql IMMUTABLE STRICT
|
||||
AS $$
|
||||
SELECT tenant_id
|
||||
FROM knoe.ekosystem_decode(substring(p_object_name FROM 3 FOR 16));
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.register_tenant(name) → (tenant_id, uuid, name)
|
||||
--
|
||||
-- Register a new ekosystem tenant. The UUID is minted by the root authority
|
||||
-- (tenant 0, shard 0) and becomes the tenant's stable external identifier —
|
||||
-- used in DNS (db.{uuid}.prole.org) and service-account prefixes.
|
||||
-- Subsequent IDs generated by the tenant embed their assigned tenant_id.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.register_tenant(p_name text)
|
||||
RETURNS TABLE(tenant_id integer, uuid text, name text)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id integer;
|
||||
v_uuid text;
|
||||
BEGIN
|
||||
SELECT COALESCE(MAX(t.tenant_id), 0) + 1
|
||||
INTO v_tenant_id
|
||||
FROM knoe.tenants t;
|
||||
|
||||
IF v_tenant_id > 4095 THEN
|
||||
RAISE EXCEPTION 'Tenant capacity exhausted (max 4096 ekosystems)';
|
||||
END IF;
|
||||
|
||||
v_uuid := knoe.ekosystem_id(0, 0); -- root authority mints the UUID
|
||||
|
||||
INSERT INTO knoe.tenants (tenant_id, uuid, name)
|
||||
VALUES (v_tenant_id, v_uuid, p_name)
|
||||
RETURNING tenants.tenant_id, tenants.uuid, tenants.name;
|
||||
|
||||
RETURN NEXT;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.project_prefix(tenant_id, shard_id) → 'p_{uuid}_'
|
||||
--
|
||||
-- Convenience: generate the full 19-char prefix for a new project namespace.
|
||||
-- Caller appends the table name: prefix || 'accounts'
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.project_prefix(
|
||||
p_tenant_id integer DEFAULT 0,
|
||||
p_shard_id integer DEFAULT 0
|
||||
) RETURNS text
|
||||
LANGUAGE sql
|
||||
AS $$
|
||||
SELECT 'p_' || knoe.ekosystem_id(p_tenant_id, p_shard_id) || '_';
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Grants (mirrors existing knoe schema pattern)
|
||||
-- ---------------------------------------------------------------------------
|
||||
GRANT EXECUTE ON FUNCTION knoe.ekosystem_id(integer, integer) TO knoe;
|
||||
GRANT EXECUTE ON FUNCTION knoe.ekosystem_decode(text) TO knoe;
|
||||
GRANT EXECUTE ON FUNCTION knoe.ekosystem_tenant(text) TO knoe;
|
||||
GRANT EXECUTE ON FUNCTION knoe.register_tenant(text) TO knoe;
|
||||
GRANT EXECUTE ON FUNCTION knoe.project_prefix(integer, integer) TO knoe;
|
||||
GRANT SELECT, INSERT ON TABLE knoe.tenants TO knoe;
|
||||
GRANT USAGE ON SEQUENCE knoe.ekosystem_seq TO knoe;
|
||||
ekosystem_objects.sql: |
|
||||
-- =============================================================================
|
||||
-- knoe.ekosystem_objects — Modular object kind registry, naming, and
|
||||
-- cross-ekosystem sharing / grant model
|
||||
--
|
||||
-- Depends on: ekosystem.sql (knoe.tenants, knoe.ekosystem_decode)
|
||||
--
|
||||
-- Object naming convention:
|
||||
-- {prefix}_{uuid} — standalone objects (project, user, embedding)
|
||||
-- {prefix}_{uuid}_{name} — compound objects (table, index, view)
|
||||
--
|
||||
-- All prefixes are {letter}_ so ekosystem_tenant() works uniformly:
|
||||
-- substring(object_name FROM 3 FOR 16) always extracts the UUID.
|
||||
--
|
||||
-- Cross-ekosystem sharing:
|
||||
-- UUIDs embed the originating tenant_id in bits — ownership is always
|
||||
-- decodable without a registry join, so imported objects cannot collide
|
||||
-- even when mixed across ekosystems. knoe.cross_grants records the
|
||||
-- explicit sharing approvals that LDAP/Samba AD will eventually enforce.
|
||||
-- =============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.object_kinds — Modular kind registry
|
||||
-- Add new kinds here; ekosystem_object() picks them up automatically.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS knoe.object_kinds (
|
||||
kind text PRIMARY KEY,
|
||||
prefix char(1) NOT NULL UNIQUE CHECK (prefix ~ '^[a-z]$'),
|
||||
description text NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO knoe.object_kinds (kind, prefix, description) VALUES
|
||||
('project', 'p', 'Project namespace — schema or resource root'),
|
||||
('user', 'u', 'Service account / PostgreSQL role'),
|
||||
('embedding', 'e', 'Vector embedding store'),
|
||||
('table', 't', 'Data table'),
|
||||
('index', 'x', 'Index or search structure'),
|
||||
('view', 'v', 'Logical view or materialised view'),
|
||||
('sequence', 's', 'Auto-increment sequence'),
|
||||
('function', 'f', 'Stored function or procedure')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
GRANT SELECT ON TABLE knoe.object_kinds TO knoe;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.ekosystem_object(uuid, kind) → '{prefix}_{uuid}'
|
||||
--
|
||||
-- Returns the canonical identifier for any typed object.
|
||||
-- Unknown kind raises — register it in knoe.object_kinds first.
|
||||
--
|
||||
-- Compound names (tables, indexes):
|
||||
-- knoe.ekosystem_object(uuid, 'table') || '_accounts'
|
||||
-- → 't_3k9mxqt2f8vn0r7b_accounts'
|
||||
--
|
||||
-- The 2-char prefix is the same width for every kind, so
|
||||
-- ekosystem_tenant() always finds the UUID at position 3.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.ekosystem_object(p_uuid text, p_kind text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_prefix char(1);
|
||||
BEGIN
|
||||
SELECT prefix INTO v_prefix FROM knoe.object_kinds WHERE kind = p_kind;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION
|
||||
'Unknown object kind: %. Register it in knoe.object_kinds first.', p_kind;
|
||||
END IF;
|
||||
RETURN v_prefix || '_' || p_uuid;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.ekosystem_kind(object_name) → kind
|
||||
--
|
||||
-- Reverse lookup: given any prefixed object name, return its kind.
|
||||
-- Returns NULL if the prefix is unregistered (not an error — enables routing
|
||||
-- filters that skip non-ekosystem objects).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.ekosystem_kind(p_object_name text)
|
||||
RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT kind
|
||||
FROM knoe.object_kinds
|
||||
WHERE prefix = substring(p_object_name FROM 1 FOR 1);
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.cross_grants — Cross-ekosystem sharing policy
|
||||
--
|
||||
-- Records that grantor_tenant has shared an object with grantee_tenant.
|
||||
-- This table is the policy source of truth; actual PG GRANT statements
|
||||
-- and future LDAP/Samba AD group memberships are derived from it.
|
||||
--
|
||||
-- Ownership is always decodable from the UUID itself via ekosystem_decode(),
|
||||
-- so this table only needs to record the *exceptions* (sharing across
|
||||
-- tenant boundaries). Within a single ekosystem, use standard PG GRANT.
|
||||
--
|
||||
-- privileges: PostgreSQL privilege names, e.g. '{SELECT}', '{SELECT,INSERT}'
|
||||
-- expires_at: NULL = permanent until explicitly revoked
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS knoe.cross_grants (
|
||||
id text PRIMARY KEY
|
||||
DEFAULT knoe.ekosystem_id(0, 0),
|
||||
object_uuid text NOT NULL,
|
||||
object_kind text NOT NULL REFERENCES knoe.object_kinds(kind),
|
||||
grantor_tenant smallint NOT NULL REFERENCES knoe.tenants(tenant_id),
|
||||
grantee_tenant smallint NOT NULL REFERENCES knoe.tenants(tenant_id),
|
||||
privileges text[] NOT NULL CHECK (array_length(privileges, 1) > 0),
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
CONSTRAINT cross_grants_no_self
|
||||
CHECK (grantor_tenant != grantee_tenant),
|
||||
CONSTRAINT cross_grants_unique
|
||||
UNIQUE (object_uuid, object_kind, grantee_tenant)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cross_grants_grantee_idx
|
||||
ON knoe.cross_grants (grantee_tenant, object_kind);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cross_grants_object_idx
|
||||
ON knoe.cross_grants (object_uuid);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE ON TABLE knoe.cross_grants TO knoe;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.grant_object(object_uuid, kind, grantee_tenant, privileges, expires_at)
|
||||
--
|
||||
-- Record that the owning tenant shares an object with another ekosystem.
|
||||
-- Validates that the caller's tenant actually owns the object (via UUID bits).
|
||||
-- On conflict: refreshes privileges and expiry (upsert semantics).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.grant_object(
|
||||
p_object_uuid text,
|
||||
p_kind text,
|
||||
p_grantee_tenant smallint,
|
||||
p_privileges text[] DEFAULT '{SELECT}',
|
||||
p_expires_at timestamptz DEFAULT NULL
|
||||
) RETURNS knoe.cross_grants
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_grantor smallint;
|
||||
v_row knoe.cross_grants;
|
||||
BEGIN
|
||||
-- Derive owner from UUID bits — no registry join needed
|
||||
SELECT tenant_id INTO v_grantor FROM knoe.ekosystem_decode(p_object_uuid);
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM knoe.tenants WHERE tenant_id = p_grantee_tenant) THEN
|
||||
RAISE EXCEPTION 'Grantee tenant % is not a registered ekosystem', p_grantee_tenant;
|
||||
END IF;
|
||||
|
||||
IF v_grantor = p_grantee_tenant THEN
|
||||
RAISE EXCEPTION
|
||||
'Grant target is the object owner (tenant %). Use standard PG GRANT within an ekosystem.',
|
||||
v_grantor;
|
||||
END IF;
|
||||
|
||||
INSERT INTO knoe.cross_grants
|
||||
(object_uuid, object_kind, grantor_tenant, grantee_tenant, privileges, expires_at)
|
||||
VALUES
|
||||
(p_object_uuid, p_kind, v_grantor, p_grantee_tenant, p_privileges, p_expires_at)
|
||||
ON CONFLICT (object_uuid, object_kind, grantee_tenant) DO UPDATE
|
||||
SET privileges = EXCLUDED.privileges,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
granted_at = now()
|
||||
RETURNING * INTO v_row;
|
||||
|
||||
RETURN v_row;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.revoke_object(object_uuid, kind, grantee_tenant)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.revoke_object(
|
||||
p_object_uuid text,
|
||||
p_kind text,
|
||||
p_grantee_tenant smallint
|
||||
) RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM knoe.cross_grants
|
||||
WHERE object_uuid = p_object_uuid
|
||||
AND object_kind = p_kind
|
||||
AND grantee_tenant = p_grantee_tenant;
|
||||
RETURN FOUND;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.can_access(object_uuid, kind, tenant_id) → boolean
|
||||
--
|
||||
-- True when:
|
||||
-- (a) the tenant owns the object (tenant_id embedded in UUID), OR
|
||||
-- (b) a non-expired cross-grant exists, OR
|
||||
-- (c) tenant 0 (knoe-db root) — root has universal read access
|
||||
--
|
||||
-- STABLE: safe in WHERE clauses and on replicas.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.can_access(
|
||||
p_object_uuid text,
|
||||
p_kind text,
|
||||
p_tenant_id smallint
|
||||
) RETURNS boolean
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT
|
||||
p_tenant_id = 0 -- root authority
|
||||
OR
|
||||
(SELECT tenant_id
|
||||
FROM knoe.ekosystem_decode(p_object_uuid)) = p_tenant_id -- owner
|
||||
OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM knoe.cross_grants
|
||||
WHERE object_uuid = p_object_uuid
|
||||
AND object_kind = p_kind
|
||||
AND grantee_tenant = p_tenant_id
|
||||
AND (expires_at IS NULL OR expires_at > now())
|
||||
);
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- knoe.my_grants(tenant_id) → active cross-grants visible to this ekosystem
|
||||
-- Includes both grants received and grants issued by this tenant.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION knoe.my_grants(p_tenant_id smallint)
|
||||
RETURNS TABLE(
|
||||
direction text, -- 'received' | 'issued'
|
||||
object_uuid text,
|
||||
object_kind text,
|
||||
object_name text, -- prefixed identifier
|
||||
other_tenant smallint,
|
||||
privileges text[],
|
||||
granted_at timestamptz,
|
||||
expires_at timestamptz
|
||||
)
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT 'received'::text,
|
||||
g.object_uuid,
|
||||
g.object_kind,
|
||||
knoe.ekosystem_object(g.object_uuid, g.object_kind),
|
||||
g.grantor_tenant,
|
||||
g.privileges,
|
||||
g.granted_at,
|
||||
g.expires_at
|
||||
FROM knoe.cross_grants g
|
||||
WHERE g.grantee_tenant = p_tenant_id
|
||||
AND (g.expires_at IS NULL OR g.expires_at > now())
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'issued'::text,
|
||||
g.object_uuid,
|
||||
g.object_kind,
|
||||
knoe.ekosystem_object(g.object_uuid, g.object_kind),
|
||||
g.grantee_tenant,
|
||||
g.privileges,
|
||||
g.granted_at,
|
||||
g.expires_at
|
||||
FROM knoe.cross_grants g
|
||||
WHERE g.grantor_tenant = p_tenant_id
|
||||
AND (g.expires_at IS NULL OR g.expires_at > now());
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Grants (mirrors existing knoe schema pattern)
|
||||
-- ---------------------------------------------------------------------------
|
||||
GRANT EXECUTE ON FUNCTION knoe.ekosystem_object(text, text) TO knoe;
|
||||
GRANT EXECUTE ON FUNCTION knoe.ekosystem_kind(text) TO knoe;
|
||||
GRANT EXECUTE ON FUNCTION knoe.grant_object(text, text, smallint, text[], timestamptz)
|
||||
TO knoe;
|
||||
GRANT EXECUTE ON FUNCTION knoe.revoke_object(text, text, smallint) TO knoe;
|
||||
GRANT EXECUTE ON FUNCTION knoe.can_access(text, text, smallint) TO knoe;
|
||||
GRANT EXECUTE ON FUNCTION knoe.my_grants(smallint) TO knoe;
|
||||
@ -6,6 +6,7 @@ resources:
|
||||
- garage-statefulset.yaml
|
||||
- garage-service.yaml
|
||||
- knoe-db-barman-objectstore.yaml
|
||||
- knoe-ekosystem-sql.yaml
|
||||
- knoe-db.yaml
|
||||
- knoe-db-postgres-service.yaml
|
||||
- knoe-configmap.yaml
|
||||
|
||||
158
knoe/ekosystem.py
Normal file
158
knoe/ekosystem.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""knoe.ekosystem — Python counterpart to knoe.ekosystem_id() in PostgreSQL.
|
||||
|
||||
Bit layout (82 bits → 16 base36 chars):
|
||||
[49: timestamp_ms][12: tenant_id][10: shard_id][11: seq]
|
||||
|
||||
Epoch: 2026-01-01 00:00:00 UTC (1767225600000 ms)
|
||||
|
||||
Produces identical output to knoe.ekosystem_id() for the same inputs and
|
||||
timestamp. Use this when a PostgreSQL connection is unavailable (e.g. from
|
||||
ClickHouse or SQLite application nodes).
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
EPOCH_MS = 1767225600000 # 2026-01-01 00:00:00 UTC
|
||||
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
# Bit widths
|
||||
_TS_BITS = 49
|
||||
_TENANT_BITS = 12
|
||||
_SHARD_BITS = 10
|
||||
_SEQ_BITS = 11
|
||||
_TOTAL_BITS = _TS_BITS + _TENANT_BITS + _SHARD_BITS + _SEQ_BITS # 82
|
||||
|
||||
# Shift offsets
|
||||
_SEQ_SHIFT = 0
|
||||
_SHARD_SHIFT = _SEQ_BITS
|
||||
_TENANT_SHIFT = _SEQ_BITS + _SHARD_BITS
|
||||
_TS_SHIFT = _SEQ_BITS + _SHARD_BITS + _TENANT_BITS
|
||||
|
||||
# Masks
|
||||
_SEQ_MASK = (1 << _SEQ_BITS) - 1
|
||||
_SHARD_MASK = (1 << _SHARD_BITS) - 1
|
||||
_TENANT_MASK = (1 << _TENANT_BITS) - 1
|
||||
_TS_MASK = (1 << _TS_BITS) - 1
|
||||
|
||||
_BASE = len(ALPHABET)
|
||||
_UUID_LEN = 16 # ceil(82 / log2(36)) = 16
|
||||
|
||||
|
||||
def _to_base36(n: int) -> str:
|
||||
"""Encode integer n as a zero-padded 16-character base36 string."""
|
||||
chars = []
|
||||
for _ in range(_UUID_LEN):
|
||||
chars.append(ALPHABET[n % _BASE])
|
||||
n //= _BASE
|
||||
return "".join(reversed(chars))
|
||||
|
||||
|
||||
def _from_base36(s: str) -> int:
|
||||
"""Decode a base36 string to an integer."""
|
||||
n = 0
|
||||
for ch in s:
|
||||
n = n * _BASE + ALPHABET.index(ch)
|
||||
return n
|
||||
|
||||
|
||||
class EkosystemID:
|
||||
"""Thread-safe ekosystem UUID generator (Snowflake pattern).
|
||||
|
||||
Args:
|
||||
tenant_id: Integer tenant identifier (0 = knoe-db root, 1 = prole, …)
|
||||
shard_id: Integer shard identifier within the tenant (usually 0)
|
||||
"""
|
||||
|
||||
def __init__(self, tenant_id: int, shard_id: int) -> None:
|
||||
if not (0 <= tenant_id < (1 << _TENANT_BITS)):
|
||||
raise ValueError(f"tenant_id must be 0–{(1 << _TENANT_BITS) - 1}")
|
||||
if not (0 <= shard_id < (1 << _SHARD_BITS)):
|
||||
raise ValueError(f"shard_id must be 0–{(1 << _SHARD_BITS) - 1}")
|
||||
self.tenant_id = tenant_id
|
||||
self.shard_id = shard_id
|
||||
self._lock = threading.Lock()
|
||||
self._last_ms: int = -1
|
||||
self._seq: int = 0
|
||||
|
||||
def generate(self) -> str:
|
||||
"""Generate a new ekosystem UUID string (thread-safe)."""
|
||||
with self._lock:
|
||||
now_ms = int(time.time() * 1000) - EPOCH_MS
|
||||
if now_ms < 0:
|
||||
raise RuntimeError("System clock is before the ekosystem epoch (2026-01-01)")
|
||||
if now_ms == self._last_ms:
|
||||
self._seq = (self._seq + 1) & _SEQ_MASK
|
||||
if self._seq == 0:
|
||||
# Sequence exhausted — spin until next millisecond
|
||||
while now_ms == self._last_ms:
|
||||
now_ms = int(time.time() * 1000) - EPOCH_MS
|
||||
else:
|
||||
self._seq = 0
|
||||
self._last_ms = now_ms
|
||||
|
||||
bits = (
|
||||
((now_ms & _TS_MASK) << _TS_SHIFT)
|
||||
| ((self.tenant_id & _TENANT_MASK) << _TENANT_SHIFT)
|
||||
| ((self.shard_id & _SHARD_MASK) << _SHARD_SHIFT)
|
||||
| (self._seq & _SEQ_MASK)
|
||||
)
|
||||
return _to_base36(bits)
|
||||
|
||||
def project_prefix(self) -> str:
|
||||
"""Return a project namespace prefix: 'p_{uuid}_'."""
|
||||
return f"p_{self.generate()}_"
|
||||
|
||||
def object_name(self, kind: str) -> str:
|
||||
"""Return a namespaced object name: '{kind}_{uuid}'."""
|
||||
return f"{kind}_{self.generate()}"
|
||||
|
||||
|
||||
def decode(uid: str) -> dict:
|
||||
"""Decode an ekosystem UUID string into its component fields.
|
||||
|
||||
Returns:
|
||||
dict with keys: tenant_id, shard_id, ts_ms, seq, issued_at (UTC datetime)
|
||||
"""
|
||||
if len(uid) != _UUID_LEN:
|
||||
raise ValueError(f"ekosystem UUID must be {_UUID_LEN} characters, got {len(uid)}")
|
||||
bits = _from_base36(uid)
|
||||
ts_ms = (bits >> _TS_SHIFT) & _TS_MASK
|
||||
tenant_id = (bits >> _TENANT_SHIFT) & _TENANT_MASK
|
||||
shard_id = (bits >> _SHARD_SHIFT) & _SHARD_MASK
|
||||
seq = bits & _SEQ_MASK
|
||||
issued_at = datetime.fromtimestamp((ts_ms + EPOCH_MS) / 1000.0, tz=timezone.utc)
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"shard_id": shard_id,
|
||||
"ts_ms": ts_ms,
|
||||
"seq": seq,
|
||||
"issued_at": issued_at,
|
||||
}
|
||||
|
||||
|
||||
def can_access(uid: str, kind: str, tenant_id: int, grants: list) -> bool:
|
||||
"""Check whether tenant_id can access object uid of the given kind.
|
||||
|
||||
Args:
|
||||
uid: ekosystem UUID of the object
|
||||
kind: object kind string (e.g. 'table', 'embedding')
|
||||
tenant_id: the requesting tenant's integer ID
|
||||
grants: list of grant dicts with keys 'object_uuid', 'kind', 'grantee_tenant_id'
|
||||
|
||||
Returns:
|
||||
True if the object's own tenant matches tenant_id, or a matching grant exists.
|
||||
"""
|
||||
info = decode(uid)
|
||||
if info["tenant_id"] == tenant_id:
|
||||
return True
|
||||
for g in grants:
|
||||
if (
|
||||
g.get("object_uuid") == uid
|
||||
and g.get("kind") == kind
|
||||
and g.get("grantee_tenant_id") == tenant_id
|
||||
):
|
||||
return True
|
||||
return False
|
||||
37
scripts/gen-ekosystem-configmap.py
Normal file
37
scripts/gen-ekosystem-configmap.py
Normal file
@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate k8s/knoe/knoe-ekosystem-sql.yaml from knoe-db/schema/ekosystem*.sql.
|
||||
|
||||
Run via: make k8s/knoe/knoe-ekosystem-sql.yaml
|
||||
"""
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SCHEMA_DIR = os.path.join(ROOT, "knoe-db", "schema")
|
||||
OUT = os.path.join(ROOT, "k8s", "knoe", "knoe-ekosystem-sql.yaml")
|
||||
|
||||
SQL_FILES = ["ekosystem.sql", "ekosystem_objects.sql"]
|
||||
|
||||
|
||||
def indent(text, spaces=4):
|
||||
pad = " " * spaces
|
||||
return "\n".join((pad + line) if line else "" for line in text.splitlines())
|
||||
|
||||
|
||||
lines = [
|
||||
"apiVersion: v1",
|
||||
"kind: ConfigMap",
|
||||
"metadata:",
|
||||
" name: knoe-ekosystem-sql",
|
||||
" namespace: knoe-db",
|
||||
"data:",
|
||||
]
|
||||
|
||||
for fname in SQL_FILES:
|
||||
sql = open(os.path.join(SCHEMA_DIR, fname)).read()
|
||||
lines.append(f" {fname}: |")
|
||||
lines.append(indent(sql, 4))
|
||||
|
||||
with open(OUT, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
print(f"Wrote {OUT} ({os.path.getsize(OUT)} bytes)")
|
||||
197
tests/test_ekosystem.py
Normal file
197
tests/test_ekosystem.py
Normal file
@ -0,0 +1,197 @@
|
||||
"""Tests for knoe.ekosystem — Python ekosystem UUID utility."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from knoe.ekosystem import (
|
||||
ALPHABET,
|
||||
EPOCH_MS,
|
||||
EkosystemID,
|
||||
_from_base36,
|
||||
_to_base36,
|
||||
can_access,
|
||||
decode,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base36 encoding round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_base36_roundtrip():
|
||||
for n in [0, 1, 35, 36, 1000, 2**82 - 1]:
|
||||
assert _from_base36(_to_base36(n)) == n
|
||||
|
||||
|
||||
def test_base36_length():
|
||||
assert len(_to_base36(0)) == 16
|
||||
assert len(_to_base36(2**82 - 1)) == 16
|
||||
|
||||
|
||||
def test_base36_alphabet():
|
||||
assert len(ALPHABET) == 36
|
||||
assert ALPHABET == "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EkosystemID generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_returns_16_chars():
|
||||
uid = EkosystemID(1, 0).generate()
|
||||
assert len(uid) == 16
|
||||
|
||||
|
||||
def test_generate_only_base36_chars():
|
||||
uid = EkosystemID(0, 0).generate()
|
||||
assert all(c in ALPHABET for c in uid)
|
||||
|
||||
|
||||
def test_round_trip_tenant_id():
|
||||
"""Spec-mandated round-trip test."""
|
||||
assert decode(EkosystemID(1, 0).generate())["tenant_id"] == 1
|
||||
|
||||
|
||||
def test_round_trip_shard_id():
|
||||
assert decode(EkosystemID(0, 5).generate())["shard_id"] == 5
|
||||
|
||||
|
||||
def test_round_trip_tenant_and_shard():
|
||||
info = decode(EkosystemID(3, 7).generate())
|
||||
assert info["tenant_id"] == 3
|
||||
assert info["shard_id"] == 7
|
||||
|
||||
|
||||
def test_issued_at_is_recent():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
uid = EkosystemID(1, 0).generate()
|
||||
info = decode(uid)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
delta = abs((now - info["issued_at"]).total_seconds())
|
||||
assert delta < 5, f"issued_at too far from now: {delta}s"
|
||||
|
||||
|
||||
def test_sequential_uids_are_unique():
|
||||
gen = EkosystemID(1, 0)
|
||||
uids = [gen.generate() for _ in range(100)]
|
||||
assert len(set(uids)) == 100
|
||||
|
||||
|
||||
def test_invalid_tenant_id_raises():
|
||||
with pytest.raises(ValueError):
|
||||
EkosystemID(4096, 0) # 2**12
|
||||
|
||||
|
||||
def test_invalid_shard_id_raises():
|
||||
with pytest.raises(ValueError):
|
||||
EkosystemID(0, 1024) # 2**10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# project_prefix / object_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_project_prefix_format():
|
||||
prefix = EkosystemID(1, 0).project_prefix()
|
||||
assert prefix.startswith("p_")
|
||||
assert prefix.endswith("_")
|
||||
assert len(prefix) == 2 + 16 + 1 # 'p_' + uuid + '_'
|
||||
|
||||
|
||||
def test_object_name_format():
|
||||
name = EkosystemID(1, 0).object_name("t")
|
||||
assert name.startswith("t_")
|
||||
assert len(name) == 2 + 16
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# decode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_decode_wrong_length_raises():
|
||||
with pytest.raises(ValueError):
|
||||
decode("tooshort")
|
||||
|
||||
|
||||
def test_decode_seq_zero_on_first_call():
|
||||
uid = EkosystemID(1, 0).generate()
|
||||
info = decode(uid)
|
||||
# seq may be 0 on first call within a millisecond
|
||||
assert 0 <= info["seq"] < 2**11
|
||||
|
||||
|
||||
def test_decode_ts_ms_positive():
|
||||
uid = EkosystemID(1, 0).generate()
|
||||
assert decode(uid)["ts_ms"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_thread_safety_no_duplicates():
|
||||
gen = EkosystemID(1, 0)
|
||||
results = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
uid = gen.generate()
|
||||
with lock:
|
||||
results.append(uid)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(200)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(results) == 200
|
||||
assert len(set(results)) == 200, "Duplicate UUIDs generated across threads"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# can_access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_can_access_own_tenant():
|
||||
uid = EkosystemID(1, 0).generate()
|
||||
assert can_access(uid, "table", 1, [])
|
||||
|
||||
|
||||
def test_can_access_other_tenant_no_grant():
|
||||
uid = EkosystemID(1, 0).generate()
|
||||
assert not can_access(uid, "table", 2, [])
|
||||
|
||||
|
||||
def test_can_access_with_grant():
|
||||
uid = EkosystemID(1, 0).generate()
|
||||
grants = [{"object_uuid": uid, "kind": "table", "grantee_tenant_id": 2}]
|
||||
assert can_access(uid, "table", 2, grants)
|
||||
|
||||
|
||||
def test_can_access_grant_wrong_kind():
|
||||
uid = EkosystemID(1, 0).generate()
|
||||
grants = [{"object_uuid": uid, "kind": "embedding", "grantee_tenant_id": 2}]
|
||||
assert not can_access(uid, "table", 2, grants)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EPOCH constant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_epoch_ms_value():
|
||||
"""2026-01-01 00:00:00 UTC in milliseconds."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
epoch_dt = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
assert EPOCH_MS == int(epoch_dt.timestamp() * 1000)
|
||||
Loading…
Reference in New Issue
Block a user