Files
seaweedfs/weed/admin/view/app/s3tables_buckets.templ
T
Chris Lu c191b2fe01 iceberg: let clients select their table bucket as the catalog warehouse (#10549)
* 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.
2026-08-03 13:25:37 -07:00

402 lines
15 KiB
Templ

package app
import (
"fmt"
"net/url"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
templ S3TablesBuckets(data dash.S3TablesBucketsData) {
<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-table me-2"></i>S3 Tables Buckets
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createS3TablesBucketModal">
<i class="fas fa-plus me-1"></i>Create Bucket
</button>
</div>
if data.IcebergPort > 0 {
<div class="btn-group me-2">
<a id="s3tables-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-snowflake me-1"></i>Iceberg REST API
</a>
</div>
}
</div>
</div>
<div id="s3tables-buckets-content" data-iceberg-port={ fmt.Sprintf("%d", data.IcebergPort) }>
if data.IcebergPort > 0 {
<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="s3tables-iceberg-info" class="mb-0 mt-1" data-iceberg-port={ fmt.Sprintf("%d", data.IcebergPort) }>
Each table bucket is an Iceberg catalog. Connect clients to:
<code><span id="s3tables-iceberg-protocol">http</span>://<span id="s3tables-iceberg-host">localhost</span>:{ fmt.Sprintf("%d", data.IcebergPort) }/v1</code>
</p>
<script>
const s3tablesInfo = document.getElementById('s3tables-iceberg-info');
const s3tablesIcebergHost = window.location.hostname;
const s3tablesIcebergProtocol = window.location.protocol.slice(0, -1);
const s3tablesIcebergPort = s3tablesInfo ? s3tablesInfo.dataset.icebergPort : '';
document.getElementById('s3tables-iceberg-host').innerText = s3tablesIcebergHost;
const s3tablesIcebergProtocolEl = document.getElementById('s3tables-iceberg-protocol');
if (s3tablesIcebergProtocolEl) {
s3tablesIcebergProtocolEl.innerText = s3tablesIcebergProtocol;
}
const s3tablesRestLink = document.getElementById('s3tables-iceberg-rest-link');
if (s3tablesRestLink && s3tablesIcebergPort) {
s3tablesRestLink.href = `//${s3tablesIcebergHost}:${s3tablesIcebergPort}/v1/config`;
}
</script>
</div>
</div>
</div>
} else {
<div class="alert alert-warning mb-4">
<i class="fas fa-exclamation-triangle me-2"></i>Iceberg REST endpoint is disabled. Start `weed s3` with `-s3.port.iceberg` to enable it.
</div>
}
<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">
Total Buckets
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", data.TotalBuckets) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-table 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">
{ 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 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">
Iceberg REST Port
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.IcebergPort > 0 {
{ fmt.Sprintf("%d", data.IcebergPort) }
} else {
-
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-snowflake fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<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-table me-2"></i>Table Buckets
</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" width="100%" cellspacing="0" id="s3tablesBucketsTable">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>ARN</th>
<th>Iceberg Endpoint</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, bucket := range data.Buckets {
<tr>
<td>{ bucket.Name }</td>
<td>{ bucket.OwnerAccountID }</td>
<td class="text-muted small">{ bucket.ARN }</td>
<td><code class="small">/v1/{ bucket.Name }/namespaces</code></td>
<td>{ bucket.CreatedAt.Format("2006-01-02 15:04") }</td>
<td>
<div class="btn-group btn-group-sm" role="group">
{{ bucketName, parseErr := s3tables.ParseBucketNameFromARN(bucket.ARN) }}
if parseErr == nil {
<a class="btn btn-outline-primary btn-sm" href={ dash.PUrl(ctx, fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces", url.PathEscape(bucketName))) }>
<i class="fas fa-folder-open"></i>
</a>
} else {
<button type="button" class="btn btn-outline-primary btn-sm" disabled title="Invalid bucket ARN">
<i class="fas fa-folder-open"></i>
</button>
}
<button type="button" class="btn btn-outline-success btn-sm s3tables-tags-btn" data-resource-arn={ bucket.ARN } title="Tags">
<i class="fas fa-tags"></i>
</button>
<button type="button" class="btn btn-outline-info btn-sm s3tables-bucket-policy-btn" data-bucket-arn={ bucket.ARN } title="Bucket Policy">
<i class="fas fa-shield-alt"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm s3tables-delete-bucket-btn" data-bucket-arn={ bucket.ARN } data-bucket-name={ bucket.Name } title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
if len(data.Buckets) == 0 {
<tr>
<td colspan="6" class="text-center text-muted py-4">
<i class="fas fa-table fa-3x mb-3 text-muted"></i>
<div>
<h5>No table buckets found</h5>
<p>Create your first S3 Tables bucket to get started.</p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createS3TablesBucketModal">
<i class="fas fa-plus me-1"></i>Create Bucket
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
if data.IcebergPort > 0 {
<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>Iceberg Client Examples
</h6>
</div>
<div class="card-body">
<h6>DuckDB</h6>
<pre class="bg-light p-3 border rounded">
<code id="s3tables-duckdb-example">
{ `INSTALL iceberg;
LOAD iceberg;
CREATE SECRET (
TYPE ICEBERG,
ENDPOINT 'http://localhost:` + fmt.Sprintf("%d", data.IcebergPort) + `',
SCOPE 's3://my-table-bucket/'
);
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 id="s3tables-pyiceberg-example">
{ `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/",
}
)
namespaces = catalog.list_namespaces()` }
</code>
</pre>
<script>
const s3tablesExamplesHost = window.location.hostname;
const s3tablesExamples = [
document.getElementById('s3tables-duckdb-example'),
document.getElementById('s3tables-pyiceberg-example')
];
s3tablesExamples.forEach(example => {
if (!example) {
return;
}
example.textContent = example.textContent.replaceAll('localhost', s3tablesExamplesHost);
});
</script>
</div>
</div>
</div>
</div>
}
</div>
<div class="modal fade" id="createS3TablesBucketModal" tabindex="-1" aria-labelledby="createS3TablesBucketModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createS3TablesBucketModalLabel">
<i class="fas fa-plus me-2"></i>Create Table Bucket
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="createS3TablesBucketForm">
<div class="modal-body">
<div class="mb-3">
<label for="s3tablesBucketName" class="form-label">Bucket Name</label>
<input type="text" class="form-control" id="s3tablesBucketName" name="name" placeholder="table-bucket-name" required/>
</div>
<div class="mb-3">
<label for="s3tablesBucketOwner" class="form-label">Owner (Optional)</label>
<select class="form-select" id="s3tablesBucketOwner" name="owner">
<option value="">No owner (admin-only access)</option>
</select>
<div class="form-text">
The S3 identity that owns this table bucket. Non-admin users can only access table buckets they own.
</div>
</div>
<div class="mb-3">
<label for="s3tablesBucketTags" class="form-label">Tags</label>
<input type="text" class="form-control" id="s3tablesBucketTags" name="tags" placeholder="key1=value1,key2=value2"/>
<div class="form-text">Optional tags in key=value format.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>Create
</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="deleteS3TablesBucketModal" tabindex="-1" aria-labelledby="deleteS3TablesBucketModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteS3TablesBucketModalLabel">
<i class="fas fa-exclamation-triangle me-2 text-warning"></i>Delete Table Bucket
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the table bucket <strong id="deleteS3TablesBucketName"></strong>?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="deleteS3TablesBucket()">
<i class="fas fa-trash me-1"></i>Delete
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="s3tablesBucketPolicyModal" tabindex="-1" aria-labelledby="s3tablesBucketPolicyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="s3tablesBucketPolicyModalLabel">
<i class="fas fa-shield-alt me-2"></i>Table Bucket Policy
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="s3tablesBucketPolicyForm">
<div class="modal-body">
<input type="hidden" id="s3tablesBucketPolicyArn" name="bucket_arn"/>
<div class="mb-3">
<label for="s3tablesBucketPolicyText" class="form-label">Policy JSON</label>
<textarea class="form-control" id="s3tablesBucketPolicyText" name="policy" rows="12" placeholder="{ }"></textarea>
</div>
<div class="form-text">
Provide a policy JSON; use Delete Policy to remove the policy.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-danger" onclick="deleteS3TablesBucketPolicy()">
<i class="fas fa-trash me-1"></i>Delete Policy
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>Save Policy
</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="s3tablesTagsModal" tabindex="-1" aria-labelledby="s3tablesTagsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="s3tablesTagsModalLabel">
<i class="fas fa-tags me-2"></i>Resource Tags
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="s3tablesTagsForm">
<div class="modal-body">
<input type="hidden" id="s3tablesTagsResourceArn" name="resource_arn"/>
<div class="mb-3">
<label class="form-label">Existing Tags</label>
<pre class="bg-light p-3 border rounded" id="s3tablesTagsList">Loading...</pre>
</div>
<div class="mb-3">
<label for="s3tablesTagsInput" class="form-label">Add or Update Tags</label>
<input type="text" class="form-control" id="s3tablesTagsInput" placeholder="key1=value1,key2=value2"/>
</div>
<div class="mb-3">
<label for="s3tablesTagsDeleteInput" class="form-label">Remove Tag Keys</label>
<input type="text" class="form-control" id="s3tablesTagsDeleteInput" placeholder="key1,key2"/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-danger" onclick="deleteS3TablesTags()">
<i class="fas fa-trash me-1"></i>Remove Tags
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>Update Tags
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
initS3TablesBuckets();
});
</script>
}