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)
