s3tables: support multi-level namespace normalization
This commit is contained in:
@@ -63,6 +63,10 @@ type tableBucketMetadata struct {
|
||||
|
||||
const s3TablesAdminListLimit = 1000
|
||||
|
||||
func parseNamespaceInput(namespace string) ([]string, error) {
|
||||
return s3tables.ParseNamespace(namespace)
|
||||
}
|
||||
|
||||
func newS3TablesManager() *s3tables.Manager {
|
||||
manager := s3tables.NewManager()
|
||||
manager.SetAccountID(s3_constants.AccountAdminId)
|
||||
@@ -158,7 +162,11 @@ func (s *AdminServer) GetS3TablesTablesData(ctx context.Context, bucketArn, name
|
||||
var resp s3tables.ListTablesResponse
|
||||
var ns []string
|
||||
if namespace != "" {
|
||||
ns = []string{namespace}
|
||||
parts, err := parseNamespaceInput(namespace)
|
||||
if err != nil {
|
||||
return S3TablesTablesData{}, err
|
||||
}
|
||||
ns = parts
|
||||
}
|
||||
req := &s3tables.ListTablesRequest{TableBucketARN: bucketArn, Namespace: ns, MaxTables: s3TablesAdminListLimit}
|
||||
if err := s.executeS3TablesOperation(ctx, "ListTables", req, &resp); err != nil {
|
||||
@@ -257,9 +265,13 @@ func (s *AdminServer) GetIcebergTablesData(ctx context.Context, catalogName, buc
|
||||
// GetIcebergTableDetailsData returns Iceberg table metadata and snapshot information.
|
||||
func (s *AdminServer) GetIcebergTableDetailsData(ctx context.Context, catalogName, bucketArn, namespace, tableName string) (IcebergTableDetailsData, error) {
|
||||
var resp s3tables.GetTableResponse
|
||||
namespaceParts, err := parseNamespaceInput(namespace)
|
||||
if err != nil {
|
||||
return IcebergTableDetailsData{}, err
|
||||
}
|
||||
req := &s3tables.GetTableRequest{
|
||||
TableBucketARN: bucketArn,
|
||||
Namespace: []string{namespace},
|
||||
Namespace: namespaceParts,
|
||||
Name: tableName,
|
||||
}
|
||||
if err := s.executeS3TablesOperation(ctx, "GetTable", req, &resp); err != nil {
|
||||
@@ -686,7 +698,12 @@ func (s *AdminServer) CreateS3TablesNamespace(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "bucket_arn and name are required"})
|
||||
return
|
||||
}
|
||||
createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Name}}
|
||||
namespaceParts, err := parseNamespaceInput(req.Name)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "Invalid namespace: " + err.Error()})
|
||||
return
|
||||
}
|
||||
createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts}
|
||||
var resp s3tables.CreateNamespaceResponse
|
||||
if err := s.executeS3TablesOperation(c.Request.Context(), "CreateNamespace", createReq, &resp); err != nil {
|
||||
writeS3TablesError(c, err)
|
||||
@@ -705,7 +722,12 @@ func (s *AdminServer) DeleteS3TablesNamespace(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "bucket and name query parameters are required"})
|
||||
return
|
||||
}
|
||||
req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}}
|
||||
namespaceParts, err := parseNamespaceInput(namespace)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "Invalid namespace: " + err.Error()})
|
||||
return
|
||||
}
|
||||
req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: namespaceParts}
|
||||
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteNamespace", req, nil); err != nil {
|
||||
writeS3TablesError(c, err)
|
||||
return
|
||||
@@ -745,6 +767,11 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "bucket_arn, namespace, and name are required"})
|
||||
return
|
||||
}
|
||||
namespaceParts, err := parseNamespaceInput(req.Namespace)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "Invalid namespace: " + err.Error()})
|
||||
return
|
||||
}
|
||||
format := req.Format
|
||||
if format == "" {
|
||||
format = "ICEBERG"
|
||||
@@ -757,7 +784,7 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
|
||||
}
|
||||
createReq := &s3tables.CreateTableRequest{
|
||||
TableBucketARN: req.BucketARN,
|
||||
Namespace: []string{req.Namespace},
|
||||
Namespace: namespaceParts,
|
||||
Name: req.Name,
|
||||
Format: format,
|
||||
Tags: req.Tags,
|
||||
@@ -780,7 +807,12 @@ func (s *AdminServer) DeleteS3TablesTable(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
|
||||
return
|
||||
}
|
||||
req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name, VersionToken: version}
|
||||
namespaceParts, err := parseNamespaceInput(namespace)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "Invalid namespace: " + err.Error()})
|
||||
return
|
||||
}
|
||||
req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name, VersionToken: version}
|
||||
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTable", req, nil); err != nil {
|
||||
writeS3TablesError(c, err)
|
||||
return
|
||||
@@ -853,7 +885,12 @@ func (s *AdminServer) PutS3TablesTablePolicy(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "bucket_arn, namespace, name, and policy are required"})
|
||||
return
|
||||
}
|
||||
putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Namespace}, Name: req.Name, ResourcePolicy: req.Policy}
|
||||
namespaceParts, err := parseNamespaceInput(req.Namespace)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "Invalid namespace: " + err.Error()})
|
||||
return
|
||||
}
|
||||
putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts, Name: req.Name, ResourcePolicy: req.Policy}
|
||||
if err := s.executeS3TablesOperation(c.Request.Context(), "PutTablePolicy", putReq, nil); err != nil {
|
||||
writeS3TablesError(c, err)
|
||||
return
|
||||
@@ -869,7 +906,12 @@ func (s *AdminServer) GetS3TablesTablePolicy(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
|
||||
return
|
||||
}
|
||||
getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name}
|
||||
namespaceParts, err := parseNamespaceInput(namespace)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "Invalid namespace: " + err.Error()})
|
||||
return
|
||||
}
|
||||
getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
|
||||
var resp s3tables.GetTablePolicyResponse
|
||||
if err := s.executeS3TablesOperation(c.Request.Context(), "GetTablePolicy", getReq, &resp); err != nil {
|
||||
writeS3TablesError(c, err)
|
||||
@@ -886,7 +928,12 @@ func (s *AdminServer) DeleteS3TablesTablePolicy(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
|
||||
return
|
||||
}
|
||||
deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name}
|
||||
namespaceParts, err := parseNamespaceInput(namespace)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "Invalid namespace: " + err.Error()})
|
||||
return
|
||||
}
|
||||
deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
|
||||
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil {
|
||||
writeS3TablesError(c, err)
|
||||
return
|
||||
|
||||
@@ -725,20 +725,30 @@ function s3TablesNamespaceNameError(name) {
|
||||
if (name.includes('/')) {
|
||||
return "namespace name cannot contain '/'";
|
||||
}
|
||||
if (!isLowercaseLetterOrDigit(name[0])) {
|
||||
return 'Namespace name must start with a letter or digit';
|
||||
}
|
||||
if (!isLowercaseLetterOrDigit(name[name.length - 1])) {
|
||||
return 'Namespace name must end with a letter or digit';
|
||||
}
|
||||
for (const ch of name) {
|
||||
if (isLowercaseLetterOrDigit(ch) || ch === '_') {
|
||||
continue;
|
||||
|
||||
const parts = name.split('.');
|
||||
for (const part of parts) {
|
||||
if (!part) {
|
||||
return 'namespace levels cannot be empty';
|
||||
}
|
||||
if (part.length < 1 || part.length > 255) {
|
||||
return 'Namespace name must be between 1 and 255 characters';
|
||||
}
|
||||
if (!isLowercaseLetterOrDigit(part[0])) {
|
||||
return 'Namespace name must start with a letter or digit';
|
||||
}
|
||||
if (!isLowercaseLetterOrDigit(part[part.length - 1])) {
|
||||
return 'Namespace name must end with a letter or digit';
|
||||
}
|
||||
for (const ch of part) {
|
||||
if (isLowercaseLetterOrDigit(ch) || ch === '_') {
|
||||
continue;
|
||||
}
|
||||
return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
|
||||
}
|
||||
if (part.startsWith('aws')) {
|
||||
return "namespace name cannot start with reserved prefix 'aws'";
|
||||
}
|
||||
return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
|
||||
}
|
||||
if (name.startsWith('aws')) {
|
||||
return "namespace name cannot start with reserved prefix 'aws'";
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ templ IcebergNamespaces(data dash.IcebergNamespacesData) {
|
||||
<div class="mb-3">
|
||||
<label for="icebergNamespaceName" class="form-label">Namespace</label>
|
||||
<input type="text" class="form-control" id="icebergNamespaceName" name="name" placeholder="analytics" required/>
|
||||
<div class="form-text">Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.</div>
|
||||
<div class="form-text">Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -327,7 +327,7 @@ func IcebergNamespaces(data dash.IcebergNamespacesData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"> <input type=\"hidden\" id=\"icebergNamespaceCsrfToken\" name=\"csrf_token\"><div class=\"mb-3\"><label for=\"icebergNamespaceName\" class=\"form-label\">Namespace</label> <input type=\"text\" class=\"form-control\" id=\"icebergNamespaceName\" name=\"name\" placeholder=\"analytics\" required><div class=\"form-text\">Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.</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><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitIcebergNamespaces();\n\t\t});\n\t</script>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"> <input type=\"hidden\" id=\"icebergNamespaceCsrfToken\" name=\"csrf_token\"><div class=\"mb-3\"><label for=\"icebergNamespaceName\" class=\"form-label\">Namespace</label> <input type=\"text\" class=\"form-control\" id=\"icebergNamespaceName\" name=\"name\" placeholder=\"analytics\" required><div class=\"form-text\">Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).</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><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitIcebergNamespaces();\n\t\t});\n\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -154,14 +154,14 @@ templ S3TablesNamespaces(data dash.S3TablesNamespacesData) {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="createS3TablesNamespaceForm">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="bucket_arn" value={ data.BucketARN }/>
|
||||
<div class="mb-3">
|
||||
<label for="s3tablesNamespaceName" class="form-label">Namespace</label>
|
||||
<input type="text" class="form-control" id="s3tablesNamespaceName" name="name" placeholder="analytics" required/>
|
||||
<div class="form-text">Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="bucket_arn" value={ data.BucketARN }/>
|
||||
<div class="mb-3">
|
||||
<label for="s3tablesNamespaceName" class="form-label">Namespace</label>
|
||||
<input type="text" class="form-control" id="s3tablesNamespaceName" name="name" placeholder="analytics" required/>
|
||||
<div class="form-text">Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).</div>
|
||||
</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">
|
||||
@@ -235,41 +235,53 @@ templ S3TablesNamespaces(data dash.S3TablesNamespacesData) {
|
||||
input.setCustomValidity('');
|
||||
return '';
|
||||
}
|
||||
let message = '';
|
||||
if (!name) {
|
||||
message = 'Namespace name is required';
|
||||
} else if (name.length < 1 || name.length > 255) {
|
||||
message = 'Namespace name must be between 1 and 255 characters';
|
||||
} else if (name === '.' || name === '..') {
|
||||
message = "namespace name cannot be '.' or '..'";
|
||||
} else if (name.includes('/')) {
|
||||
message = "namespace name cannot contain '/'";
|
||||
} else {
|
||||
const start = name[0];
|
||||
const end = name[name.length - 1];
|
||||
const isStartValid = (start >= 'a' && start <= 'z') || (start >= '0' && start <= '9');
|
||||
const isEndValid = (end >= 'a' && end <= 'z') || (end >= '0' && end <= '9');
|
||||
if (!isStartValid) {
|
||||
message = 'Namespace name must start with a letter or digit';
|
||||
} else if (!isEndValid) {
|
||||
message = 'Namespace name must end with a letter or digit';
|
||||
let message = '';
|
||||
if (!name) {
|
||||
message = 'Namespace name is required';
|
||||
} else if (name.length < 1 || name.length > 255) {
|
||||
message = 'Namespace name must be between 1 and 255 characters';
|
||||
} else if (name === '.' || name === '..') {
|
||||
message = "namespace name cannot be '.' or '..'";
|
||||
} else if (name.includes('/')) {
|
||||
message = "namespace name cannot contain '/'";
|
||||
} else {
|
||||
for (const ch of name) {
|
||||
const isLower = ch >= 'a' && ch <= 'z';
|
||||
const isDigit = ch >= '0' && ch <= '9';
|
||||
if (!(isLower || isDigit || ch === '_')) {
|
||||
message = "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
|
||||
for (const part of name.split('.')) {
|
||||
if (!part) {
|
||||
message = 'namespace levels cannot be empty';
|
||||
break;
|
||||
}
|
||||
const start = part[0];
|
||||
const end = part[part.length - 1];
|
||||
const isStartValid = (start >= 'a' && start <= 'z') || (start >= '0' && start <= '9');
|
||||
const isEndValid = (end >= 'a' && end <= 'z') || (end >= '0' && end <= '9');
|
||||
if (!isStartValid) {
|
||||
message = 'Namespace name must start with a letter or digit';
|
||||
break;
|
||||
}
|
||||
if (!isEndValid) {
|
||||
message = 'Namespace name must end with a letter or digit';
|
||||
break;
|
||||
}
|
||||
for (const ch of part) {
|
||||
const isLower = ch >= 'a' && ch <= 'z';
|
||||
const isDigit = ch >= '0' && ch <= '9';
|
||||
if (!(isLower || isDigit || ch === '_')) {
|
||||
message = "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (message) {
|
||||
break;
|
||||
}
|
||||
if (part.startsWith('aws')) {
|
||||
message = "namespace name cannot start with reserved prefix 'aws'";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!message && name.startsWith('aws')) {
|
||||
message = "namespace name cannot start with reserved prefix 'aws'";
|
||||
}
|
||||
}
|
||||
input.setCustomValidity(message);
|
||||
return message;
|
||||
}
|
||||
input.setCustomValidity(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
s3tablesNamespaceDeleteModalInstance = new bootstrap.Modal(document.getElementById('deleteS3TablesNamespaceModal'));
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user