mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-13 01:50:40 +02:00
* iceberg: accept bare bucket names and ARNs as the catalog warehouse Only s3://<bucket>/ was recognized. A warehouse spelled as a bare table bucket name or as the s3tables bucket ARN -- the two forms users reach for first, the latter being what AWS S3 Tables itself takes -- was silently dropped, so every call landed on the default "warehouse" bucket and failed with "table bucket warehouse not found". * iceberg: report a missing table bucket as 404, not 500 Pointing a client at a table bucket that does not exist -- which every client with no warehouse set does, since the default bucket "warehouse" rarely exists -- returned InternalServerError with a message naming a bucket the client never asked for. Answer 404 and say how to select one. * admin: show the warehouse in the PyIceberg example The example connected without one, so it always resolved to the default table bucket and every client that copied it failed on the first call. * test: pin bearer auth against a table bucket that exists The subtest called the catalog with no warehouse and accepted 500 as proof that auth had passed, since the default bucket does not exist. A missing table bucket now answers 404, which the test read as an auth failure. Give it a real table bucket so only 200 passes. * test: assert the missing-bucket guidance reaches the client The status and error type were checked but not the message, which is the part of the mapping that tells a user how to select a table bucket. * test: encode the warehouse query value The ARN case pasted raw colons and slashes into the query string. Go's parser tolerates them, so the test passed without modelling how a client actually sends the request.
249 lines
8.2 KiB
Templ
249 lines
8.2 KiB
Templ
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
)
|
|
|
|
templ IcebergCatalog(data dash.IcebergCatalogData) {
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
|
<h1 class="h2">
|
|
<i class="fas fa-snowflake me-2"></i>Iceberg Catalog
|
|
</h1>
|
|
<div class="btn-toolbar mb-2 mb-md-0">
|
|
<div class="btn-group me-2">
|
|
<a href={ dash.PUrl(ctx, "/object-store/s3tables/buckets") } class="btn btn-sm btn-primary">
|
|
<i class="fas fa-plus me-1"></i>Create Table Bucket
|
|
</a>
|
|
</div>
|
|
<div class="btn-group me-2">
|
|
<a id="iceberg-rest-link" href={ templ.SafeURL(fmt.Sprintf("//localhost:%d/v1/config", data.IcebergPort)) } target="_blank" class="btn btn-sm btn-outline-secondary">
|
|
<i class="fas fa-external-link-alt me-1"></i>REST API
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="iceberg-catalog-content">
|
|
<!-- Info Alert about Iceberg REST -->
|
|
<div class="alert alert-info mb-4">
|
|
<div class="d-flex align-items-center">
|
|
<i class="fas fa-info-circle fa-2x me-3"></i>
|
|
<div>
|
|
<strong>Iceberg REST Catalog</strong>
|
|
<p id="iceberg-rest-info" class="mb-0 mt-1" data-iceberg-port={ fmt.Sprintf("%d", data.IcebergPort) }>
|
|
Connect your Iceberg clients (DuckDB, Spark, etc.) to:
|
|
<code><span id="iceberg-protocol">http</span>://<span id="iceberg-host">localhost</span>:{fmt.Sprintf("%d", data.IcebergPort)}/v1</code>
|
|
</p>
|
|
<script>
|
|
const icebergInfo = document.getElementById('iceberg-rest-info');
|
|
const icebergHost = window.location.hostname;
|
|
const icebergProtocol = window.location.protocol.slice(0, -1);
|
|
const icebergPort = icebergInfo ? icebergInfo.dataset.icebergPort : '';
|
|
document.getElementById('iceberg-host').innerText = icebergHost;
|
|
const icebergProtocolEl = document.getElementById('iceberg-protocol');
|
|
if (icebergProtocolEl) {
|
|
icebergProtocolEl.innerText = icebergProtocol;
|
|
}
|
|
const restLink = document.getElementById('iceberg-rest-link');
|
|
if (restLink && icebergPort) {
|
|
restLink.href = `//${icebergHost}:${icebergPort}/v1/config`;
|
|
}
|
|
</script>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Cards -->
|
|
<div class="row mb-4">
|
|
<div class="col-xl-4 col-md-6 mb-4">
|
|
<div class="card border-left-primary shadow h-100 py-2">
|
|
<div class="card-body">
|
|
<div class="row no-gutters align-items-center">
|
|
<div class="col mr-2">
|
|
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
|
Catalogs (Table Buckets)
|
|
</div>
|
|
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
|
{ fmt.Sprintf("%d", data.TotalCatalogs) }
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<i class="fas fa-database fa-2x text-gray-300"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-xl-4 col-md-6 mb-4">
|
|
<div class="card border-left-success shadow h-100 py-2">
|
|
<div class="card-body">
|
|
<div class="row no-gutters align-items-center">
|
|
<div class="col mr-2">
|
|
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
|
REST Port
|
|
</div>
|
|
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
|
{ fmt.Sprintf("%d", data.IcebergPort) }
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<i class="fas fa-plug fa-2x text-gray-300"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-xl-4 col-md-6 mb-4">
|
|
<div class="card border-left-info shadow h-100 py-2">
|
|
<div class="card-body">
|
|
<div class="row no-gutters align-items-center">
|
|
<div class="col mr-2">
|
|
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
|
Last Updated
|
|
</div>
|
|
<div class="h6 mb-0 font-weight-bold text-gray-800">
|
|
if data.LastUpdated.IsZero() {
|
|
<span class="text-muted">-</span>
|
|
} else {
|
|
{ data.LastUpdated.Format("2006-01-02 15:04") }
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Catalog List -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card shadow mb-4">
|
|
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
|
<h6 class="m-0 font-weight-bold text-primary">
|
|
<i class="fas fa-snowflake me-2"></i>Available Catalogs
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted mb-3">
|
|
Each S3 Table Bucket acts as an Iceberg catalog. Use the bucket name as the catalog prefix in your REST API calls.
|
|
</p>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover" width="100%" cellspacing="0" id="icebergCatalogsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Catalog Name</th>
|
|
<th>Owner</th>
|
|
<th>REST Endpoint</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, catalog := range data.Catalogs {
|
|
<tr>
|
|
<td>
|
|
<i class="fas fa-snowflake text-info me-2"></i>
|
|
<strong>{ catalog.Name }</strong>
|
|
</td>
|
|
<td>{ catalog.OwnerAccountID }</td>
|
|
<td>
|
|
<code class="small">/v1/{ catalog.Name }/namespaces</code>
|
|
</td>
|
|
<td>
|
|
if catalog.CreatedAt.IsZero() {
|
|
<span class="text-muted">-</span>
|
|
} else {
|
|
{ catalog.CreatedAt.Format("2006-01-02 15:04") }
|
|
}
|
|
</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
{{ bucketName, parseErr := s3tables.ParseBucketNameFromARN(catalog.ARN) }}
|
|
if parseErr == nil {
|
|
<a class="btn btn-outline-primary btn-sm" href={ dash.PUrl(ctx, fmt.Sprintf("/object-store/iceberg/%s/namespaces", url.PathEscape(bucketName))) } title="Browse Namespaces">
|
|
<i class="fas fa-folder-open"></i>
|
|
</a>
|
|
} else {
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" disabled title={ parseErr.Error() }>
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
</button>
|
|
}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
if len(data.Catalogs) == 0 {
|
|
<tr>
|
|
<td colspan="5" class="text-center text-muted py-4">
|
|
<i class="fas fa-snowflake fa-3x mb-3 text-muted"></i>
|
|
<div>
|
|
<h5>No catalogs available</h5>
|
|
<p>Create an S3 Table Bucket first to use as an Iceberg catalog.</p>
|
|
<a href={ dash.PUrl(ctx, "/object-store/s3tables/buckets") } class="btn btn-primary">
|
|
<i class="fas fa-plus me-1"></i>Create Table Bucket
|
|
</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Example Usage Card -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card shadow mb-4">
|
|
<div class="card-header py-3">
|
|
<h6 class="m-0 font-weight-bold text-primary">
|
|
<i class="fas fa-code me-2"></i>Example Usage
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<h6>DuckDB</h6>
|
|
<pre class="bg-light p-3 border rounded"><code>{ `-- Install and load Iceberg extension
|
|
INSTALL iceberg;
|
|
LOAD iceberg;
|
|
|
|
-- Create a catalog connection
|
|
CREATE SECRET (
|
|
TYPE ICEBERG,
|
|
ENDPOINT 'http://localhost:` + fmt.Sprintf("%d", data.IcebergPort) + `',
|
|
SCOPE 's3://my-table-bucket/'
|
|
);
|
|
|
|
-- Query tables
|
|
SELECT * FROM iceberg_scan('s3://my-table-bucket/my-namespace/my-table');` }</code></pre>
|
|
|
|
<h6 class="mt-4">Python (PyIceberg)</h6>
|
|
<pre class="bg-light p-3 border rounded"><code>{ `from pyiceberg.catalog import load_catalog
|
|
|
|
catalog = load_catalog(
|
|
name="seaweedfs",
|
|
**{
|
|
"type": "rest",
|
|
"uri": "http://localhost:` + fmt.Sprintf("%d", data.IcebergPort) + `",
|
|
"warehouse": "s3://my-table-bucket/",
|
|
}
|
|
)
|
|
|
|
# List namespaces
|
|
namespaces = catalog.list_namespaces()` }</code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|