The web UI now validates the MCIAS token after login and rejects accounts with the guest role before setting the session cookie. This is defense-in-depth alongside the env:restricted MCIAS tag. The webserver.New() constructor takes a new ValidateFunc parameter that inspects token roles post-authentication. MCIAS login does not return roles, so this requires an extra ValidateToken round-trip at login time (result is cached for 30s). Security: guest role accounts are denied web UI access Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1099 lines
41 KiB
Markdown
1099 lines
41 KiB
Markdown
# MCR Architecture
|
|
|
|
Metacircular Container Registry — Technical Design Document
|
|
|
|
---
|
|
|
|
## 1. System Overview
|
|
|
|
MCR is an OCI Distribution Spec-compliant container registry for Metacircular
|
|
Dynamics. It stores and serves container images for the platform's services,
|
|
with MCP directing nodes to pull images from MCR. Authentication is delegated
|
|
to MCIAS; all operations require a valid bearer token. MCR sits behind an
|
|
mc-proxy instance for TLS routing.
|
|
|
|
### Components
|
|
|
|
```
|
|
┌──────────────────────────────────────────────┐
|
|
│ MCR Server (mcrsrv) │
|
|
│ │
|
|
│ ┌────────────┐ ┌──────────┐ ┌──────────┐ │
|
|
│ │ OCI API │ │ Auth │ │ Policy │ │
|
|
│ │ Handler │ │ (MCIAS) │ │ Engine │ │
|
|
│ └─────┬──────┘ └────┬─────┘ └────┬─────┘ │
|
|
│ └──────────────┼─────────────┘ │
|
|
│ │ │
|
|
│ ┌─────────────▼────────────┐ │
|
|
│ │ SQLite (metadata) │ │
|
|
│ └──────────────────────────┘ │
|
|
│ ┌─────────────────────────┐ │
|
|
│ │ Filesystem (blobs) │ │
|
|
│ │ /srv/mcr/layers/ │ │
|
|
│ └──────────────────────────┘ │
|
|
│ │
|
|
│ ┌─────────────────┐ ┌──────────────────┐ │
|
|
│ │ REST listener │ │ gRPC listener │ │
|
|
│ │ (OCI + admin) │ │ (admin) │ │
|
|
│ │ :8443 │ │ :9443 │ │
|
|
│ └─────────────────┘ └──────────────────┘ │
|
|
└──────────────────────────────────────────────┘
|
|
▲ ▲ ▲
|
|
│ │ │
|
|
┌────┴───┐ ┌─────┴─────┐ ┌───┴──────┐
|
|
│ Docker │ │ mcrctl │ │ mcr-web │
|
|
│ / OCI │ │ (admin │ │ (web UI) │
|
|
│ client │ │ CLI) │ │ │
|
|
└────────┘ └───────────┘ └──────────┘
|
|
```
|
|
|
|
**mcrsrv** — The registry server. Exposes OCI Distribution endpoints and
|
|
an admin REST API over HTTPS/TLS, plus a gRPC admin API. Handles blob
|
|
storage, manifest management, and token-based authentication via MCIAS.
|
|
|
|
**mcr-web** — The web UI. Communicates with mcrsrv via gRPC. Provides
|
|
repository/tag browsing and ACL policy management for administrators.
|
|
|
|
**mcrctl** — The admin CLI. Communicates with mcrsrv via REST or gRPC.
|
|
Provides garbage collection, repository management, and policy management.
|
|
|
|
---
|
|
|
|
## 2. OCI Distribution Spec Compliance
|
|
|
|
MCR implements the OCI Distribution Specification for content discovery and
|
|
content management. All OCI endpoints require authentication — there is no
|
|
anonymous access.
|
|
|
|
### Supported Operations
|
|
|
|
| Category | Capability |
|
|
|----------|-----------|
|
|
| Content discovery | Repository catalog, tag listing |
|
|
| Pull | Manifest retrieval (by tag or digest), blob download |
|
|
| Push | Monolithic and chunked blob upload, manifest upload |
|
|
| Delete | Manifest deletion (by digest), blob deletion |
|
|
|
|
### Not Supported (v1)
|
|
|
|
| Feature | Rationale |
|
|
|---------|-----------|
|
|
| Multi-arch manifest lists | Not needed for single-platform deployment |
|
|
| Image signing / content trust | Deferred to future work |
|
|
| Cross-repository blob mounts | Complexity not justified at current scale |
|
|
|
|
### Content Addressing
|
|
|
|
All blobs and manifests are identified by their SHA-256 digest in the format
|
|
`sha256:<hex>`. Digests are verified on upload — if the computed digest does
|
|
not match the client-supplied digest, the upload is rejected.
|
|
|
|
Tags are mutable pointers to manifest digests. Pushing a manifest with an
|
|
existing tag atomically updates the tag to point to the new digest.
|
|
|
|
---
|
|
|
|
## 3. Authentication
|
|
|
|
MCR delegates all authentication to MCIAS. No local user database.
|
|
|
|
### MCIAS Configuration
|
|
|
|
MCR registers with MCIAS as a service with the `env:restricted` tag. This
|
|
means MCIAS denies login to `guest` and `viewer` accounts — only `admin`
|
|
and `user` roles can authenticate to MCR.
|
|
|
|
```toml
|
|
[mcias]
|
|
server_url = "https://mcias.metacircular.net:8443"
|
|
service_name = "mcr"
|
|
tags = ["env:restricted"]
|
|
```
|
|
|
|
### OCI Token Authentication
|
|
|
|
OCI/Docker clients expect a specific auth handshake:
|
|
|
|
```
|
|
Client mcrsrv
|
|
│ │
|
|
├─ GET /v2/ ────────────────────▶│
|
|
│◀─ 401 WWW-Authenticate: │
|
|
│ Bearer realm="/v2/token", │
|
|
│ service="mcr.metacircular.…" │
|
|
│ │
|
|
├─ GET /v2/token ───────────────▶│ (Basic auth: username:password)
|
|
│ ├─ Forward credentials to MCIAS
|
|
│ │ POST /v1/auth/login
|
|
│ ├─ MCIAS returns JWT
|
|
│◀─ 200 {"token":"<jwt>", │
|
|
│ "expires_in": N} │
|
|
│ │
|
|
├─ GET /v2/<name>/manifests/… ──▶│ (Authorization: Bearer <jwt>)
|
|
│ ├─ Validate token via MCIAS
|
|
│ ├─ Check policy engine
|
|
│◀─ 200 (manifest) │
|
|
```
|
|
|
|
**Token endpoint** (`GET /v2/token`): Accepts HTTP Basic auth, forwards
|
|
credentials to MCIAS `/v1/auth/login`, and returns the MCIAS JWT in the
|
|
Docker-compatible token response format. The `scope` parameter is accepted
|
|
but not used for token scoping — authorization is enforced per-request by
|
|
the policy engine.
|
|
|
|
**Direct bearer tokens**: MCIAS service tokens and pre-authenticated JWTs
|
|
are accepted directly on all OCI endpoints via the `Authorization: Bearer`
|
|
header. This allows system accounts and CLI tools to skip the token endpoint.
|
|
|
|
**Token validation**: Every request validates the bearer token by calling
|
|
MCIAS `ValidateToken()`. Results are cached by SHA-256 of the token with a
|
|
30-second TTL.
|
|
|
|
---
|
|
|
|
## 4. Authorization & Policy Engine
|
|
|
|
### Role Model
|
|
|
|
| Role | Access |
|
|
|------|--------|
|
|
| `admin` | Full access: push, pull, delete, catalog, policy management, GC |
|
|
| `user` | Full content access: push, pull, delete, catalog |
|
|
| System account | Default deny; requires explicit policy rule for any operation |
|
|
|
|
Admin detection is based solely on the MCIAS `admin` role. Human users with
|
|
the `user` role have full content management access. System accounts have no
|
|
implicit access and must be granted specific permissions via policy rules.
|
|
|
|
### Policy Engine
|
|
|
|
MCR implements a local policy engine for registry-specific access control,
|
|
following the same architecture as MCIAS's policy engine (priority-based,
|
|
deny-wins, default-deny). The engine is an in-process Go package
|
|
(`internal/policy`) with no external dependencies.
|
|
|
|
#### Actions
|
|
|
|
| Action | Description |
|
|
|--------|-------------|
|
|
| `registry:version_check` | OCI version check (`GET /v2/`) |
|
|
| `registry:pull` | Read manifests, download blobs, list tags for a repository |
|
|
| `registry:push` | Upload blobs and push manifests/tags |
|
|
| `registry:delete` | Delete manifests and blobs |
|
|
| `registry:catalog` | List all repositories (`GET /v2/_catalog`) |
|
|
| `policy:manage` | Create, update, delete policy rules (admin only) |
|
|
|
|
#### Policy Input
|
|
|
|
```go
|
|
type PolicyInput struct {
|
|
Subject string // MCIAS account UUID
|
|
AccountType string // "human" or "system"
|
|
Roles []string // roles from MCIAS JWT
|
|
|
|
Action Action
|
|
Repository string // target repository name (e.g., "myapp");
|
|
// empty for global operations (catalog, health)
|
|
}
|
|
```
|
|
|
|
#### Rule Structure
|
|
|
|
```go
|
|
type Rule struct {
|
|
ID int64
|
|
Priority int // lower = evaluated first
|
|
Description string
|
|
Effect Effect // "allow" or "deny"
|
|
|
|
// Principal conditions (all populated fields ANDed)
|
|
Roles []string // principal must hold at least one
|
|
AccountTypes []string // "human", "system", or both
|
|
SubjectUUID string // exact principal UUID
|
|
|
|
// Action condition
|
|
Actions []Action
|
|
|
|
// Resource condition
|
|
Repositories []string // repository name patterns (glob via path.Match).
|
|
// Examples:
|
|
// "myapp" — exact match
|
|
// "production/*" — any repo one level under production/
|
|
// Empty list = wildcard (matches all repositories).
|
|
}
|
|
```
|
|
|
|
#### Evaluation Algorithm
|
|
|
|
```
|
|
1. Merge built-in defaults with operator-defined rules
|
|
2. Sort by Priority ascending (stable)
|
|
3. Collect all matching rules
|
|
4. If any match has Effect=Deny → return Deny (deny-wins)
|
|
5. If any match has Effect=Allow → return Allow
|
|
6. Return Deny (default-deny)
|
|
```
|
|
|
|
A rule matches when every populated field satisfies its condition:
|
|
|
|
| Field | Match condition |
|
|
|-------|----------------|
|
|
| `Roles` | Principal holds at least one of the listed roles |
|
|
| `AccountTypes` | Principal's account type is in the list |
|
|
| `SubjectUUID` | Principal UUID equals exactly |
|
|
| `Actions` | Request action is in the list |
|
|
| `Repositories` | Request repository matches at least one pattern; empty list is a wildcard (matches all repositories) |
|
|
|
|
Repository glob matching uses `path.Match` semantics: `*` matches any
|
|
sequence of non-`/` characters within a single path segment. For example,
|
|
`production/*` matches `production/myapp` but not `production/team/myapp`.
|
|
An empty `Repositories` field is a wildcard (matches all repositories).
|
|
|
|
When `PolicyInput.Repository` is empty (global operations like catalog),
|
|
only rules with an empty `Repositories` field match — a rule scoped to
|
|
specific repositories does not apply to global operations.
|
|
|
|
#### Built-in Default Rules
|
|
|
|
```
|
|
Priority 0, Allow: roles=[admin], actions=<all>
|
|
— admin wildcard
|
|
|
|
Priority 0, Allow: roles=[user], accountTypes=[human],
|
|
actions=[registry:pull, registry:push, registry:delete, registry:catalog]
|
|
— human users have full content access
|
|
|
|
Priority 0, Allow: actions=[registry:version_check]
|
|
— /v2/ endpoint (always accessible to authenticated users)
|
|
```
|
|
|
|
System accounts have no built-in allow rules for catalog, push, pull, or
|
|
delete. An operator must create explicit policy rules granting them access.
|
|
|
|
#### Example: CI System Push Access (Glob)
|
|
|
|
Grant a CI system account permission to push and pull from all repositories
|
|
under the `production/` namespace:
|
|
|
|
```json
|
|
{
|
|
"effect": "allow",
|
|
"account_types": ["system"],
|
|
"subject_uuid": "<ci-account-uuid>",
|
|
"actions": ["registry:push", "registry:pull"],
|
|
"repositories": ["production/*"],
|
|
"priority": 50,
|
|
"description": "CI system: push/pull to all production repos"
|
|
}
|
|
```
|
|
|
|
#### Example: Deploy Agent Pull-Only
|
|
|
|
Grant a deploy agent pull access to all repositories (empty `repositories`
|
|
= wildcard), but deny delete globally:
|
|
|
|
```json
|
|
{
|
|
"effect": "allow",
|
|
"subject_uuid": "<deploy-agent-uuid>",
|
|
"actions": ["registry:pull"],
|
|
"repositories": [],
|
|
"priority": 50,
|
|
"description": "deploy-agent: pull from any repo"
|
|
}
|
|
```
|
|
|
|
```json
|
|
{
|
|
"effect": "deny",
|
|
"subject_uuid": "<deploy-agent-uuid>",
|
|
"actions": ["registry:delete"],
|
|
"priority": 10,
|
|
"description": "deploy-agent may never delete images (deny-wins)"
|
|
}
|
|
```
|
|
|
|
#### Example: Exact Repo Access
|
|
|
|
Grant a specific system account access to exactly two named repositories:
|
|
|
|
```json
|
|
{
|
|
"effect": "allow",
|
|
"subject_uuid": "<svc-account-uuid>",
|
|
"actions": ["registry:push", "registry:pull"],
|
|
"repositories": ["myapp", "infra-proxy"],
|
|
"priority": 50,
|
|
"description": "svc-account: push/pull to myapp and infra-proxy only"
|
|
}
|
|
```
|
|
|
|
### Policy Management
|
|
|
|
Policy rules are managed via the admin REST/gRPC API and the web UI. Only
|
|
users with the `admin` role can create, update, or delete policy rules.
|
|
|
|
---
|
|
|
|
## 5. Storage Design
|
|
|
|
MCR uses a split storage model: SQLite for metadata, filesystem for blobs.
|
|
|
|
### Blob Storage
|
|
|
|
Blobs (image layers and config objects) are stored as content-addressed files
|
|
under `/srv/mcr/layers/`:
|
|
|
|
```
|
|
/srv/mcr/layers/
|
|
└── sha256/
|
|
├── ab/
|
|
│ └── abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
|
|
├── cd/
|
|
│ └── cdef...
|
|
└── ef/
|
|
└── ef01...
|
|
```
|
|
|
|
The two-character hex prefix directory limits the number of files per
|
|
directory. Blobs are written atomically: data is written to a temporary file
|
|
in `/srv/mcr/uploads/`, then renamed into place after digest verification.
|
|
The `uploads/` and `layers/` directories must reside on the same filesystem
|
|
for `rename(2)` to be atomic.
|
|
|
|
### Upload Staging
|
|
|
|
In-progress blob uploads are stored in `/srv/mcr/uploads/`:
|
|
|
|
```
|
|
/srv/mcr/uploads/
|
|
└── <upload-uuid>
|
|
```
|
|
|
|
Each upload UUID corresponds to a row in the `uploads` table tracking the
|
|
current byte offset. Completed uploads are renamed to the blob store;
|
|
cancelled or expired uploads are cleaned up.
|
|
|
|
### Manifest Storage
|
|
|
|
Manifests are small JSON documents stored directly in the SQLite database
|
|
(in the `manifests` table `content` column). This simplifies metadata queries
|
|
and avoids filesystem overhead for small objects.
|
|
|
|
### Manifest Push Flow
|
|
|
|
When a client calls `PUT /v2/<name>/manifests/<reference>`:
|
|
|
|
```
|
|
1. Parse the manifest JSON. Reject if malformed or unsupported media type.
|
|
2. Compute the SHA-256 digest of the raw manifest bytes.
|
|
3. If <reference> is a digest, verify it matches the computed digest.
|
|
Reject with DIGEST_INVALID if mismatch.
|
|
4. Parse the manifest's layer and config descriptors.
|
|
5. Verify every referenced blob exists in the `blobs` table.
|
|
Reject with MANIFEST_BLOB_UNKNOWN if any are missing.
|
|
6. Begin write transaction:
|
|
a. Create the repository row if it does not exist (implicit creation).
|
|
b. Insert or update the manifest row (repository_id, digest, content).
|
|
c. Populate `manifest_blobs` join table for all referenced blobs.
|
|
d. If <reference> is a tag name, insert or update the tag row
|
|
to point to the new manifest (atomic tag move).
|
|
7. Commit.
|
|
8. Return 201 Created with `Docker-Content-Digest: <digest>` header.
|
|
```
|
|
|
|
This is the most complex write path in the system. The entire operation
|
|
executes in a single SQLite transaction so a crash at any point leaves the
|
|
database consistent.
|
|
|
|
### Data Directory
|
|
|
|
```
|
|
/srv/mcr/
|
|
├── mcr.toml Configuration
|
|
├── mcr.db SQLite database (metadata)
|
|
├── certs/ TLS certificates
|
|
├── layers/ Content-addressed blob storage
|
|
│ └── sha256/
|
|
├── uploads/ In-progress blob uploads
|
|
└── backups/ Database snapshots
|
|
```
|
|
|
|
---
|
|
|
|
## 6. API Surface
|
|
|
|
### Error Response Formats
|
|
|
|
OCI and admin endpoints use different error formats:
|
|
|
|
**OCI endpoints** (`/v2/...`) follow the OCI Distribution Spec error format:
|
|
```json
|
|
{"errors": [{"code": "MANIFEST_UNKNOWN", "message": "...", "detail": "..."}]}
|
|
```
|
|
|
|
Standard OCI error codes used by MCR:
|
|
|
|
| Code | HTTP Status | Trigger |
|
|
|------|-------------|---------|
|
|
| `UNAUTHORIZED` | 401 | Missing or invalid bearer token |
|
|
| `DENIED` | 403 | Policy engine denied the request |
|
|
| `NAME_UNKNOWN` | 404 | Repository does not exist |
|
|
| `MANIFEST_UNKNOWN` | 404 | Manifest not found (by tag or digest) |
|
|
| `BLOB_UNKNOWN` | 404 | Blob not found |
|
|
| `MANIFEST_BLOB_UNKNOWN` | 400 | Manifest references a blob not yet uploaded |
|
|
| `DIGEST_INVALID` | 400 | Computed digest does not match supplied digest |
|
|
| `MANIFEST_INVALID` | 400 | Malformed or unsupported manifest |
|
|
| `BLOB_UPLOAD_UNKNOWN` | 404 | Upload UUID not found or expired |
|
|
| `BLOB_UPLOAD_INVALID` | 400 | Chunked upload byte range error |
|
|
| `UNSUPPORTED` | 405 | Operation not supported (e.g., cross-repo mount) |
|
|
|
|
**Admin endpoints** (`/v1/...`) use the platform-standard format:
|
|
```json
|
|
{"error": "human-readable message"}
|
|
```
|
|
|
|
### OCI Distribution Endpoints
|
|
|
|
All OCI endpoints are prefixed with `/v2` and require authentication.
|
|
|
|
#### Version Check
|
|
|
|
| Method | Path | Description |
|
|
|--------|------|-------------|
|
|
| GET | `/v2/` | API version check; returns `{}` if authenticated |
|
|
|
|
#### Token
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/v2/token` | Basic | Exchange credentials for bearer token via MCIAS |
|
|
|
|
#### Content Discovery
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/v2/_catalog` | bearer | List repositories (paginated) |
|
|
| GET | `/v2/<name>/tags/list` | bearer | List tags for a repository (paginated) |
|
|
|
|
#### Manifests
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| HEAD | `/v2/<name>/manifests/<reference>` | bearer | Check manifest existence |
|
|
| GET | `/v2/<name>/manifests/<reference>` | bearer | Pull manifest by tag or digest |
|
|
| PUT | `/v2/<name>/manifests/<reference>` | bearer | Push manifest |
|
|
| DELETE | `/v2/<name>/manifests/<digest>` | bearer | Delete manifest (digest only) |
|
|
|
|
`<reference>` is either a tag name or a `sha256:...` digest.
|
|
|
|
All manifest responses (GET, HEAD, PUT) include the `Docker-Content-Digest`
|
|
header with the manifest's SHA-256 digest and a `Content-Type` header with
|
|
the manifest's media type.
|
|
|
|
#### Blobs
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| HEAD | `/v2/<name>/blobs/<digest>` | bearer | Check blob existence |
|
|
| GET | `/v2/<name>/blobs/<digest>` | bearer | Download blob |
|
|
| DELETE | `/v2/<name>/blobs/<digest>` | bearer | Delete blob |
|
|
|
|
#### Blob Uploads
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| POST | `/v2/<name>/blobs/uploads/` | bearer | Initiate blob upload |
|
|
| GET | `/v2/<name>/blobs/uploads/<uuid>` | bearer | Check upload progress |
|
|
| PATCH | `/v2/<name>/blobs/uploads/<uuid>` | bearer | Upload chunk (chunked flow) |
|
|
| PUT | `/v2/<name>/blobs/uploads/<uuid>?digest=<digest>` | bearer | Complete upload with digest verification |
|
|
| DELETE | `/v2/<name>/blobs/uploads/<uuid>` | bearer | Cancel upload |
|
|
|
|
**Monolithic upload**: Client sends `POST` to initiate, then `PUT` with the
|
|
entire blob body and `digest` query parameter in a single request.
|
|
|
|
**Chunked upload**: Client sends `POST` to initiate, then one or more `PATCH`
|
|
requests with sequential byte ranges, then `PUT` with the final digest.
|
|
|
|
### Admin REST Endpoints
|
|
|
|
Admin endpoints use the `/v1` prefix and follow the platform API conventions.
|
|
|
|
#### Authentication
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| POST | `/v1/auth/login` | none | Login via MCIAS (username/password) |
|
|
| POST | `/v1/auth/logout` | bearer | Revoke current token |
|
|
|
|
#### Health
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/v1/health` | none | Health check |
|
|
|
|
#### Repository Management
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/v1/repositories` | bearer | List repositories with metadata |
|
|
| GET | `/v1/repositories/{name}` | bearer | Repository detail (tags, size, manifest count) |
|
|
| DELETE | `/v1/repositories/{name}` | admin | Delete repository and all its manifests/tags |
|
|
|
|
Repository `{name}` may contain `/` (e.g., `production/myapp`). The chi
|
|
router must use a wildcard catch-all segment for this parameter.
|
|
|
|
#### Garbage Collection
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| POST | `/v1/gc` | admin | Trigger garbage collection (async) |
|
|
| GET | `/v1/gc/status` | admin | Check GC status (running, last result) |
|
|
|
|
#### Policy Management
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/v1/policy/rules` | admin | List all policy rules |
|
|
| POST | `/v1/policy/rules` | admin | Create a policy rule |
|
|
| GET | `/v1/policy/rules/{id}` | admin | Get a single rule |
|
|
| PATCH | `/v1/policy/rules/{id}` | admin | Update rule |
|
|
| DELETE | `/v1/policy/rules/{id}` | admin | Delete rule |
|
|
|
|
#### Audit
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/v1/audit` | admin | List audit log events |
|
|
|
|
---
|
|
|
|
## 7. gRPC Admin Interface
|
|
|
|
The gRPC API provides the same admin capabilities as the REST admin API. OCI
|
|
Distribution endpoints are REST-only (protocol requirement — OCI clients
|
|
speak HTTP).
|
|
|
|
### Proto Package Layout
|
|
|
|
```
|
|
proto/
|
|
└── mcr/
|
|
└── v1/
|
|
├── registry.proto # Repository listing, GC
|
|
├── policy.proto # Policy rule CRUD
|
|
├── audit.proto # Audit log queries
|
|
├── admin.proto # Health
|
|
└── common.proto # Shared message types
|
|
gen/
|
|
└── mcr/
|
|
└── v1/ # Generated Go stubs (committed)
|
|
```
|
|
|
|
### Service Definitions
|
|
|
|
| Service | RPCs |
|
|
|---------|------|
|
|
| `RegistryService` | `ListRepositories`, `GetRepository`, `DeleteRepository`, `GarbageCollect`, `GetGCStatus` |
|
|
| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` |
|
|
| `AuditService` | `ListAuditEvents` |
|
|
| `AdminService` | `Health` |
|
|
|
|
Auth endpoints (`/v1/auth/login`, `/v1/auth/logout`) are REST-only. Login
|
|
requires HTTP Basic auth or form-encoded credentials, which do not map
|
|
cleanly to gRPC unary RPCs. Clients that need programmatic auth use MCIAS
|
|
directly and present the resulting bearer token to gRPC.
|
|
|
|
### Transport Security
|
|
|
|
Same TLS certificate and key as the REST server. TLS 1.3 minimum.
|
|
|
|
### Authentication
|
|
|
|
gRPC unary interceptor extracts the `authorization` metadata key, validates
|
|
the MCIAS bearer token, and injects claims into the context. Same validation
|
|
logic as the REST middleware.
|
|
|
|
### Interceptor Chain
|
|
|
|
```
|
|
[Request Logger] → [Auth Interceptor] → [Admin Interceptor] → [Handler]
|
|
```
|
|
|
|
- **Request Logger**: Logs method, peer IP, status code, duration.
|
|
- **Auth Interceptor**: Validates bearer JWT via MCIAS. `Health` bypasses auth.
|
|
- **Admin Interceptor**: Requires admin role for GC, policy, and delete operations.
|
|
|
|
---
|
|
|
|
## 8. Database Schema
|
|
|
|
SQLite 3, WAL mode, `PRAGMA foreign_keys = ON`, `PRAGMA busy_timeout = 5000`.
|
|
|
|
```sql
|
|
-- Schema version tracking
|
|
CREATE TABLE schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
);
|
|
|
|
-- Container image repositories
|
|
CREATE TABLE repositories (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE, -- e.g., "myapp", "infra/proxy"
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
);
|
|
-- UNIQUE on name creates an implicit index; no explicit index needed.
|
|
|
|
-- Image manifests (content stored in DB — small JSON documents)
|
|
CREATE TABLE manifests (
|
|
id INTEGER PRIMARY KEY,
|
|
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
digest TEXT NOT NULL, -- "sha256:<hex>"
|
|
media_type TEXT NOT NULL, -- "application/vnd.oci.image.manifest.v1+json"
|
|
content BLOB NOT NULL, -- manifest JSON
|
|
size INTEGER NOT NULL, -- byte size of content
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
UNIQUE(repository_id, digest)
|
|
);
|
|
|
|
CREATE INDEX idx_manifests_repo ON manifests (repository_id);
|
|
CREATE INDEX idx_manifests_digest ON manifests (digest);
|
|
|
|
-- Tags: mutable pointers from name → manifest
|
|
CREATE TABLE tags (
|
|
id INTEGER PRIMARY KEY,
|
|
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL, -- e.g., "latest", "v1.2.3"
|
|
manifest_id INTEGER NOT NULL REFERENCES manifests(id) ON DELETE CASCADE,
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
UNIQUE(repository_id, name)
|
|
);
|
|
|
|
CREATE INDEX idx_tags_repo ON tags (repository_id);
|
|
CREATE INDEX idx_tags_manifest ON tags (manifest_id);
|
|
|
|
-- Blob metadata (actual data on filesystem at /srv/mcr/layers/)
|
|
CREATE TABLE blobs (
|
|
id INTEGER PRIMARY KEY,
|
|
digest TEXT NOT NULL UNIQUE, -- "sha256:<hex>"
|
|
size INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
);
|
|
-- UNIQUE on digest creates an implicit index; no explicit index needed.
|
|
|
|
-- Many-to-many: tracks which blobs are referenced by manifests in which repos.
|
|
-- A blob may be shared across repositories (content-addressed dedup).
|
|
-- Used by garbage collection to determine unreferenced blobs.
|
|
CREATE TABLE manifest_blobs (
|
|
manifest_id INTEGER NOT NULL REFERENCES manifests(id) ON DELETE CASCADE,
|
|
blob_id INTEGER NOT NULL REFERENCES blobs(id),
|
|
PRIMARY KEY (manifest_id, blob_id)
|
|
);
|
|
|
|
CREATE INDEX idx_manifest_blobs_blob ON manifest_blobs (blob_id);
|
|
|
|
-- In-progress blob uploads
|
|
CREATE TABLE uploads (
|
|
id INTEGER PRIMARY KEY,
|
|
uuid TEXT NOT NULL UNIQUE,
|
|
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
byte_offset INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
);
|
|
|
|
-- Policy rules for registry access control
|
|
CREATE TABLE policy_rules (
|
|
id INTEGER PRIMARY KEY,
|
|
priority INTEGER NOT NULL DEFAULT 100,
|
|
description TEXT NOT NULL,
|
|
rule_json TEXT NOT NULL, -- JSON-encoded rule body
|
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
|
|
created_by TEXT, -- MCIAS account UUID
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
);
|
|
|
|
-- Audit log — append-only
|
|
CREATE TABLE audit_log (
|
|
id INTEGER PRIMARY KEY,
|
|
event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
event_type TEXT NOT NULL,
|
|
actor_id TEXT, -- MCIAS account UUID
|
|
repository TEXT, -- repository name (if applicable)
|
|
digest TEXT, -- affected digest (if applicable)
|
|
ip_address TEXT,
|
|
details TEXT -- JSON blob; never contains secrets
|
|
);
|
|
|
|
CREATE INDEX idx_audit_time ON audit_log (event_time);
|
|
CREATE INDEX idx_audit_actor ON audit_log (actor_id);
|
|
CREATE INDEX idx_audit_event ON audit_log (event_type);
|
|
```
|
|
|
|
### Schema Notes
|
|
|
|
- Repositories are created implicitly on first push. No explicit creation
|
|
step required.
|
|
- Repository names may contain `/` for organizational grouping (e.g.,
|
|
`production/myapp`) but are otherwise flat strings. No hierarchical
|
|
enforcement.
|
|
- The `manifest_blobs` join table enables content-addressed deduplication:
|
|
the same layer blob may be referenced by manifests in different
|
|
repositories.
|
|
- Manifest deletion cascades to `manifest_blobs` rows and to any tags
|
|
pointing at the deleted manifest (`ON DELETE CASCADE` on both
|
|
`manifest_blobs.manifest_id` and `tags.manifest_id`). Blob files on
|
|
the filesystem are not deleted — unreferenced blobs are reclaimed by
|
|
garbage collection.
|
|
- Tag updates are atomic: pushing a manifest with an existing tag name
|
|
updates the `manifest_id` in a single transaction.
|
|
|
|
---
|
|
|
|
## 9. Garbage Collection
|
|
|
|
Garbage collection removes unreferenced blobs — blobs that are not
|
|
referenced by any manifest. GC is a manual process triggered by an
|
|
administrator.
|
|
|
|
### Algorithm
|
|
|
|
GC runs in two phases to maintain consistency across a crash:
|
|
|
|
```
|
|
Phase 1 — Mark and sweep (database)
|
|
1. Acquire registry-wide GC lock (blocks new blob uploads).
|
|
2. Begin write transaction.
|
|
3. Find all blob rows in `blobs` with no corresponding row in
|
|
`manifest_blobs` (unreferenced blobs). Record their digests.
|
|
4. Delete those rows from `blobs`.
|
|
5. Commit.
|
|
|
|
Phase 2 — File cleanup (filesystem)
|
|
6. For each digest recorded in step 3:
|
|
a. Delete the file from /srv/mcr/layers/sha256/<prefix>/<digest>.
|
|
7. Remove empty prefix directories.
|
|
8. Release GC lock.
|
|
```
|
|
|
|
**Crash safety**: If the process crashes after phase 1 but before phase 2
|
|
completes, orphaned files remain on disk with no matching DB row. These are
|
|
harmless — they consume space but are never served. A subsequent GC run or
|
|
a filesystem reconciliation command (`mcrctl gc --reconcile`) cleans them
|
|
up by scanning the layers directory and deleting files with no `blobs` row.
|
|
|
|
### Trigger Methods
|
|
|
|
- **CLI**: `mcrctl gc`
|
|
- **REST**: `POST /v1/gc`
|
|
- **gRPC**: `RegistryService.GarbageCollect`
|
|
|
|
GC runs asynchronously. Status can be checked via `GET /v1/gc/status` or
|
|
`mcrctl gc status`. Only one GC run may be active at a time.
|
|
|
|
### Safety
|
|
|
|
GC acquires a registry-wide lock that **blocks all new blob uploads** for
|
|
the duration of the mark-and-sweep phase. Ongoing uploads that started
|
|
before the lock are allowed to complete before the lock is acquired. This
|
|
is a stop-the-world approach, acceptable at the target scale (single
|
|
developer, several dozen repos). Pulls are not blocked.
|
|
|
|
---
|
|
|
|
## 10. Configuration
|
|
|
|
TOML format. Environment variable overrides via `MCR_*`.
|
|
|
|
```toml
|
|
[server]
|
|
listen_addr = ":8443" # HTTPS (OCI + admin REST)
|
|
grpc_addr = ":9443" # gRPC admin API (optional; omit to disable)
|
|
tls_cert = "/srv/mcr/certs/cert.pem"
|
|
tls_key = "/srv/mcr/certs/key.pem"
|
|
read_timeout = "30s" # HTTP read timeout
|
|
write_timeout = "0s" # HTTP write timeout; 0 = disabled for large
|
|
# blob uploads (idle_timeout provides the safety net)
|
|
idle_timeout = "120s" # HTTP idle timeout
|
|
shutdown_timeout = "60s" # Graceful shutdown drain period
|
|
|
|
[database]
|
|
path = "/srv/mcr/mcr.db"
|
|
|
|
[storage]
|
|
layers_path = "/srv/mcr/layers" # Blob storage root
|
|
uploads_path = "/srv/mcr/uploads" # Upload staging directory
|
|
# Must be on the same filesystem as layers_path
|
|
|
|
[mcias]
|
|
server_url = "https://mcias.metacircular.net:8443"
|
|
ca_cert = "" # Custom CA for MCIAS TLS
|
|
service_name = "mcr"
|
|
tags = ["env:restricted"]
|
|
|
|
[web]
|
|
listen_addr = "127.0.0.1:8080" # Web UI listen address
|
|
grpc_addr = "127.0.0.1:9443" # mcrsrv gRPC address for the web UI to connect to
|
|
ca_cert = "" # CA cert for verifying mcrsrv gRPC TLS
|
|
|
|
[log]
|
|
level = "info" # debug, info, warn, error
|
|
```
|
|
|
|
### Validation
|
|
|
|
Required fields are validated at startup. The server refuses to start if
|
|
any are missing or if TLS certificate paths are invalid. `storage.uploads_path`
|
|
and `storage.layers_path` must resolve to the same filesystem (verified at
|
|
startup via `os.Stat` device ID comparison).
|
|
|
|
### Timeout Notes
|
|
|
|
The HTTP `write_timeout` is disabled (0) by default because blob uploads
|
|
can transfer hundreds of megabytes over slow connections. The `idle_timeout`
|
|
serves as the safety net for stale connections. Operators may set a non-zero
|
|
`write_timeout` if all clients are on fast local networks.
|
|
|
|
---
|
|
|
|
## 11. Web UI
|
|
|
|
### Technology
|
|
|
|
Go `html/template` + htmx, embedded via `//go:embed`. The web UI is a
|
|
separate binary (`mcr-web`) that communicates with mcrsrv via gRPC.
|
|
|
|
### Pages
|
|
|
|
| Path | Description |
|
|
|------|-------------|
|
|
| `/login` | MCIAS login form |
|
|
| `/` | Dashboard (repository count, total size, recent pushes) |
|
|
| `/repositories` | Repository list with tag counts and sizes |
|
|
| `/repositories/{name}` | Repository detail: tags, manifests, layer list |
|
|
| `/repositories/{name}/manifests/{digest}` | Manifest detail: layers, config, size |
|
|
| `/policies` | Policy rule management (admin only): create, edit, delete |
|
|
| `/audit` | Audit log viewer (admin only) |
|
|
|
|
### Security
|
|
|
|
- CSRF protection via signed double-submit cookies on all mutating requests.
|
|
- Session cookie: `HttpOnly`, `Secure`, `SameSite=Strict`.
|
|
- All user input escaped by `html/template`.
|
|
- Guest accounts are blocked at login. After MCIAS authentication succeeds,
|
|
the web UI validates the token and checks roles; accounts with the `guest`
|
|
role are denied access. This is defense-in-depth alongside the
|
|
`env:restricted` MCIAS tag.
|
|
|
|
---
|
|
|
|
## 12. CLI Tools
|
|
|
|
### mcrsrv
|
|
|
|
The registry server. Cobra subcommands:
|
|
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| `server` | Start the registry server |
|
|
| `init` | First-time setup (create directories, example config) |
|
|
| `snapshot` | Database backup via `VACUUM INTO` |
|
|
|
|
### mcr-web
|
|
|
|
The web UI server. Communicates with mcrsrv via gRPC.
|
|
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| `server` | Start the web UI server |
|
|
|
|
### mcrctl
|
|
|
|
Admin CLI. Communicates with mcrsrv via REST or gRPC.
|
|
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| `status` | Query server health |
|
|
| `repo list` | List repositories |
|
|
| `repo delete <name>` | Delete a repository |
|
|
| `gc` | Trigger garbage collection |
|
|
| `gc status` | Check GC status |
|
|
| `policy list` | List policy rules |
|
|
| `policy create` | Create a policy rule |
|
|
| `policy update <id>` | Update a policy rule |
|
|
| `policy delete <id>` | Delete a policy rule |
|
|
| `audit tail [--n N]` | Print recent audit events |
|
|
| `snapshot` | Trigger database backup via `VACUUM INTO` |
|
|
|
|
---
|
|
|
|
## 13. Package Structure
|
|
|
|
```
|
|
mcr/
|
|
├── cmd/
|
|
│ ├── mcrsrv/ # Server binary: OCI + REST + gRPC
|
|
│ ├── mcr-web/ # Web UI binary
|
|
│ └── mcrctl/ # Admin CLI
|
|
├── internal/
|
|
│ ├── auth/ # MCIAS integration: token validation, 30s cache
|
|
│ ├── config/ # TOML config loading and validation
|
|
│ ├── db/ # SQLite: migrations, CRUD for all tables
|
|
│ ├── oci/ # OCI Distribution Spec handler: manifests, blobs, uploads
|
|
│ ├── policy/ # Registry policy engine: rules, evaluation, defaults
|
|
│ ├── server/ # REST API: admin routes, middleware, chi router
|
|
│ ├── grpcserver/ # gRPC admin API: interceptors, service handlers
|
|
│ ├── webserver/ # Web UI: template routes, htmx handlers
|
|
│ ├── storage/ # Blob filesystem operations: write, read, delete, GC
|
|
│ └── gc/ # Garbage collection: mark, sweep, locking
|
|
├── proto/mcr/
|
|
│ └── v1/ # Protobuf definitions
|
|
├── gen/mcr/
|
|
│ └── v1/ # Generated gRPC code (never edit by hand)
|
|
├── web/
|
|
│ ├── embed.go # //go:embed directive
|
|
│ ├── templates/ # HTML templates
|
|
│ └── static/ # CSS, htmx
|
|
├── deploy/
|
|
│ ├── docker/ # Docker Compose
|
|
│ ├── examples/ # Example config files
|
|
│ ├── scripts/ # Install script
|
|
│ └── systemd/ # systemd units and timers
|
|
└── docs/ # Internal documentation
|
|
```
|
|
|
|
---
|
|
|
|
## 14. Deployment
|
|
|
|
### Binary
|
|
|
|
Single static binary per component, built with `CGO_ENABLED=0`.
|
|
|
|
### Container
|
|
|
|
Multi-stage Docker build:
|
|
1. **Builder**: `golang:1.25-alpine`, static compilation with
|
|
`-trimpath -ldflags="-s -w"`.
|
|
2. **Runtime**: `alpine:3.21`, non-root user (`mcr`), ports 8443/9443.
|
|
|
|
The `/srv/mcr/` directory is a mounted volume containing the database,
|
|
blob storage, and configuration.
|
|
|
|
### systemd
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `mcr.service` | Registry server |
|
|
| `mcr-web.service` | Web UI |
|
|
| `mcr-backup.service` | Oneshot database backup |
|
|
| `mcr-backup.timer` | Daily backup timer (02:00 UTC, 5-minute jitter) |
|
|
|
|
Standard security hardening per engineering standards
|
|
(`NoNewPrivileges=true`, `ProtectSystem=strict`,
|
|
`ReadWritePaths=/srv/mcr`, etc.).
|
|
|
|
### Backup
|
|
|
|
Database backup via `VACUUM INTO` captures metadata only (repositories,
|
|
manifests, tags, blobs table, policy rules, audit log). **Blob data on the
|
|
filesystem is not included.** A complete backup requires both:
|
|
|
|
1. `mcrsrv snapshot` (or `mcrctl snapshot`) — SQLite database backup.
|
|
2. Filesystem-level copy of `/srv/mcr/layers/` — blob data.
|
|
|
|
The database and blob directory must be backed up together. A database
|
|
backup without the corresponding blob directory is usable (metadata is
|
|
intact; missing blobs return 404 on pull) but incomplete. A blob directory
|
|
without the database is useless (no way to map digests to repositories).
|
|
|
|
### Graceful Shutdown
|
|
|
|
On `SIGINT` or `SIGTERM`:
|
|
1. Stop accepting new connections.
|
|
2. Drain in-flight requests (including ongoing uploads) up to
|
|
`shutdown_timeout` (default 60s).
|
|
3. Force-close remaining connections.
|
|
4. Close database.
|
|
5. Exit.
|
|
|
|
---
|
|
|
|
## 15. Audit Events
|
|
|
|
| Event | Trigger |
|
|
|-------|---------|
|
|
| `manifest_pushed` | Manifest uploaded (includes repo, tag, digest) |
|
|
| `manifest_pulled` | Manifest downloaded |
|
|
| `manifest_deleted` | Manifest deleted |
|
|
| `blob_uploaded` | Blob upload completed |
|
|
| `blob_deleted` | Blob deleted |
|
|
| `repo_deleted` | Repository deleted (admin) |
|
|
| `gc_started` | Garbage collection started |
|
|
| `gc_completed` | Garbage collection finished (includes blobs removed, bytes freed) |
|
|
| `policy_rule_created` | Policy rule created |
|
|
| `policy_rule_updated` | Policy rule updated |
|
|
| `policy_rule_deleted` | Policy rule deleted |
|
|
| `policy_deny` | Policy engine denied a request |
|
|
| `login_ok` | Successful authentication |
|
|
| `login_fail` | Failed authentication |
|
|
|
|
The audit log is append-only. It never contains credentials or token values.
|
|
|
|
---
|
|
|
|
## 16. Error Handling and Logging
|
|
|
|
- All errors are wrapped with `fmt.Errorf("context: %w", err)`.
|
|
- Structured logging uses `log/slog` (or goutils wrapper).
|
|
- Log levels: DEBUG (dev only), INFO (normal ops), WARN (recoverable),
|
|
ERROR (unexpected failures).
|
|
- OCI operations (push, pull, delete) are logged at INFO with:
|
|
`{event, repository, reference, digest, account_uuid, ip, duration}`.
|
|
- **Never log:** bearer tokens, passwords, blob content, MCIAS credentials.
|
|
- OCI endpoint errors use the OCI error format (see §6). Admin endpoint
|
|
errors use the platform `{"error": "..."}` format.
|
|
|
|
---
|
|
|
|
## 17. Security Model
|
|
|
|
### Threat Mitigations
|
|
|
|
| Threat | Mitigation |
|
|
|--------|------------|
|
|
| Unauthenticated access | All endpoints require MCIAS bearer token; `env:restricted` tag blocks guest/viewer login; web UI additionally rejects `guest` role at login |
|
|
| Unauthorized push/delete | Policy engine enforces per-principal, per-repository access; default-deny for system accounts |
|
|
| Digest mismatch (supply chain) | All uploads verified against client-supplied digest; rejected if mismatch |
|
|
| Blob corruption | Content-addressed storage; digests verified on write. Periodic integrity scrub via `mcrctl scrub` (future) |
|
|
| Upload resource exhaustion | Stale uploads expire and are cleaned up; GC reclaims orphaned data |
|
|
| Information leakage | Error responses follow OCI spec format; no internal details exposed |
|
|
|
|
### Security Invariants
|
|
|
|
1. Every API request (OCI and admin) requires a valid MCIAS bearer token.
|
|
2. Token validation results are cached for at most 30 seconds.
|
|
3. System accounts have no implicit access — explicit policy rules required.
|
|
4. Blob digests are verified on upload; mismatched digests are rejected.
|
|
Reads trust the content-addressed path (digest is the filename).
|
|
5. Manifest deletion by tag is not supported — only by digest (OCI spec).
|
|
6. The audit log never contains credentials, tokens, or blob content.
|
|
7. TLS 1.3 minimum on all listeners. No fallback.
|
|
|
|
---
|
|
|
|
## 18. Future Work
|
|
|
|
| Item | Description |
|
|
|------|-------------|
|
|
| **Image signing / content trust** | Cosign or Notary v2 integration for image verification |
|
|
| **Multi-arch manifests** | OCI image index support for multi-platform images |
|
|
| **Cross-repo blob mounts** | `POST /v2/<name>/blobs/uploads/?mount=<digest>&from=<other>` for efficient cross-repo copies |
|
|
| **MCP integration** | Wire MCR into the Metacircular Control Plane for automated image deployment |
|
|
| **Upload expiry** | Automatic cleanup of stale uploads after configurable TTL |
|
|
| **Repository size quotas** | Per-repository storage limits |
|
|
| **Webhook notifications** | Push events to external systems on manifest push/delete |
|
|
| **Integrity scrub** | `mcrctl scrub` — verify blob digests on disk match their filenames, report corruption |
|
|
| **Metrics** | Prometheus-compatible metrics: push/pull counts, storage usage, request latency |
|