This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch provisioner-integration
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 02649849b88c3ac968fe6c5ec41beab3bc342271
Author: lahiruj <[email protected]>
AuthorDate: Tue Jun 2 03:01:03 2026 -0400

    Add posix username generation and per-cluster unique local_username
---
 .../db/migrations/000002_compute_clusters.up.sql   |   1 +
 pkg/posix/username.go                              |  79 +++++++++++++
 pkg/posix/username_test.go                         | 129 +++++++++++++++++++++
 pkg/service/compute_cluster_user.go                |  19 ++-
 4 files changed, 227 insertions(+), 1 deletion(-)

diff --git a/internal/db/migrations/000002_compute_clusters.up.sql 
b/internal/db/migrations/000002_compute_clusters.up.sql
index f0c268482..c150758db 100644
--- a/internal/db/migrations/000002_compute_clusters.up.sql
+++ b/internal/db/migrations/000002_compute_clusters.up.sql
@@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS compute_cluster_users
     updated_at         TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON 
UPDATE CURRENT_TIMESTAMP(6),
     PRIMARY KEY (id),
     UNIQUE KEY uq_compute_cluster_users_pair (compute_cluster_id, user_id),
+    UNIQUE KEY uq_compute_cluster_users_local_username (compute_cluster_id, 
local_username),
     KEY idx_compute_cluster_users_user (user_id),
     CONSTRAINT fk_compute_cluster_users_cluster FOREIGN KEY 
(compute_cluster_id) REFERENCES compute_clusters (id) ON DELETE CASCADE,
     CONSTRAINT fk_compute_cluster_users_user FOREIGN KEY (user_id) REFERENCES 
users (id) ON DELETE CASCADE
diff --git a/pkg/posix/username.go b/pkg/posix/username.go
new file mode 100644
index 000000000..64b00b5b8
--- /dev/null
+++ b/pkg/posix/username.go
@@ -0,0 +1,79 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package posix builds and validates POSIX-conformant usernames for HPC
+// account provisioning.
+package posix
+
+import (
+       "os"
+       "strings"
+       "unicode"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+const MaxCollisionSuffix = 999
+
+// BuildBase returns the unsuffixed username and a flag set when the name
+// portion was truncated to fit the 32-char Unix login limit.
+func BuildBase(u *models.User, prefix string) (string, bool) {
+       first := Normalize(u.FirstName)
+       last := Normalize(u.LastName)
+
+       var local string
+       switch {
+       case first != "" && last != "":
+               local = string(first[0]) + last
+       case last != "":
+               local = last
+       case first != "":
+               local = first
+       default:
+               local = "user"
+       }
+
+       // Reserve 3 chars for a numeric collision suffix (up to "999").
+       maxLocal := 32 - len(prefix) - 1 - 3
+       truncated := false
+       if len(local) > maxLocal {
+               local = local[:maxLocal]
+               truncated = true
+       }
+       return prefix + "-" + local, truncated
+}
+
+func Normalize(s string) string {
+       s = strings.ToLower(s)
+       var b strings.Builder
+       for _, r := range s {
+               if r > unicode.MaxASCII {
+                       continue
+               }
+               if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+                       b.WriteRune(r)
+               }
+       }
+       return b.String()
+}
+
+func Prefix() string {
+       if v := os.Getenv("POSIX_USERNAME_PREFIX"); v != "" {
+               return v
+       }
+       return "custos"
+}
diff --git a/pkg/posix/username_test.go b/pkg/posix/username_test.go
new file mode 100644
index 000000000..a8601dad2
--- /dev/null
+++ b/pkg/posix/username_test.go
@@ -0,0 +1,129 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package posix
+
+import (
+       "strings"
+       "testing"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+func TestBuildBase(t *testing.T) {
+       tests := []struct {
+               name      string
+               first     string
+               middle    string
+               last      string
+               prefix    string
+               wantBase  string
+               wantTrunc bool
+       }{
+               {
+                       name:  "standard",
+                       first: "Alice", last: "Smith", prefix: "custos",
+                       wantBase: "custos-asmith", wantTrunc: false,
+               },
+               {
+                       name:  "middle ignored",
+                       first: "Alice", middle: "Marie", last: "Smith", prefix: 
"custos",
+                       wantBase: "custos-asmith", wantTrunc: false,
+               },
+               {
+                       name:  "single name as last",
+                       first: "", last: "Madonna", prefix: "custos",
+                       wantBase: "custos-madonna", wantTrunc: false,
+               },
+               {
+                       name:  "first only",
+                       first: "Alice", last: "", prefix: "custos",
+                       wantBase: "custos-alice", wantTrunc: false,
+               },
+               {
+                       name: "non-ASCII stripped",
+                       // "Aña" normalizes to "aa"; first letter 'a' + "kili" 
(from "Şəkili")
+                       first: "Aña", last: "Şəkili", prefix: "custos",
+                       wantBase: "custos-akili", wantTrunc: false,
+               },
+               {
+                       name:  "prefix swap",
+                       first: "Alice", last: "Smith", prefix: "nexus",
+                       wantBase: "nexus-asmith", wantTrunc: false,
+               },
+               {
+                       name:  "truncation at 32-char limit",
+                       first: "L", last: strings.Repeat("a", 50), prefix: 
"custos",
+                       wantTrunc: true,
+               },
+       }
+
+       for _, tc := range tests {
+               t.Run(tc.name, func(t *testing.T) {
+                       u := &models.User{FirstName: tc.first, MiddleName: 
tc.middle, LastName: tc.last}
+                       got, trunc := BuildBase(u, tc.prefix)
+
+                       if trunc != tc.wantTrunc {
+                               t.Errorf("truncated = %v, want %v", trunc, 
tc.wantTrunc)
+                       }
+                       if tc.wantBase != "" && got != tc.wantBase {
+                               t.Errorf("base = %q, want %q", got, tc.wantBase)
+                       }
+                       if len(got) > 32 {
+                               t.Errorf("base len %d > 32: %q", len(got), got)
+                       }
+                       if !strings.HasPrefix(got, tc.prefix+"-") {
+                               t.Errorf("base %q does not start with prefix 
%q", got, tc.prefix+"-")
+                       }
+               })
+       }
+}
+
+func TestNormalize(t *testing.T) {
+       tests := []struct {
+               in   string
+               want string
+       }{
+               {"Alice", "alice"},
+               {"ALLCAPS", "allcaps"},
+               {"abc123", "abc123"},
+               {"Aña", "aa"},
+               {"Şəkili", "kili"},
+               {"hello-world", "helloworld"},
+               {"O'Brien", "obrien"},
+               {"", ""},
+       }
+       for _, tc := range tests {
+               t.Run(tc.in, func(t *testing.T) {
+                       got := Normalize(tc.in)
+                       if got != tc.want {
+                               t.Errorf("Normalize(%q) = %q, want %q", tc.in, 
got, tc.want)
+                       }
+               })
+       }
+}
+
+func TestPrefix(t *testing.T) {
+       t.Setenv("POSIX_USERNAME_PREFIX", "")
+       if got := Prefix(); got != "custos" {
+               t.Errorf("default = %q, want %q", got, "custos")
+       }
+       t.Setenv("POSIX_USERNAME_PREFIX", "nexus")
+       if got := Prefix(); got != "nexus" {
+               t.Errorf("override = %q, want %q", got, "nexus")
+       }
+}
diff --git a/pkg/service/compute_cluster_user.go 
b/pkg/service/compute_cluster_user.go
index b8311bbf4..0aeca1f27 100644
--- a/pkg/service/compute_cluster_user.go
+++ b/pkg/service/compute_cluster_user.go
@@ -21,6 +21,7 @@ import (
        "context"
        "database/sql"
        "fmt"
+       "strings"
 
        "github.com/apache/airavata-custos/pkg/events"
        "github.com/apache/airavata-custos/pkg/models"
@@ -67,13 +68,29 @@ func (s *Service) CreateComputeClusterUser(ctx 
context.Context, cu *models.Compu
        if err := s.inTx(ctx, func(tx *sql.Tx) error {
                return s.clusterUsers.Create(ctx, tx, cu)
        }); err != nil {
-               return nil, fmt.Errorf("create compute cluster user: %w", err)
+               switch {
+               case isLocalUsernameDuplicate(err):
+                       return nil, fmt.Errorf("%w: %s", ErrAlreadyExists, 
cu.LocalUsername)
+               case isPairDuplicate(err):
+                       return nil, fmt.Errorf("%w: user %q is already mapped 
on cluster %q",
+                               ErrAlreadyExists, cu.UserID, 
cu.ComputeClusterID)
+               default:
+                       return nil, fmt.Errorf("create compute cluster user: 
%w", err)
+               }
        }
 
        s.eventBus.Publish(events.ComputeClusterUserCreateEvent, cu)
        return cu, nil
 }
 
+func isLocalUsernameDuplicate(err error) bool {
+       return err != nil && strings.Contains(err.Error(), 
"uq_compute_cluster_users_local_username")
+}
+
+func isPairDuplicate(err error) bool {
+       return err != nil && strings.Contains(err.Error(), 
"uq_compute_cluster_users_pair")
+}
+
 // GetComputeClusterUser retrieves a compute-cluster user by its ID.
 func (s *Service) GetComputeClusterUser(ctx context.Context, id string) 
(*models.ComputeClusterUser, error) {
        c, err := s.clusterUsers.FindByID(ctx, id)

Reply via email to