Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 58 additions & 7 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,38 @@ name: Build and Push to GHCR
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- name: Cache cargo
uses: Swatinem/rust-cache@v2

- name: Check formatting
run: cargo fmt --all -- --check

- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings

- name: Test
run: cargo test --workspace

build:
if: github.event_name == 'push'
needs: test
strategy:
matrix:
arch: [amd64, arm64]
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -24,14 +53,36 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build and push
- name: Build and push ${{ matrix.arch }}
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/actorfield/unitycatalog:latest
ghcr.io/actorfield/unitycatalog:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/${{ matrix.arch }}
tags: ghcr.io/actorfield/unitycatalog:${{ github.sha }}-${{ matrix.arch }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}

manifest:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Create and push multi-arch manifest
run: |
docker buildx imagetools create \
-t ghcr.io/actorfield/unitycatalog:latest \
-t ghcr.io/actorfield/unitycatalog:${{ github.sha }} \
ghcr.io/actorfield/unitycatalog:${{ github.sha }}-amd64 \
ghcr.io/actorfield/unitycatalog:${{ github.sha }}-arm64
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
FROM rust:latest AS chef
FROM --platform=$BUILDPLATFORM rust:alpine AS chef
# TARGETARCH: auto-set by buildkit/buildah from host arch unless --platform is passed.
ARG TARGETARCH
RUN apt-get update && apt-get install -y pkg-config python3-pip && rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache build-base bash pkgconf python3 py3-pip
RUN pip install ziglang --break-system-packages
RUN cargo install cargo-chef cargo-zigbuild
# cook must use --zigbuild too, or its fingerprint won't match the final build and everything recompiles twice.
Expand Down
13 changes: 1 addition & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ unitycatalog-rs/
├── migrations/
│ ├── sqlite/ DDL for SQLite (default)
│ └── postgres/ DDL for PostgreSQL
├── ui/ React frontend (proxy → server on :8080)
├── tests/python/ Pytest integration tests
├── scripts/
│ └── seed.py Seeds sample data (unity catalog + default schema)
Expand Down Expand Up @@ -71,17 +70,7 @@ python3 scripts/seed.py http://localhost:8080

This creates: catalog `unity`, schema `default`, tables (marksheet, numbers, user_countries), volumes (txt_files, json_files), functions (sum, lowercase).

### 4. Open the UI

```bash
cd ui
npm install
npm start # → http://localhost:3000
```

The UI proxies all `/api/*` requests to the server on `:8080`.

### 5. Run integration tests
### 4. Run integration tests

```bash
pip install unitycatalog-client pytest pytest-asyncio
Expand Down
2 changes: 2 additions & 0 deletions crates/uc-api/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub struct AppState {
}

impl AppState {
// One arg per AppState field; a builder would just move the noise.
#[allow(clippy::too_many_arguments)]
pub fn new(
pool: AnyPool,
authorizer: Arc<dyn Authorizer>,
Expand Down
5 changes: 5 additions & 0 deletions crates/uc-api/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// Each integration test file is compiled as its own binary and pulls in this
// module separately, so helpers unused by one binary but used by another
// trip dead_code false positives.
#![allow(dead_code)]

use axum::body::to_bytes;
/// Shared test infrastructure for in-process axum handler tests.
///
Expand Down
2 changes: 1 addition & 1 deletion crates/uc-api/tests/test_catalogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async fn catalog_get_not_found() {
async fn catalog_update_comment() {
let (app, _) = build_test_app().await;
post(&app, &format!("{UC}/catalogs"), json!({"name":"upd_cat"})).await;
let (status, body) = patch(
let (status, _body) = patch(
&app,
&format!("{UC}/catalogs/upd_cat"),
json!({"comment":"updated"}),
Expand Down
6 changes: 3 additions & 3 deletions crates/uc-api/tests/test_control_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async fn scim2_user_create_list_get_delete() {
// PUT update
let req = axum::http::Request::builder()
.method("PUT")
.uri(&format!("{CTRL}/scim2/Users/{uid}"))
.uri(format!("{CTRL}/scim2/Users/{uid}"))
.header("content-type", "application/json")
.body(axum::body::Body::from(
serde_json::to_vec(&json!({"userName":"alice_new@test.com","active":true})).unwrap(),
Expand All @@ -51,7 +51,7 @@ async fn scim2_user_create_list_get_delete() {
// PATCH disable
let req2 = axum::http::Request::builder()
.method("PATCH")
.uri(&format!("{CTRL}/scim2/Users/{uid}"))
.uri(format!("{CTRL}/scim2/Users/{uid}"))
.header("content-type", "application/json")
.body(axum::body::Body::from(
serde_json::to_vec(&json!({
Expand All @@ -72,7 +72,7 @@ async fn scim2_user_create_list_get_delete() {
// Delete
let req3 = axum::http::Request::builder()
.method("DELETE")
.uri(&format!("{CTRL}/scim2/Users/{uid}"))
.uri(format!("{CTRL}/scim2/Users/{uid}"))
.body(axum::body::Body::empty())
.unwrap();
let res3 = app.clone().oneshot(req3).await.unwrap();
Expand Down
8 changes: 4 additions & 4 deletions crates/uc-api/tests/test_delta_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ async fn delta_table_exists_head() {

let req = axum::http::Request::builder()
.method("HEAD")
.uri(&delta_tables("head_t"))
.uri(delta_tables("head_t"))
.body(axum::body::Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);

let req2 = axum::http::Request::builder()
.method("HEAD")
.uri(&delta_tables("no_such_table"))
.uri(delta_tables("no_such_table"))
.body(axum::body::Body::empty())
.unwrap();
let res2 = app.clone().oneshot(req2).await.unwrap();
Expand Down Expand Up @@ -250,7 +250,7 @@ async fn delta_rename_table() {

let req = axum::http::Request::builder()
.method("GET")
.uri(&delta_tables("ren_src"))
.uri(delta_tables("ren_src"))
.body(axum::body::Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
Expand All @@ -271,7 +271,7 @@ async fn delta_delete_table() {
.await;
let req = axum::http::Request::builder()
.method("DELETE")
.uri(&delta_tables("del_dt"))
.uri(delta_tables("del_dt"))
.body(axum::body::Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
Expand Down
7 changes: 4 additions & 3 deletions crates/uc-auth/src/authorizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,10 @@ impl UcAuthorizer {
pub(crate) async fn force_save_policy(&self) -> Result<(), UcError> {
use casbin::CoreApi;
let mut enforcer = self.enforcer.write().await;
enforcer.save_policy().await.map_err(|e| {
UcError::new(ErrorCode::Internal, format!("save_policy failed: {}", e))
})
enforcer
.save_policy()
.await
.map_err(|e| UcError::new(ErrorCode::Internal, format!("save_policy failed: {}", e)))
}
}

Expand Down
27 changes: 21 additions & 6 deletions crates/uc-auth/src/db_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ impl Adapter for SqlxAdapter {

#[cfg(test)]
mod tests {
use super::*;

use crate::{Authorizer, UcAuthorizer};
use uc_db::AnyPool;
use uc_types::Privilege;
Expand Down Expand Up @@ -365,7 +365,10 @@ mod tests {
let catalog = Uuid::new_v4();

let auth1 = UcAuthorizer::new_with_db(pool.clone()).await.unwrap();
auth1.grant(principal, catalog, Privilege::Owner).await.unwrap();
auth1
.grant(principal, catalog, Privilege::Owner)
.await
.unwrap();

// Simulate restart — fresh enforcer loads g/g2/g3 from the same DB.
let auth2 = UcAuthorizer::new_with_db(pool.clone()).await.unwrap();
Expand All @@ -376,7 +379,10 @@ mod tests {
Privilege::Select,
] {
assert!(
auth2.authorize(principal, catalog, req.clone()).await.unwrap(),
auth2
.authorize(principal, catalog, req.clone())
.await
.unwrap(),
"OWNER must imply {:?} via g3 after restart (g3 must load into section \"g\")",
req
);
Expand All @@ -396,7 +402,10 @@ mod tests {
let schema = Uuid::new_v4();

let auth1 = UcAuthorizer::new_with_db(pool.clone()).await.unwrap();
auth1.grant(principal, catalog, Privilege::Owner).await.unwrap();
auth1
.grant(principal, catalog, Privilege::Owner)
.await
.unwrap();
auth1.add_hierarchy_child(catalog, schema).await.unwrap();

// Force a full snapshot save (the path that previously mislabeled ptypes).
Expand All @@ -405,11 +414,17 @@ mod tests {
// Reload from the snapshot and verify both hierarchies survived.
let auth2 = UcAuthorizer::new_with_db(pool.clone()).await.unwrap();
assert!(
auth2.authorize(principal, catalog, Privilege::CreateSchema).await.unwrap(),
auth2
.authorize(principal, catalog, Privilege::CreateSchema)
.await
.unwrap(),
"g3 (OWNER→CREATE_SCHEMA) must survive a save_policy snapshot"
);
assert!(
auth2.authorize(principal, schema, Privilege::CreateTable).await.unwrap(),
auth2
.authorize(principal, schema, Privilege::CreateTable)
.await
.unwrap(),
"g2 (catalog→schema) + g3 must survive a save_policy snapshot"
);
}
Expand Down
8 changes: 7 additions & 1 deletion crates/uc-credentials/src/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ fn parse_expiry_ttl(creds: &TemporaryCredentials) -> Option<Duration> {
.aws_temp_credentials
.as_ref()
.and_then(|a| a.expiration.as_deref())
.or_else(|| creds.expiration_time.as_deref())?;
.or(creds.expiration_time.as_deref())?;

let exp = chrono::DateTime::parse_from_rfc3339(exp_str).ok()?;
let now = chrono::Utc::now();
Expand All @@ -145,6 +145,12 @@ fn parse_expiry_ttl(creds: &TemporaryCredentials) -> Option<Duration> {

pub struct AwsCredentialVendor;

impl Default for AwsCredentialVendor {
fn default() -> Self {
Self::new()
}
}

impl AwsCredentialVendor {
pub fn new() -> Self {
Self
Expand Down
2 changes: 2 additions & 0 deletions crates/uc-db/src/repos/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use crate::{models::catalog::CatalogRow, pool::AnyPool};
use uc_errors::{ErrorCode, UcError};
use uuid::Uuid;

// One arg per column of the INSERT below; a params struct would just move the noise.
#[allow(clippy::too_many_arguments)]
pub async fn create(
pool: &AnyPool,
id: Uuid,
Expand Down
2 changes: 2 additions & 0 deletions crates/uc-db/src/repos/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use crate::{models::schema::SchemaRow, pool::AnyPool};
use uc_errors::{ErrorCode, UcError};
use uuid::Uuid;

// One arg per column of the INSERT below; a params struct would just move the noise.
#[allow(clippy::too_many_arguments)]
pub async fn create(
pool: &AnyPool,
id: Uuid,
Expand Down
4 changes: 0 additions & 4 deletions crates/uc-db/tests/test_repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@
/// These test every repo method directly — no HTTP layer.
use uc_db::{
models::{
catalog::CatalogRow,
credential::CredentialRow,
delta::DeltaCommitRow,
external_location::ExternalLocationRow,
metastore::MetastoreRow,
schema::SchemaRow,
staging::StagingTableRow,
table::{ColumnRow, TableRow},
user::UserRow,
volume::VolumeRow,
},
pool::run_migrations,
Expand Down
14 changes: 1 addition & 13 deletions crates/uc-openapi/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -792,7 +792,7 @@ pub struct GcpOauthToken {
pub oauth_token: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct TemporaryCredentials {
#[serde(skip_serializing_if = "Option::is_none")]
pub aws_temp_credentials: Option<AwsCredentials>,
Expand All @@ -806,18 +806,6 @@ pub struct TemporaryCredentials {
pub url: Option<String>,
}

impl Default for TemporaryCredentials {
fn default() -> Self {
Self {
aws_temp_credentials: None,
azure_user_delegation_sas: None,
gcp_oauth_token: None,
expiration_time: None,
url: None,
}
}
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum CredentialOperation {
Expand Down
Loading
Loading