Compare commits
6 Commits
v1.555.2
...
alp/fix_ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af4e9e6a22 | ||
|
|
50a6106436 | ||
|
|
6806f2193d | ||
|
|
7d5196170c | ||
|
|
7b9e2c2d68 | ||
|
|
258b275f9b |
20
backend/.sqlx/query-5fdfc9427f455a4c1bc8f6ca41ddfd426bc0c2ac126792c926f3cf1182ded981.json
generated
Normal file
20
backend/.sqlx/query-5fdfc9427f455a4c1bc8f6ca41ddfd426bc0c2ac126792c926f3cf1182ded981.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value->>'ducklake_user_pg_pwd' FROM global_settings WHERE name = 'ducklake_settings';",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5fdfc9427f455a4c1bc8f6ca41ddfd426bc0c2ac126792c926f3cf1182ded981"
|
||||
}
|
||||
20
backend/.sqlx/query-73c1c88bdf26ea0559b83314fed7a67d850e4e4dd60f4424ffb0b6f472acc8d5.json
generated
Normal file
20
backend/.sqlx/query-73c1c88bdf26ea0559b83314fed7a67d850e4e4dd60f4424ffb0b6f472acc8d5.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value->'instance_catalog_db_status' FROM global_settings WHERE name = 'ducklake_settings'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "73c1c88bdf26ea0559b83314fed7a67d850e4e4dd60f4424ffb0b6f472acc8d5"
|
||||
}
|
||||
22
backend/.sqlx/query-97e3a1439202e13e739ad2e3f22b3a21d0c9b0e57d7d35326753e8f6a804d4f8.json
generated
Normal file
22
backend/.sqlx/query-97e3a1439202e13e739ad2e3f22b3a21d0c9b0e57d7d35326753e8f6a804d4f8.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Name"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "97e3a1439202e13e739ad2e3f22b3a21d0c9b0e57d7d35326753e8f6a804d4f8"
|
||||
}
|
||||
22
backend/.sqlx/query-b344ba5a32ec873181390e205e16356f1b79bd994a4bd1a8655dbe17bd1e4a30.json
generated
Normal file
22
backend/.sqlx/query-b344ba5a32ec873181390e205e16356f1b79bd994a4bd1a8655dbe17bd1e4a30.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT ws.ducklake->'ducklakes' AS ducklake_name\n FROM workspace_settings ws\n WHERE ws.workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "ducklake_name",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b344ba5a32ec873181390e205e16356f1b79bd994a4bd1a8655dbe17bd1e4a30"
|
||||
}
|
||||
14
backend/.sqlx/query-fd55112d55995ab08d2c275aa6430cdec1cacebdf2f2b3dd6f678b434643eb50.json
generated
Normal file
14
backend/.sqlx/query-fd55112d55995ab08d2c275aa6430cdec1cacebdf2f2b3dd6f678b434643eb50.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE global_settings SET value = jsonb_set(value, '{instance_catalog_db_status}', (COALESCE(value->'instance_catalog_db_status', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'ducklake_settings'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Json"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fd55112d55995ab08d2c275aa6430cdec1cacebdf2f2b3dd6f678b434643eb50"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script outputs all features except private. Usage :
|
||||
# > cargo build --features $(./all_features_oss.sh)
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Path to the Cargo.toml file
|
||||
CARGO_TOML_PATH="./Cargo.toml"
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
INSERT INTO global_settings (name, value) VALUES (
|
||||
'ducklake_user_pg_pwd',
|
||||
(SELECT g2.value->'ducklake_user_pg_pwd' FROM global_settings g2 WHERE g2.name = 'ducklake_settings')
|
||||
);
|
||||
|
||||
DELETE FROM global_settings WHERE name = 'ducklake_settings';
|
||||
@@ -0,0 +1,9 @@
|
||||
INSERT INTO global_settings (name, value) VALUES (
|
||||
'ducklake_settings',
|
||||
(SELECT json_build_object(
|
||||
'ducklake_user_pg_pwd', g2.value,
|
||||
'instance_catalog_db_status', '{}'::json
|
||||
) FROM global_settings g2 WHERE g2.name = 'ducklake_user_pg_pwd')
|
||||
);
|
||||
|
||||
DELETE FROM global_settings WHERE name = 'ducklake_user_pg_pwd';
|
||||
@@ -770,34 +770,26 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
/settings/databases_exist:
|
||||
/settings/get_ducklake_instance_catalog_db_status:
|
||||
post:
|
||||
summary: checks that all given databases exist or else return the ones that don't
|
||||
operationId: databasesExist
|
||||
summary: Returns the set-up statuses of ducklake instance catalog dbs
|
||||
operationId: getDucklakeInstanceCatalogDbStatus
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: databases that do not exist
|
||||
description: Statuses of all ducklake instance catalog dbs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/DucklakeInstanceCatalogDbStatus"
|
||||
|
||||
/settings/create_ducklake_database/{name}:
|
||||
/settings/setup_ducklake_catalog_db/{name}:
|
||||
post:
|
||||
summary: Runs CREATE DATABASE on the Windmill Postgres and grants access to the ducklake_user
|
||||
operationId: createDucklakeDatabase
|
||||
operationId: setupDucklakeCatalogDb
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
@@ -812,7 +804,8 @@ paths:
|
||||
description: status
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: "#/components/schemas/DucklakeInstanceCatalogDbStatus"
|
||||
|
||||
/settings/global/{key}:
|
||||
get:
|
||||
@@ -17333,6 +17326,49 @@ components:
|
||||
- enabled
|
||||
- aws_auth_resource_type
|
||||
|
||||
LoggedWizardStatus:
|
||||
type: string
|
||||
enum:
|
||||
- OK
|
||||
- SKIP
|
||||
- FAIL
|
||||
|
||||
DucklakeInstanceCatalogDbStatusLogs:
|
||||
type: object
|
||||
properties:
|
||||
super_admin:
|
||||
$ref: "#/components/schemas/LoggedWizardStatus"
|
||||
database_credentials:
|
||||
$ref: "#/components/schemas/LoggedWizardStatus"
|
||||
valid_dbname:
|
||||
$ref: "#/components/schemas/LoggedWizardStatus"
|
||||
created_database:
|
||||
$ref: "#/components/schemas/LoggedWizardStatus"
|
||||
description: Created database status log
|
||||
db_connect:
|
||||
$ref: "#/components/schemas/LoggedWizardStatus"
|
||||
grant_permissions:
|
||||
$ref: "#/components/schemas/LoggedWizardStatus"
|
||||
|
||||
DucklakeInstanceCatalogDbStatus:
|
||||
type: object
|
||||
required:
|
||||
- logs
|
||||
- success
|
||||
properties:
|
||||
logs:
|
||||
$ref: "#/components/schemas/DucklakeInstanceCatalogDbStatusLogs"
|
||||
success:
|
||||
type: boolean
|
||||
description: Whether the operation completed successfully
|
||||
example: true
|
||||
error:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Error message if the operation failed
|
||||
example: "Connection timeout"
|
||||
|
||||
|
||||
NewSqsTrigger:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use crate::{
|
||||
db::{ApiAuthed, DB},
|
||||
@@ -23,6 +23,7 @@ use axum::{
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
use axum::extract::Query;
|
||||
use serde_json::json;
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
use crate::utils::require_devops_role;
|
||||
@@ -68,10 +69,13 @@ pub fn global_service() -> Router {
|
||||
"/critical_alerts/:id/acknowledge",
|
||||
post(acknowledge_critical_alert),
|
||||
)
|
||||
.route("/databases_exist", post(databases_exist))
|
||||
.route(
|
||||
"/create_ducklake_database/:name",
|
||||
post(create_ducklake_database),
|
||||
"/get_ducklake_instance_catalog_db_status",
|
||||
post(get_ducklake_instance_catalog_db_status),
|
||||
)
|
||||
.route(
|
||||
"/setup_ducklake_catalog_db/:name",
|
||||
post(setup_ducklake_catalog_db),
|
||||
)
|
||||
.route(
|
||||
"/critical_alerts/acknowledge_all",
|
||||
@@ -568,54 +572,112 @@ pub async fn acknowledge_all_critical_alerts() -> error::Error {
|
||||
error::Error::NotFound("Critical Alerts require EE".to_string())
|
||||
}
|
||||
|
||||
async fn databases_exist(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Json(database_names): Json<Vec<String>>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
let result = sqlx::query_scalar!(
|
||||
r#"SELECT elem FROM (SELECT unnest($1::TEXT[]) AS elem) AS e
|
||||
WHERE elem NOT IN (SELECT datname FROM pg_catalog.pg_database);"#,
|
||||
database_names.as_slice()
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|x| x)
|
||||
.collect();
|
||||
|
||||
Ok(Json(result))
|
||||
#[derive(Deserialize, Debug, Serialize)]
|
||||
struct DucklakeInstanceCatalogDbStatus {
|
||||
logs: DucklakeInstanceCatalogDbStatusLogs, // (Step, Message)[]
|
||||
success: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn create_ducklake_database(
|
||||
#[derive(Deserialize, Debug, Serialize, Default)]
|
||||
#[serde(default)]
|
||||
struct DucklakeInstanceCatalogDbStatusLogs {
|
||||
super_admin: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
database_credentials: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
valid_dbname: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
created_database: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
db_connect: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
grant_permissions: String,
|
||||
}
|
||||
|
||||
async fn get_ducklake_instance_catalog_db_status(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<HashMap<String, DucklakeInstanceCatalogDbStatus>> {
|
||||
let result = sqlx::query_scalar!(
|
||||
r#"SELECT value->'instance_catalog_db_status' FROM global_settings WHERE name = 'ducklake_settings'"#,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.ok_or_else(|| error::Error::ExecutionErr("Couldn't find ducklake_settings".to_string()))?;
|
||||
let result = serde_json::from_value(result).map_err(|e| {
|
||||
error::Error::ExecutionErr(format!(
|
||||
"couldn't parse instance_catalog_db_status : {}",
|
||||
e.to_string()
|
||||
))
|
||||
})?;
|
||||
return Ok(Json(result));
|
||||
}
|
||||
|
||||
async fn setup_ducklake_catalog_db(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(dbname): Path<String>,
|
||||
) -> JsonResult<DucklakeInstanceCatalogDbStatus> {
|
||||
let mut logs = DucklakeInstanceCatalogDbStatusLogs::default();
|
||||
let result = setup_ducklake_catalog_db_inner(authed, &db, &dbname, &mut logs).await;
|
||||
let success = result.is_ok();
|
||||
let error = result.err().map(|e| e.to_string());
|
||||
let status = DucklakeInstanceCatalogDbStatus { logs, success, error };
|
||||
let status_json = serde_json::to_value(&status).map_err(to_anyhow)?;
|
||||
// Save that the database was setup successfully
|
||||
sqlx::query!(
|
||||
r#"UPDATE global_settings SET value = jsonb_set(value, '{instance_catalog_db_status}', (COALESCE(value->'instance_catalog_db_status', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'ducklake_settings'"#,
|
||||
json!({ dbname: status_json })
|
||||
).execute(&db).await?;
|
||||
|
||||
Ok(Json(status))
|
||||
}
|
||||
|
||||
async fn setup_ducklake_catalog_db_inner(
|
||||
authed: ApiAuthed,
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
logs: &mut DucklakeInstanceCatalogDbStatusLogs,
|
||||
) -> Result<()> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
require_super_admin(db, &authed.email).await?;
|
||||
logs.super_admin = "OK".to_string();
|
||||
let pg_creds = &get_database_url().await?;
|
||||
let pg_creds = parse_postgres_url(pg_creds)?;
|
||||
logs.database_credentials = "OK".to_string();
|
||||
|
||||
// Validate name to ensure it only contains alphanumeric characters
|
||||
// Prevents SQL injection on the instance database
|
||||
let valid_name = regex::Regex::new(r"^[a-zA-Z0-9_]+$")
|
||||
.map_err(|_| error::Error::internal_err("Failed to compile regex".to_string()))?;
|
||||
if !valid_name.is_match(&dbname) {
|
||||
lazy_static::lazy_static! {
|
||||
static ref VALID_NAME: regex::Regex = regex::Regex::new(r"^[a-zA-Z0-9_]+$").unwrap();
|
||||
}
|
||||
if !VALID_NAME.is_match(dbname) {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Invalid database name".to_string(),
|
||||
"Catalog name must be alphanumeric, underscores allowed".to_string(),
|
||||
));
|
||||
}
|
||||
if pg_creds.database.trim().eq_ignore_ascii_case(dbname.trim()) {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Database name cannot be the same as the main database".to_string(),
|
||||
));
|
||||
}
|
||||
logs.valid_dbname = "OK".to_string();
|
||||
|
||||
sqlx::query(&format!("CREATE DATABASE \"{dbname}\""))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let db_exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||||
dbname
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
sqlx::query(&format!(
|
||||
"GRANT CONNECT ON DATABASE \"{dbname}\" TO ducklake_user"
|
||||
))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// We have to connect to the newly created database as admin to grant permissions
|
||||
let pg_creds = parse_postgres_url(&get_database_url().await?)?;
|
||||
logs.created_database = "SKIP".to_string();
|
||||
if !db_exists {
|
||||
sqlx::query(&format!("CREATE DATABASE \"{dbname}\""))
|
||||
.execute(db)
|
||||
.await?;
|
||||
logs.created_database = "OK".to_string();
|
||||
}
|
||||
|
||||
let ssl_mode = match pg_creds.ssl_mode.as_deref() {
|
||||
Some("allow") => "prefer".to_string(),
|
||||
@@ -623,6 +685,7 @@ async fn create_ducklake_database(
|
||||
Some(s) => s.to_string(),
|
||||
None => "prefer".to_string(),
|
||||
};
|
||||
// We have to connect to the newly created database as admin to grant permissions
|
||||
let conn_str = format!(
|
||||
"postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}",
|
||||
user = urlencoding::encode(&pg_creds.username.unwrap_or_else(|| "postgres".to_string())),
|
||||
@@ -632,29 +695,41 @@ async fn create_ducklake_database(
|
||||
dbname = dbname,
|
||||
sslmode = ssl_mode
|
||||
);
|
||||
|
||||
let (client, connection) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
tokio_postgres::connect(&conn_str, tokio_postgres::NoTls),
|
||||
)
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
.map_err(to_anyhow)?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
eprintln!("connection error: {}", e);
|
||||
}
|
||||
});
|
||||
.map_err(|e| error::Error::ExecutionErr(format!("timeout: {}", e.to_string())))?
|
||||
.map_err(|e| error::Error::ExecutionErr(format!("error: {}", e.to_string())))?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
logs.db_connect = "OK".to_string();
|
||||
|
||||
client
|
||||
.batch_execute(&format!(
|
||||
"GRANT USAGE ON SCHEMA public TO ducklake_user;
|
||||
"GRANT CONNECT ON DATABASE \"{dbname}\" TO ducklake_user;
|
||||
GRANT USAGE ON SCHEMA public TO ducklake_user;
|
||||
GRANT CREATE ON SCHEMA public TO ducklake_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ducklake_user;"
|
||||
))
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
.map_err(|e| {
|
||||
error::Error::ExecutionErr(format!(
|
||||
"Failed to grant permissions to ducklake_user: {}",
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
logs.grant_permissions = "OK".to_string();
|
||||
|
||||
drop(client); // /!\ Drop before joining to avoid deadlock
|
||||
join_handle
|
||||
.await
|
||||
.map_err(|e| error::Error::ExecutionErr(format!("join error: {}", e.to_string())))?
|
||||
.map_err(|e| {
|
||||
error::Error::ExecutionErr(format!("tokio_postgres error: {}", e.to_string()))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1023,10 +1023,11 @@ async fn edit_ducklake_config(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||||
ApiAuthed { is_admin, username, email, .. }: ApiAuthed,
|
||||
Json(new_config): Json<EditDucklakeConfig>,
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
let is_superadmin = require_super_admin(&db, &email).await.is_ok();
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -1065,6 +1066,38 @@ async fn edit_ducklake_config(
|
||||
}
|
||||
}
|
||||
|
||||
// Check that non-superadmins are not abusing Instance catalogs
|
||||
if !is_superadmin {
|
||||
let old_ducklakes = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT ws.ducklake->'ducklakes' AS ducklake_name
|
||||
FROM workspace_settings ws
|
||||
WHERE ws.workspace_id = $1
|
||||
"#,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let old_ducklakes: HashMap<String, Ducklake> =
|
||||
serde_json::from_value(old_ducklakes).unwrap_or_default();
|
||||
for (name, dl) in new_config.settings.ducklakes.iter() {
|
||||
if dl.catalog.resource_type == DucklakeCatalogResourceType::Instance {
|
||||
let old_dl = old_ducklakes.get(name);
|
||||
if old_dl.is_none()
|
||||
|| old_dl.unwrap().catalog.resource_type
|
||||
!= DucklakeCatalogResourceType::Instance
|
||||
|| old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path
|
||||
{
|
||||
return Err(Error::BadRequest(
|
||||
"Only superadmins can create or modify ducklakes with Instance catalogs"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let config: serde_json::Value = serde_json::to_value(new_config.settings)
|
||||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ pub async fn get_ducklake_from_db_unchecked(
|
||||
|
||||
pub async fn get_ducklake_instance_pg_catalog_password(db: &DB) -> Result<String> {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT trim(both '\"' from value::text) FROM global_settings WHERE name = 'ducklake_user_pg_pwd';"
|
||||
"SELECT value->>'ducklake_user_pg_pwd' FROM global_settings WHERE name = 'ducklake_settings';"
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
@@ -269,11 +269,8 @@ async fn transform_json_unchecked(
|
||||
.map_err(to_anyhow)?;
|
||||
let mc = build_crypt(&db, &w_id).await?;
|
||||
let variable = decrypt(&mc, variable).map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Error decrypting variable {}: {}",
|
||||
&s, e
|
||||
))
|
||||
})?;
|
||||
Error::internal_err(format!("Error decrypting variable {}: {}", &s, e))
|
||||
})?;
|
||||
serde_json::Value::String(variable)
|
||||
}
|
||||
s @ serde_json::Value::String(_) => s.clone(),
|
||||
|
||||
@@ -877,9 +877,6 @@ pub async fn run_agent(
|
||||
"inner_job_completed_tx should be set as agent jobs are not supported on agent workers",
|
||||
);
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut bench = windmill_common::bench::BenchmarkIter::new();
|
||||
|
||||
// Spawn handle_queued_job on separate task to prevent tokio stack overflow
|
||||
// Clone everything needed for the spawned task
|
||||
let tool_job_spawn = tool_job.clone();
|
||||
|
||||
@@ -16,8 +16,5 @@ set -e
|
||||
echo "Running dnt..."
|
||||
deno run -A dnt.ts
|
||||
|
||||
# Remove .ts extensions after building to go back to the original state
|
||||
./windmill-utils-internal/remove-ts-ext.sh
|
||||
|
||||
echo "Build complete!"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./config";
|
||||
export * from "./config.ts";
|
||||
@@ -8,8 +8,8 @@
|
||||
* - Cross-platform path constants
|
||||
*/
|
||||
|
||||
export * from "./inline-scripts";
|
||||
export * from "./path-utils";
|
||||
export * from "./parse";
|
||||
export * from "./config";
|
||||
export { SEP, DELIMITER } from "./constants";
|
||||
export * from "./inline-scripts.ts";
|
||||
export * from "./path-utils.ts";
|
||||
export * from "./parse.ts";
|
||||
export * from "./config.ts";
|
||||
export { SEP, DELIMITER } from "./constants.ts";
|
||||
@@ -1,5 +1,5 @@
|
||||
import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner";
|
||||
import { FlowModule } from "../gen/types.gen";
|
||||
import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner.ts";
|
||||
import { FlowModule } from "../gen/types.gen.ts";
|
||||
|
||||
/**
|
||||
* Represents an inline script extracted from a flow module
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./replacer";
|
||||
export * from "./extractor";
|
||||
export * from "./replacer.ts";
|
||||
export * from "./extractor.ts";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FlowModule } from "../gen/types.gen";
|
||||
import { FlowModule } from "../gen/types.gen.ts";
|
||||
|
||||
/**
|
||||
* Replaces inline script references with actual file content from the filesystem.
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./parse-schema";
|
||||
export * from "./parse-schema.ts";
|
||||
@@ -1 +1 @@
|
||||
export * from "./path-assigner";
|
||||
export * from "./path-assigner.ts";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RawScript } from "../gen/types.gen";
|
||||
import { RawScript } from "../gen/types.gen.ts";
|
||||
|
||||
const INLINE_SCRIPT_PREFIX = "inline_script";
|
||||
|
||||
|
||||
14
flake.nix
14
flake.nix
@@ -50,11 +50,11 @@
|
||||
xmlsec.dev
|
||||
libxslt.dev
|
||||
libclang.dev
|
||||
libffi # For deno_ffi
|
||||
libtool
|
||||
nodejs
|
||||
postgresql
|
||||
pkg-config
|
||||
glibc.dev
|
||||
clang
|
||||
cmake
|
||||
];
|
||||
@@ -298,9 +298,10 @@
|
||||
# included we need to look in a few places.
|
||||
# See https://web.archive.org/web/20220523141208/https://hoverbear.org/blog/rust-bindgen-in-nix/
|
||||
BINDGEN_EXTRA_CLANG_ARGS =
|
||||
"${builtins.readFile "${stdenv.cc}/nix-support/libc-crt1-cflags"} ${
|
||||
# Prevent clang from using system headers - only use Nix headers
|
||||
"-nostdinc ${builtins.readFile "${stdenv.cc}/nix-support/libc-crt1-cflags"} ${
|
||||
builtins.readFile "${stdenv.cc}/nix-support/libc-cflags"
|
||||
}${builtins.readFile "${stdenv.cc}/nix-support/cc-cflags"}${
|
||||
} ${builtins.readFile "${stdenv.cc}/nix-support/cc-cflags"} ${
|
||||
builtins.readFile "${stdenv.cc}/nix-support/libcxx-cxxflags"
|
||||
} -idirafter ${pkgs.libiconv}/include ${
|
||||
lib.optionalString stdenv.cc.isClang
|
||||
@@ -313,9 +314,10 @@
|
||||
lib.getVersion stdenv.cc.cc
|
||||
} -isystem ${stdenv.cc.cc}/include/c++/${
|
||||
lib.getVersion stdenv.cc.cc
|
||||
}/${stdenv.hostPlatform.config} -idirafter ${stdenv.cc.cc}/lib/gcc/${stdenv.hostPlatform.config}/14.2.1/include"
|
||||
}"; # NOTE: It is hardcoded to 14.2.1 -------------------------------------------------------------^^^^^^
|
||||
# Please update the version here as well if you want to update flake.
|
||||
}/${stdenv.hostPlatform.config} -idirafter ${stdenv.cc.cc}/lib/gcc/${stdenv.hostPlatform.config}/${
|
||||
lib.getVersion stdenv.cc.cc
|
||||
}/include"
|
||||
}";
|
||||
};
|
||||
packages.default = self.packages.${system}.windmill;
|
||||
packages.windmill-client = pkgs.buildNpmPackage {
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
|
||||
let monaco: SimpleEditor | undefined = $state(undefined)
|
||||
let monacoTemplate: TemplateEditor | undefined = $state(undefined)
|
||||
let argInput: ArgInput | undefined = $state(undefined)
|
||||
let focusedPrev = false
|
||||
|
||||
let hidden = $state(false)
|
||||
@@ -355,32 +354,6 @@
|
||||
|
||||
function onFocus() {
|
||||
focused = true
|
||||
if (isStaticTemplate(inputCat)) {
|
||||
focusProp?.(argName, 'append', (path) => {
|
||||
// Empty field + variable = use $var:/$res: syntax instead of ${...}
|
||||
const isEmpty = !arg.value || arg.value.trim() === ''
|
||||
|
||||
if (isEmpty && variableMatch(path)) {
|
||||
connectProperty(path)
|
||||
return true
|
||||
} else {
|
||||
const toAppend = `\$\{${path}}`
|
||||
arg.value = `${arg.value ?? ''}${toAppend}`
|
||||
monacoTemplate?.setCode(arg.value)
|
||||
setPropertyType(arg.value)
|
||||
argInput?.focus()
|
||||
return false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
focusProp?.(argName, 'insert', (path) => {
|
||||
arg.expr = path
|
||||
arg.type = 'javascript'
|
||||
propertyType = 'javascript'
|
||||
monaco?.setCode(arg.expr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let prevArg: any = undefined
|
||||
@@ -711,7 +684,6 @@
|
||||
{resourceTypes}
|
||||
noMargin
|
||||
compact
|
||||
bind:this={argInput}
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
@@ -777,19 +749,11 @@
|
||||
renderLineHighlight="none"
|
||||
hideLineNumbers
|
||||
fakeMonacoPlaceholderClass="mt-2"
|
||||
on:focus={() => {
|
||||
focused = true
|
||||
focusProp?.(argName, 'insert', (path) => {
|
||||
monaco?.insertAtCursor(path)
|
||||
return false
|
||||
})
|
||||
}}
|
||||
on:focus={() => (focused = true)}
|
||||
on:blur={() => (focused = false)}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName, arg })
|
||||
}}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
}}
|
||||
autoHeight
|
||||
loadAsync
|
||||
/>
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
}: Props = $props()
|
||||
|
||||
if (initialValue && value == undefined) {
|
||||
console.log('initialValue', initialValue)
|
||||
value = initialValue
|
||||
}
|
||||
|
||||
@@ -76,15 +75,12 @@
|
||||
$effect(() => {
|
||||
if (value === undefined) {
|
||||
if (initialValue) {
|
||||
console.log('initialValue', initialValue)
|
||||
if (initialValue != value) {
|
||||
value = initialValue
|
||||
}
|
||||
} else {
|
||||
console.log('no value')
|
||||
}
|
||||
} else {
|
||||
console.log('value', value)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -125,7 +121,6 @@
|
||||
}
|
||||
collection = nc
|
||||
if (collection.length == 1 && selectFirst && (value == undefined || value == '')) {
|
||||
console.log('selectFirst', collection[0].value)
|
||||
value = collection[0].value
|
||||
valueType = collection[0].type
|
||||
}
|
||||
|
||||
@@ -491,6 +491,7 @@
|
||||
if (ctxMatch) {
|
||||
nonStaticRunnableInputs[k] = '$ctx:' + ctxMatch[1]
|
||||
} else {
|
||||
// console.log('k', k)
|
||||
nonStaticRunnableInputs[k] = await inputValues[k]?.computeExpr()
|
||||
}
|
||||
if (isEditor && field?.type == 'evalv2' && field.allowUserResources) {
|
||||
@@ -506,6 +507,7 @@
|
||||
|
||||
const oneOfRunnableInputs = isEditor ? collectOneOfFields(fields, $app) : {}
|
||||
|
||||
// console.log(JSON.stringify({ id, nonStaticRunnableInputs, inputValues }))
|
||||
const requestBody: ExecuteComponentData['requestBody'] = {
|
||||
args: nonStaticRunnableInputs,
|
||||
component: id,
|
||||
@@ -749,7 +751,7 @@
|
||||
|
||||
let lastJobId: string | undefined = $state(undefined)
|
||||
|
||||
let inputValues: Record<string, InputValue> = $state({})
|
||||
let inputValues: Record<string, InputValue> = {}
|
||||
|
||||
function updateBgRuns(loading: boolean) {
|
||||
if (loading) {
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
} else {
|
||||
outputs?.result.set(undefined)
|
||||
}
|
||||
console.log('outputs?.result', outputs?.result.peak())
|
||||
untrack(() => fireOnChange())
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1158,7 +1158,12 @@
|
||||
<div class="relative flex flex-col h-full"></div>
|
||||
{:else}
|
||||
<Pane bind:size={rightPanelSize} minSize={15} maxSize={33}>
|
||||
<div bind:clientWidth={$runnableJob.width} class="relative flex flex-col h-full">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:clientWidth={$runnableJob.width}
|
||||
class="relative flex flex-col h-full"
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tabs bind:selected={selectedTab} wrapperClass="!min-h-[42px]" class="!h-full">
|
||||
<Popover disappearTimeout={0} notClickable placement="bottom">
|
||||
{#snippet text()}
|
||||
|
||||
@@ -191,8 +191,8 @@
|
||||
{#if job?.args}
|
||||
<div class="p-2">
|
||||
<JobArgs
|
||||
id={job.id}
|
||||
workspace={job.workspace_id ?? $workspaceStore ?? 'no_w'}
|
||||
id={job?.id}
|
||||
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
|
||||
args={job?.args}
|
||||
/>
|
||||
</div>
|
||||
@@ -216,12 +216,12 @@
|
||||
/>
|
||||
</Pane>
|
||||
<Pane size={50} minSize={10} class="text-sm text-secondary">
|
||||
{#if job != undefined && 'result' in job && job.result != undefined}<div
|
||||
{#if job != undefined && 'result' in job && job?.result != undefined}<div
|
||||
class="relative h-full px-2"
|
||||
><DisplayResult
|
||||
workspaceId={$workspaceStore}
|
||||
jobId={selectedJobId}
|
||||
result={job.result}
|
||||
result={job?.result}
|
||||
/></div
|
||||
>
|
||||
{:else if testIsLoading}
|
||||
@@ -237,7 +237,7 @@
|
||||
{#if jobResult?.transformer}
|
||||
<Pane size={50} minSize={10} class="text-sm text-secondary p-2">
|
||||
<div class="font-bold">Transformer results</div>
|
||||
{#if job != undefined && 'result' in job && job.result != undefined}
|
||||
{#if job != undefined && 'result' in job && job?.result != undefined}
|
||||
<div class="relative h-full px-2">
|
||||
<DisplayResult
|
||||
workspaceId={$workspaceStore}
|
||||
|
||||
@@ -56,6 +56,29 @@
|
||||
{#if componentInput.fieldType !== 'any'}
|
||||
<div class="w-full">
|
||||
<div class="flex gap-2 justify-end" bind:clientWidth>
|
||||
<div class="flex">
|
||||
<ConnectionButton
|
||||
closeConnection={() => {
|
||||
$connectingInput = {
|
||||
opened: false,
|
||||
hoveredComponent: undefined,
|
||||
input: undefined,
|
||||
onConnect: () => {}
|
||||
}
|
||||
dispatch('select', true)
|
||||
}}
|
||||
openConnection={() => {
|
||||
$connectingInput = {
|
||||
opened: true,
|
||||
input: undefined,
|
||||
hoveredComponent: undefined,
|
||||
onConnect: applyConnection
|
||||
}
|
||||
}}
|
||||
isOpen={!!$connectingInput.opened}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleButtonGroup
|
||||
on:selected={() => {
|
||||
onchange?.()
|
||||
@@ -121,29 +144,6 @@
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<div class="flex">
|
||||
<ConnectionButton
|
||||
closeConnection={() => {
|
||||
$connectingInput = {
|
||||
opened: false,
|
||||
hoveredComponent: undefined,
|
||||
input: undefined,
|
||||
onConnect: () => {}
|
||||
}
|
||||
dispatch('select', true)
|
||||
}}
|
||||
openConnection={() => {
|
||||
$connectingInput = {
|
||||
opened: true,
|
||||
input: undefined,
|
||||
hoveredComponent: undefined,
|
||||
onConnect: applyConnection
|
||||
}
|
||||
}}
|
||||
isOpen={!!$connectingInput.opened}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
<div class={classNames('flex gap-x-2 gap-y-1 justify-end items-center')}>
|
||||
{#if componentInput?.type && allowTypeChange !== false}
|
||||
<ConnectionButton
|
||||
small
|
||||
{closeConnection}
|
||||
{openConnection}
|
||||
isOpen={!!$connectingInput.opened}
|
||||
|
||||
@@ -7,13 +7,25 @@
|
||||
import type { AppViewerContext } from '$lib/components/apps/types'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export let isOpen = false
|
||||
export let openConnection: () => void
|
||||
export let closeConnection: () => void
|
||||
export let btnWrapperClasses = ''
|
||||
export let id: string | undefined = undefined
|
||||
interface Props {
|
||||
isOpen?: boolean
|
||||
openConnection: () => void
|
||||
closeConnection: () => void
|
||||
btnWrapperClasses?: string
|
||||
id?: string | undefined
|
||||
small?: boolean
|
||||
}
|
||||
|
||||
let selected = false
|
||||
let {
|
||||
isOpen = false,
|
||||
openConnection,
|
||||
closeConnection,
|
||||
btnWrapperClasses = '',
|
||||
id = undefined,
|
||||
small
|
||||
}: Props = $props()
|
||||
|
||||
let selected = $state(false)
|
||||
|
||||
const { panzoomActive } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
@@ -51,25 +63,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDownOutside(e: CustomEvent) {
|
||||
function handlePointerDownOutside() {
|
||||
if (!$panzoomActive) {
|
||||
deactivateConnection()
|
||||
}
|
||||
}
|
||||
|
||||
$: !isOpen && (selected = false)
|
||||
$effect(() => {
|
||||
!isOpen && (selected = false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
use:pointerDownOutside={{
|
||||
capture: true,
|
||||
stopPropagation: isOpen,
|
||||
exclude: getConnectionButtonElements,
|
||||
customEventName: 'pointerdown_connecting'
|
||||
customEventName: 'pointerdown_connecting',
|
||||
onClickOutside: () => handlePointerDownOutside()
|
||||
}}
|
||||
on:keydown={handleKeyDown}
|
||||
on:pointerdown_outside={handlePointerDownOutside}
|
||||
onkeydown={handleKeyDown}
|
||||
data-connection-button
|
||||
>
|
||||
<AnimatedButton
|
||||
@@ -79,14 +93,14 @@
|
||||
marginWidth="2px"
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
size={small ? 'xs' : 'md'}
|
||||
variant="border"
|
||||
color="light"
|
||||
title="Connect"
|
||||
on:click={() => handleConnect(true)}
|
||||
{id}
|
||||
wrapperClasses={twMerge(btnWrapperClasses, selected ? 'opacity-100' : '')}
|
||||
btnClasses="p-0"
|
||||
btnClasses={small ? 'p-0' : ''}
|
||||
>
|
||||
<Plug size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
jobKindsCat?: string | undefined
|
||||
minTs?: string | undefined
|
||||
maxTs?: string | undefined
|
||||
jobKinds?: string
|
||||
jobKinds?: string | undefined
|
||||
queue_count?: Tweened<number> | undefined
|
||||
suspended_count?: Tweened<number> | undefined
|
||||
autoRefresh?: boolean
|
||||
@@ -98,12 +98,12 @@
|
||||
loadJobsIntern(true)
|
||||
}
|
||||
|
||||
function computeJobKinds(jobKindsCat: string | undefined): string {
|
||||
function computeJobKinds(jobKindsCat: string | undefined): string | undefined {
|
||||
if (jobKindsCat == undefined && jobKinds != undefined) {
|
||||
return jobKinds
|
||||
}
|
||||
if (jobKindsCat == 'all') {
|
||||
return ''
|
||||
return undefined
|
||||
} else if (jobKindsCat == 'dependencies') {
|
||||
let kinds: CompletedJob['job_kind'][] = [
|
||||
'dependencies',
|
||||
|
||||
108
frontend/src/lib/components/wizards/LoggedWizardResult.svelte
Normal file
108
frontend/src/lib/components/wizards/LoggedWizardResult.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script module lang="ts">
|
||||
export function firstEmptyStepIsError<Step extends { status?: LoggedWizardStatus }>(
|
||||
steps: Step[],
|
||||
error: string | undefined
|
||||
): (Step & { status: LoggedWizardStatus })[] {
|
||||
let convertedSteps = [...steps]
|
||||
let alreadyFoundEmpty = false
|
||||
for (let step of convertedSteps) {
|
||||
if (!step.status) {
|
||||
if (!alreadyFoundEmpty) {
|
||||
alreadyFoundEmpty = true
|
||||
step.status = error !== undefined ? 'FAIL' : 'SKIP'
|
||||
} else {
|
||||
step.status = 'SKIP'
|
||||
}
|
||||
}
|
||||
}
|
||||
return convertedSteps as any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { LoggedWizardStatus } from '$lib/gen'
|
||||
import { CircleCheck, Circle, CircleX, ChevronDown } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
|
||||
|
||||
type Props = {
|
||||
steps: { title?: string; status: LoggedWizardStatus; description?: string }[]
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { steps, class: className = '' }: Props = $props()
|
||||
|
||||
let openedDescriptions: Record<number, true> = $state({})
|
||||
|
||||
$effect(() => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
let step = steps[i]
|
||||
if (step.status == 'FAIL') {
|
||||
openedDescriptions[i] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class={twMerge('flex flex-col gap-2', className)}>
|
||||
{#each steps as step, i}
|
||||
{@const descriptionOpened = openedDescriptions[i] ?? false}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex flex-col bg-surface rounded-md py-1.5 px-3 cursor-pointer"
|
||||
role=""
|
||||
onclick={() => {
|
||||
if (step.description) {
|
||||
if (descriptionOpened) delete openedDescriptions[i]
|
||||
else openedDescriptions[i] = true
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<span class="inline-flex w-10 h-10 shrink-0 justify-center items-center">
|
||||
{#if step.status == 'SKIP'}
|
||||
<Circle size={20} class="inline text-hint/50" />
|
||||
{:else if step.status == 'FAIL'}
|
||||
<CircleX size={20} class="inline text-red-500" />
|
||||
{:else if step.status == 'OK'}
|
||||
<CircleCheck size={20} class="inline text-green-500" />
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex-1 my-2">
|
||||
<span
|
||||
class={twMerge(
|
||||
'font-medium flex justify-between items-center',
|
||||
{
|
||||
SKIP: 'text-hint/75',
|
||||
FAIL: 'text-red-400',
|
||||
OK: 'text-green-600 dark:text-green-400'
|
||||
}[step.status]
|
||||
)}
|
||||
>
|
||||
{i + 1}. {step.title}
|
||||
{#if step.description}
|
||||
<ChevronDown
|
||||
class={twMerge(
|
||||
'text-hint transition-transform',
|
||||
descriptionOpened ? 'rotate-180' : ''
|
||||
)}
|
||||
size={16}
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
<ResizeTransitionWrapper vertical class="relative text-xs text-secondary">
|
||||
{#if descriptionOpened}
|
||||
<div
|
||||
class="whitespace-pre-wrap cursor-default mt-1.5"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{step.description}
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import { ArrowRight, TriangleAlert, Plus } from 'lucide-svelte'
|
||||
|
||||
import Button from '../common/button/Button.svelte'
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
import Select from '../select/Select.svelte'
|
||||
import ResourcePicker from '../ResourcePicker.svelte'
|
||||
import { usePromise } from '$lib/svelte5Utils.svelte'
|
||||
import { SettingService, WorkspaceService } from '$lib/gen'
|
||||
import { SettingService, WorkspaceService, type DucklakeInstanceCatalogDbStatus } from '$lib/gen'
|
||||
import { type GetSettingsResponse } from '$lib/gen'
|
||||
|
||||
import { superadmin, workspaceStore } from '$lib/stores'
|
||||
@@ -73,12 +73,14 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import { clone, pluralize } from '$lib/utils'
|
||||
import { clone } from '$lib/utils'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import Popover from '../meltComponents/Popover.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import Section from '../Section.svelte'
|
||||
import LoggedWizardResult, { firstEmptyStepIsError } from '../wizards/LoggedWizardResult.svelte'
|
||||
import { safeSelectItems } from '../select/utils.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
|
||||
const DEFAULT_DUCKLAKE_CATALOG_NAME = 'ducklake_catalog'
|
||||
|
||||
@@ -88,7 +90,7 @@
|
||||
}
|
||||
let { ducklakeSettings = $bindable(), ducklakeSavedSettings = $bindable() }: Props = $props()
|
||||
|
||||
let isWmDbEnabled = $derived($superadmin && !isCloudHosted())
|
||||
let isInstanceCatalogEnabled = $derived($superadmin && !isCloudHosted())
|
||||
|
||||
function onNewDucklake() {
|
||||
const name = ducklakeSettings.ducklakes.some((d) => d.name === 'main')
|
||||
@@ -97,8 +99,8 @@
|
||||
ducklakeSettings.ducklakes.push({
|
||||
name,
|
||||
catalog: {
|
||||
resource_type: isWmDbEnabled ? 'instance' : 'postgresql',
|
||||
resource_path: isWmDbEnabled ? DEFAULT_DUCKLAKE_CATALOG_NAME : undefined
|
||||
resource_type: isInstanceCatalogEnabled ? 'instance' : 'postgresql',
|
||||
resource_path: isInstanceCatalogEnabled ? DEFAULT_DUCKLAKE_CATALOG_NAME : undefined
|
||||
},
|
||||
storage: {
|
||||
storage: undefined,
|
||||
@@ -111,12 +113,6 @@
|
||||
ducklakeSettings.ducklakes.splice(index, 1)
|
||||
}
|
||||
|
||||
const windmillDbNames = $derived(
|
||||
ducklakeSettings.ducklakes
|
||||
.filter((d) => d.catalog.resource_type === 'instance')
|
||||
.map((d) => d.catalog.resource_path ?? '')
|
||||
)
|
||||
|
||||
const ducklakeIsDirty: Record<string, boolean> = $derived(
|
||||
Object.fromEntries(
|
||||
ducklakeSettings.ducklakes.map((d) => {
|
||||
@@ -126,25 +122,27 @@
|
||||
)
|
||||
)
|
||||
|
||||
let instanceCatalogSetupIsRunning = $state(false)
|
||||
const instanceCatalogStatuses = usePromise(SettingService.getDucklakeInstanceCatalogDbStatus, {
|
||||
clearValueOnRefresh: false
|
||||
})
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
if (windmillDbNames.length) {
|
||||
// Ensure that all instance dbs exist
|
||||
const nonExistentDbs = await SettingService.databasesExist({ requestBody: windmillDbNames })
|
||||
if (nonExistentDbs.length) {
|
||||
let confirmed = await confirmationModal.ask({
|
||||
title: "The following databases do not exist in Windmill's Postgres instance",
|
||||
confirmationText: `Create ${pluralize(nonExistentDbs.length, 'database')}`,
|
||||
children: `<span>
|
||||
Confirm running the following in the instance's Postgres :<br />
|
||||
${nonExistentDbs.map((db) => `<pre class='border mt-1 p-2 rounded-md'>CREATE DATABASE "${db}";</pre>`).join('\n')}
|
||||
</span>`
|
||||
})
|
||||
if (!confirmed) return
|
||||
await Promise.all(
|
||||
nonExistentDbs.map((name) => SettingService.createDucklakeDatabase({ name }))
|
||||
)
|
||||
}
|
||||
if (
|
||||
isInstanceCatalogEnabled &&
|
||||
ducklakeSettings.ducklakes.some(
|
||||
(d) =>
|
||||
d.catalog.resource_type === 'instance' &&
|
||||
!instanceCatalogStatuses.value?.[d.catalog.resource_path ?? '']?.success
|
||||
)
|
||||
) {
|
||||
let confirm = await confirmationModal.ask({
|
||||
title: 'Some instance catalogs are not setup',
|
||||
children: 'Are you sure you want to save without setting them up ?',
|
||||
confirmationText: 'Save anyway'
|
||||
})
|
||||
if (!confirm) return
|
||||
}
|
||||
const settings = convertDucklakeSettingsToBackend(ducklakeSettings)
|
||||
await WorkspaceService.editDucklakeConfig({
|
||||
@@ -192,56 +190,12 @@
|
||||
</div>
|
||||
|
||||
{#if ducklakeSettings.ducklakes.some((d) => d.catalog.resource_type === 'instance')}
|
||||
<Alert title="Instance catalogs use the Windmill database" class="mb-4" type="info">
|
||||
Using an instance catalog is the fastest way to get started with Ducklake. They are public to
|
||||
the instance and can be re-used in other workspaces' Ducklake settings.
|
||||
<div>
|
||||
<Section
|
||||
label="Manual setup instructions"
|
||||
collapsable
|
||||
headerClass="mt-6 border bg-surface px-3 py-1 rounded-md text-xs text-secondary"
|
||||
class="text-secondary"
|
||||
animate
|
||||
>
|
||||
This is what happens when you create a new Instance catalog with the name
|
||||
<code>ducklake_catalog</code>. This may be useful to debug issues in case the automatic
|
||||
setup fails in the middle, but in most cases Windmill will handle it for you.
|
||||
<br /><br />
|
||||
|
||||
If the database <code>ducklake_catalog</code> already exists, Windmill assumes that the
|
||||
setup was successful and does not do anything. However, it is possible that it failed in the
|
||||
middle (in which case you should have seen an error pop up during setup). There is no
|
||||
rollback as the following operations do not work in a transaction.
|
||||
<br />
|
||||
This is what the setup does :
|
||||
<br /><br />
|
||||
Connect to the Windmill PostgreSQL as the default user (the one in your DATABASE_URL, usually
|
||||
'postgres') and run :
|
||||
<br />
|
||||
<code class="block p-2 border rounded-md bg-surface mt-2">
|
||||
CREATE DATABASE <code>ducklake_catalog</code>;<br />
|
||||
GRANT CONNECT ON DATABASE <code>ducklake_catalog</code> TO ducklake_user;
|
||||
</code>
|
||||
<br />
|
||||
Then, connect to the <code>ducklake_catalog</code> database with the same user as above (NOT
|
||||
ducklake_user) and run :
|
||||
<code class="block p-2 border rounded-md bg-surface mt-2">
|
||||
GRANT USAGE ON SCHEMA public TO ducklake_user;<br />
|
||||
GRANT CREATE ON SCHEMA public TO ducklake_user;<br />
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public<br />
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ducklake_user;
|
||||
</code>
|
||||
<br />
|
||||
After doing that, creating a new Ducklake with an Instance catalog named
|
||||
<code>ducklake_catalog</code> should not prompt you to run the automatic setup, and
|
||||
everything should work fine.
|
||||
<br /><br />
|
||||
Note : the ducklake_user is automatically created by Windmill in a migration. Its password is
|
||||
auto-generated and stored in the database table <code>global_settings</code> with the key
|
||||
<code>ducklake_user_pg_pwd</code>.
|
||||
</Section>
|
||||
</div>
|
||||
</Alert>
|
||||
<div transition:slide={{ duration: 200 }} class="mb-4">
|
||||
<Alert title="Instance catalogs use the Windmill database" type="info">
|
||||
Using an instance catalog is the fastest way to get started with Ducklake. They are public to
|
||||
the instance and can be re-used in other workspaces' Ducklake settings.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DataTable>
|
||||
@@ -271,7 +225,7 @@
|
||||
<Row>
|
||||
<Cell first class="w-48 relative">
|
||||
{#if ducklake.name === 'main'}
|
||||
<Tooltip wrapperClass="absolute mt-2.5 right-4" placement="bottom-start">
|
||||
<Tooltip wrapperClass="absolute mt-3 right-4" placement="bottom-start">
|
||||
The <i>main</i> ducklake can be accessed with the
|
||||
<br />
|
||||
<code class="px-1 py-0.5 border rounded-md">ATTACH 'ducklake' AS dl;</code> shorthand
|
||||
@@ -280,10 +234,10 @@
|
||||
<TextInput bind:value={ducklake.name} inputProps={{ placeholder: 'Name' }} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="relative">
|
||||
{#if ducklake.catalog.resource_type === 'instance'}
|
||||
<Tooltip wrapperClass="absolute mt-2.5 right-2 z-20" placement="bottom-start">
|
||||
<Tooltip wrapperClass="absolute mt-3 right-2 z-20" placement="bottom-start">
|
||||
Use Windmill's PostgreSQL instance as a catalog
|
||||
</Tooltip>
|
||||
{/if}
|
||||
@@ -291,7 +245,11 @@
|
||||
items={[
|
||||
{ value: 'postgresql', label: 'PostgreSQL' },
|
||||
{ value: 'mysql', label: 'MySQL' },
|
||||
...(isWmDbEnabled ? [{ value: 'instance', label: 'Instance' }] : [])
|
||||
{
|
||||
value: 'instance',
|
||||
label: 'Instance',
|
||||
subtitle: isInstanceCatalogEnabled ? undefined : 'Superadmin only'
|
||||
}
|
||||
]}
|
||||
bind:value={
|
||||
() => ducklake.catalog.resource_type,
|
||||
@@ -306,7 +264,7 @@
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 w-80">
|
||||
<div class="flex items-center gap-1 w-80 relative">
|
||||
{#if ducklake.catalog.resource_type !== 'instance'}
|
||||
<ResourcePicker
|
||||
bind:value={ducklake.catalog.resource_path}
|
||||
@@ -315,16 +273,51 @@
|
||||
class="min-h-9"
|
||||
/>
|
||||
{:else}
|
||||
<TextInput
|
||||
{@const status =
|
||||
instanceCatalogStatuses.value?.[ducklake.catalog.resource_path ?? '']}
|
||||
<Select
|
||||
class="flex-1"
|
||||
inputClass="pr-20"
|
||||
bind:value={ducklake.catalog.resource_path}
|
||||
inputProps={{ placeholder: 'PostgreSQL database name' }}
|
||||
onCreateItem={(i) => (ducklake.catalog.resource_path = i)}
|
||||
placeholder="PostgreSQL database name"
|
||||
items={safeSelectItems(Object.keys(instanceCatalogStatuses.value ?? {}))}
|
||||
disabled={!isInstanceCatalogEnabled}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
class="absolute right-1.5"
|
||||
enableFlyTransition
|
||||
contentClasses="py-5 px-6 w-[34rem] bg-surface-secondary -translate-y-2"
|
||||
closeOnOtherPopoverOpen
|
||||
closeOnOutsideClick
|
||||
{...confirmationModal.props.open ? { isOpen: false } : {}}
|
||||
>
|
||||
<svelte:fragment slot="trigger">
|
||||
<Button spacingSize="xs2" variant="border" color="light" btnClasses="h-6">
|
||||
{#if !status}
|
||||
<span class="text-yellow-600 dark:text-yellow-400">
|
||||
Setup <ArrowRight class="inline" size={14} />
|
||||
</span>
|
||||
{:else if !status.success}
|
||||
<span class="text-red-400 flex gap-1">
|
||||
Error <TriangleAlert class="inline" size={16} />
|
||||
</span>
|
||||
{:else}
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green-400"></div>
|
||||
{/if}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="content">
|
||||
{@render instanceCatalogWizard(status, ducklake.catalog.resource_path ?? '')}
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
placeholder="Default storage"
|
||||
items={[
|
||||
@@ -389,7 +382,7 @@
|
||||
</tbody>
|
||||
</DataTable>
|
||||
<Button
|
||||
wrapperClasses="mt-4 mb-44 max-w-fit"
|
||||
wrapperClasses="mt-4 mb-16 max-w-fit"
|
||||
on:click={onSave}
|
||||
disabled={ducklakeSavedSettings.ducklakes.length === ducklakeSettings.ducklakes.length &&
|
||||
Object.values(ducklakeIsDirty).every((v) => v === false)}
|
||||
@@ -399,3 +392,120 @@
|
||||
<DbManagerDrawer bind:this={dbManagerDrawer} />
|
||||
|
||||
<ConfirmationModal {...confirmationModal.props} />
|
||||
|
||||
{#snippet instanceCatalogWizard(
|
||||
status: DucklakeInstanceCatalogDbStatus | undefined,
|
||||
dbname: string
|
||||
)}
|
||||
{#if !status}
|
||||
<div class="mb-4 text-secondary text-sm">
|
||||
{dbname} needs to be configured in the Windmill postgres instance
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status?.error}
|
||||
<div transition:slide={{ duration: 200 }} class="mb-4">
|
||||
<Alert title="Error setting up ducklake instance catalog" type="error">
|
||||
{status.error}
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<LoggedWizardResult
|
||||
class="max-h-[24rem] overflow-y-auto"
|
||||
steps={firstEmptyStepIsError(
|
||||
[
|
||||
{
|
||||
title: 'Super admin required',
|
||||
status: status?.logs.super_admin,
|
||||
description:
|
||||
'You need to be a super admin to setup an instance catalog, as it requires creating a new database in the Windmill PostgreSQL instance'
|
||||
},
|
||||
{
|
||||
title: 'Retrieve and parse database credentials',
|
||||
status: status?.logs.database_credentials,
|
||||
description:
|
||||
'Windmill uses the DATABASE_URL or DATABASE_URL_FILE environment variable to connect to the PostgreSQL instance. Make sure it is correctly set'
|
||||
},
|
||||
{
|
||||
title: 'Catalog name is valid',
|
||||
status: status?.logs.valid_dbname,
|
||||
description:
|
||||
'The catalog name must be alphanumeric (underscores allowed) and cannot be named the same as the Windmill database (usually "windmill")'
|
||||
},
|
||||
{
|
||||
title:
|
||||
'Create database' +
|
||||
(status?.logs.created_database === 'SKIP' ? ' (already exists, skipped)' : ''),
|
||||
status: status?.logs.created_database,
|
||||
description: `In the Windmill PostgreSQL instance, run: CREATE DATABASE "${dbname}".`
|
||||
},
|
||||
{
|
||||
title: `Connect to the ${dbname} database`,
|
||||
status: status?.logs.db_connect,
|
||||
description:
|
||||
"Connect to the newly created database with the default admin user (the one in DATABASE_URL, usually 'postgres') to run the next commands"
|
||||
},
|
||||
{
|
||||
title: 'Grant permissions to ducklake_user',
|
||||
status: status?.logs.grant_permissions,
|
||||
description:
|
||||
'Gives ducklake_user the required permissions to use the database as a Ducklake catalog. ducklake_user is already created during a migration and has an auto-generated password stored in global_settings.ducklake_settings.ducklake_user_pg_pwd. These are the commands : \n\n' +
|
||||
`GRANT CONNECT ON DATABASE "${dbname}" TO ducklake_user;\n` +
|
||||
'GRANT USAGE ON SCHEMA public TO ducklake_user;\n' +
|
||||
'GRANT CREATE ON SCHEMA public TO ducklake_user;\n' +
|
||||
'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' +
|
||||
' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO ducklake_user;'
|
||||
}
|
||||
],
|
||||
status?.error ?? undefined
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
wrapperClasses="mt-6"
|
||||
size="sm"
|
||||
disabled={!isInstanceCatalogEnabled}
|
||||
onClick={async () => {
|
||||
if (instanceCatalogSetupIsRunning) return
|
||||
|
||||
let wasAlreadySuccessful = status?.success ?? false
|
||||
if (status?.logs.created_database != 'OK' && status?.logs.created_database != 'SKIP') {
|
||||
let confirm = await confirmationModal.ask({
|
||||
title: 'Confirm setup',
|
||||
children: `This will create a new database ${dbname} in the Windmill PostgreSQL instance`,
|
||||
confirmationText: 'Setup catalog'
|
||||
})
|
||||
if (!confirm) return
|
||||
}
|
||||
|
||||
try {
|
||||
instanceCatalogSetupIsRunning = true
|
||||
let result = await SettingService.setupDucklakeCatalogDb({ name: dbname })
|
||||
await instanceCatalogStatuses.refresh()
|
||||
if (result.success) {
|
||||
if (!wasAlreadySuccessful) sendUserToast('Setup successful')
|
||||
else sendUserToast('Everything OK')
|
||||
} else {
|
||||
sendUserToast(result.error ?? 'An error occured', true)
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast('Unexpected error, check console for details', true)
|
||||
console.error('Error setting up ducklake instance catalog', e)
|
||||
} finally {
|
||||
instanceCatalogSetupIsRunning = false
|
||||
}
|
||||
}}
|
||||
loading={instanceCatalogSetupIsRunning}
|
||||
>
|
||||
{#if !isInstanceCatalogEnabled}
|
||||
Only superadmins can setup instance catalogs
|
||||
{:else if status?.success}
|
||||
Check again
|
||||
{:else if status?.error}
|
||||
Try again
|
||||
{:else}
|
||||
Setup {dbname}
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -30,6 +30,7 @@ export type UsePromiseResult<T> = (
|
||||
| { status: 'ok'; value: T; error?: undefined }
|
||||
) & {
|
||||
refresh: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export type UsePromiseOptions = {
|
||||
@@ -64,6 +65,12 @@ export function usePromise<T>(
|
||||
ret.status = 'error'
|
||||
})
|
||||
})
|
||||
},
|
||||
clear: () => {
|
||||
ret.status = 'loading'
|
||||
ret.value = undefined
|
||||
ret.error = undefined
|
||||
ret.__promise = undefined
|
||||
}
|
||||
})
|
||||
if (loadInit) ret.refresh()
|
||||
|
||||
Reference in New Issue
Block a user