You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ya...@apache.org on 2026/09/30 23:11:28 UTC
(airavata-custos) 01/01: keep posix group and its provisioned status per compute allocation
This is an automated email from the ASF dual-hosted git repository.
yasithdev pushed a commit to branch vast-core
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit b71e6820877f5576ba1ae077aeb4a8fcddf2fcb4
Author: Yasith Jayawardana <ya...@icloud.com>
AuthorDate: Wed Sep 30 19:11:11 2026 -0400
keep posix group and its provisioned status per compute allocation
---
api/core.openapi.yaml | 10 ++-
.../Identity-Provisioner/config.example.yaml | 1 +
internal/db/migrations/000004_allocations.up.sql | 3 +
internal/store/compute_allocation_store.go | 14 +++-
internal/store/compute_cluster_user_store.go | 31 +++++++-
internal/store/store.go | 9 ++-
pkg/models/allocation.go | 18 +++--
pkg/posix/username.go | 40 +++++++++-
pkg/posix/username_test.go | 33 ++++++++
pkg/service/compute_allocation.go | 53 ++++++++++++-
pkg/service/compute_allocation_integration_test.go | 87 ++++++++++++++++++++++
pkg/service/compute_cluster_user.go | 54 ++++++++++----
.../compute_cluster_user_integration_test.go | 78 ++++++++++++++++++-
pkg/service/user_onboard.go | 4 +-
web/src/generated/core/types.gen.ts | 12 ++-
web/src/generated/core/zod.gen.ts | 2 +
16 files changed, 402 insertions(+), 47 deletions(-)
diff --git a/api/core.openapi.yaml b/api/core.openapi.yaml
index e9393567b..d09e632f6 100644
--- a/api/core.openapi.yaml
+++ b/api/core.openapi.yaml
@@ -120,7 +120,7 @@ definitions:
reviewed_at:
type: string
reviewed_by:
- description: The admin who approved or denied the account.
+ description: The admin who approved or denied the cluster account.
type: string
user_id:
type: string
@@ -139,6 +139,12 @@ definitions:
type: integer
name:
type: string
+ posix_group:
+ description: Fixed at creation; also the directory and quota name.
+ type: string
+ posix_group_provisioned_at:
+ description: When the group was provisioned into the registry; nil until then.
+ type: string
project_id:
type: string
start_time:
@@ -405,7 +411,7 @@ definitions:
reviewed_at:
type: string
reviewed_by:
- description: The admin who approved or denied the account.
+ description: The admin who approved or denied the cluster account.
type: string
user_id:
type: string
diff --git a/connectors/COmanage/Identity-Provisioner/config.example.yaml b/connectors/COmanage/Identity-Provisioner/config.example.yaml
index 7f4edb5e2..5350cb42b 100644
--- a/connectors/COmanage/Identity-Provisioner/config.example.yaml
+++ b/connectors/COmanage/Identity-Provisioner/config.example.yaml
@@ -35,3 +35,4 @@
# COMANAGE_HOMEDIR_PREFIX=/home/
# COMANAGE_HTTP_TIMEOUT=30s
# POSIX_USERNAME_PREFIX=custos # read by pkg/posix; default "custos"
+# POSIX_GROUP_PREFIX=proj # read by pkg/posix; default "proj"
diff --git a/internal/db/migrations/000004_allocations.up.sql b/internal/db/migrations/000004_allocations.up.sql
index 31ef1fdc8..cd365400e 100644
--- a/internal/db/migrations/000004_allocations.up.sql
+++ b/internal/db/migrations/000004_allocations.up.sql
@@ -25,9 +25,12 @@ CREATE TABLE IF NOT EXISTS compute_allocations
initial_su_amount BIGINT NOT NULL DEFAULT 0,
start_time TIMESTAMPTZ(6) NOT NULL,
end_time TIMESTAMPTZ(6) NOT NULL,
+ posix_group VARCHAR(32) NULL,
+ posix_group_provisioned_at TIMESTAMPTZ(6) NULL,
created_at TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
+ CONSTRAINT uq_compute_allocations_cluster_posix_group UNIQUE (compute_cluster_id, posix_group),
CONSTRAINT fk_compute_allocations_project FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE RESTRICT,
CONSTRAINT fk_compute_allocations_cluster FOREIGN KEY (compute_cluster_id) REFERENCES compute_clusters (id) ON DELETE RESTRICT
);
diff --git a/internal/store/compute_allocation_store.go b/internal/store/compute_allocation_store.go
index e6b76784b..4aff16afc 100644
--- a/internal/store/compute_allocation_store.go
+++ b/internal/store/compute_allocation_store.go
@@ -22,6 +22,7 @@ import (
"database/sql"
"errors"
"strings"
+ "time"
"github.com/jmoiron/sqlx"
@@ -37,7 +38,7 @@ func NewComputeAllocationStore(db *sqlx.DB) ComputeAllocationStore {
return &pgComputeAllocationStore{db: db}
}
-const computeAllocationColumns = `id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time`
+const computeAllocationColumns = `id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time, posix_group, posix_group_provisioned_at`
func (s *pgComputeAllocationStore) FindByID(ctx context.Context, id string) (*models.ComputeAllocation, error) {
var a models.ComputeAllocation
@@ -74,12 +75,17 @@ func (s *pgComputeAllocationStore) FindByCluster(ctx context.Context, clusterID
func (s *pgComputeAllocationStore) Create(ctx context.Context, tx *sql.Tx, a *models.ComputeAllocation) error {
_, err := tx.ExecContext(ctx,
- `INSERT INTO compute_allocations (`+computeAllocationColumns+`)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
- a.ID, a.ProjectID, a.Name, string(a.Status), a.ComputeClusterID, a.InitialSUAmount, a.StartTime, a.EndTime)
+ `INSERT INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time, posix_group)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+ a.ID, a.ProjectID, a.Name, string(a.Status), a.ComputeClusterID, a.InitialSUAmount, a.StartTime, a.EndTime, a.PosixGroup)
return err
}
+func (s *pgComputeAllocationStore) MarkGroupProvisioned(ctx context.Context, tx *sql.Tx, id string) (*time.Time, error) {
+ return markOnce(ctx, tx, `UPDATE compute_allocations SET posix_group_provisioned_at = NOW()
+ WHERE id = $1 AND posix_group_provisioned_at IS NULL RETURNING posix_group_provisioned_at`, id)
+}
+
func (s *pgComputeAllocationStore) Update(ctx context.Context, tx *sql.Tx, a *models.ComputeAllocation) error {
_, err := tx.ExecContext(ctx,
`UPDATE compute_allocations
diff --git a/internal/store/compute_cluster_user_store.go b/internal/store/compute_cluster_user_store.go
index 5769a81c8..3debb8cc4 100644
--- a/internal/store/compute_cluster_user_store.go
+++ b/internal/store/compute_cluster_user_store.go
@@ -171,9 +171,34 @@ func (s *pgComputeClusterUserStore) Update(ctx context.Context, tx *sql.Tx, c *m
return err
}
-func (s *pgComputeClusterUserStore) MarkProvisioned(ctx context.Context, tx *sql.Tx, id string) error {
- _, err := tx.ExecContext(ctx, `UPDATE compute_cluster_users SET provisioned_at = NOW() WHERE id = $1`, id)
- return err
+func (s *pgComputeClusterUserStore) MarkProvisioned(ctx context.Context, tx *sql.Tx, id string) (*time.Time, error) {
+ return markOnce(ctx, tx, `UPDATE compute_cluster_users SET provisioned_at = NOW()
+ WHERE id = $1 AND provisioned_at IS NULL RETURNING provisioned_at`, id)
+}
+
+func markOnce(ctx context.Context, tx *sql.Tx, query, id string) (*time.Time, error) {
+ var at time.Time
+ if err := tx.QueryRowContext(ctx, query, id).Scan(&at); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return &at, nil
+}
+
+func (s *pgComputeClusterUserStore) FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeClusterUser, error) {
+ var rows []models.ComputeClusterUser
+ err := s.db.SelectContext(ctx, &rows,
+ `SELECT `+computeClusterUserColumns+` FROM compute_cluster_users cu
+ WHERE approval_status = 'APPROVED' AND provisioned_at IS NOT NULL
+ AND EXISTS (SELECT 1 FROM compute_allocations a
+ JOIN projects p ON p.id = a.project_id
+ JOIN compute_allocation_memberships m ON m.compute_allocation_id = a.id
+ WHERE a.id = $1 AND a.compute_cluster_id = cu.compute_cluster_id AND m.user_id = cu.user_id
+ AND a.status = 'ACTIVE' AND p.status = 'ACTIVE' AND m.membership_status = 'ACTIVE')
+ ORDER BY local_username`, allocationID)
+ return rows, err
}
func (s *pgComputeClusterUserStore) ReassignUser(ctx context.Context, tx *sql.Tx, fromUserID, toUserID string) error {
diff --git a/internal/store/store.go b/internal/store/store.go
index 433834cba..84e5b8893 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -101,8 +101,11 @@ type ComputeClusterUserStore interface {
Review(ctx context.Context, tx *sql.Tx, id string, status models.ClusterAccountApproval, reviewedBy, note string, at time.Time) error
// Update replaces mutable fields of an existing mapping within the provided transaction.
Update(ctx context.Context, tx *sql.Tx, c *models.ComputeClusterUser) error
- // MarkProvisioned stamps provisioned_at on the mapping within the provided transaction.
- MarkProvisioned(ctx context.Context, tx *sql.Tx, id string) error
+ // MarkProvisioned stamps provisioned_at if unset, returning the stamp or nil.
+ MarkProvisioned(ctx context.Context, tx *sql.Tx, id string) (*time.Time, error)
+ // FindByAllocation returns the allocation's POSIX group members: approved, provisioned accounts with an active
+ // membership, while the allocation and its project are active.
+ FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeClusterUser, error)
// ReassignUser moves every mapping owned by fromUserID over to toUserID,
// dropping fromUserID's rows on clusters where toUserID already has one.
ReassignUser(ctx context.Context, tx *sql.Tx, fromUserID, toUserID string) error
@@ -186,6 +189,8 @@ type ComputeAllocationStore interface {
FindByCluster(ctx context.Context, clusterID string) ([]models.ComputeAllocation, error)
// Create inserts a new allocation within the provided transaction.
Create(ctx context.Context, tx *sql.Tx, a *models.ComputeAllocation) error
+ // MarkGroupProvisioned stamps posix_group_provisioned_at if unset, returning the stamp or nil.
+ MarkGroupProvisioned(ctx context.Context, tx *sql.Tx, id string) (*time.Time, error)
// Update replaces mutable fields of an existing allocation within the provided transaction.
Update(ctx context.Context, tx *sql.Tx, a *models.ComputeAllocation) error
// Delete removes an allocation by ID within the provided transaction.
diff --git a/pkg/models/allocation.go b/pkg/models/allocation.go
index 675815cfc..f0268d260 100644
--- a/pkg/models/allocation.go
+++ b/pkg/models/allocation.go
@@ -65,14 +65,16 @@ type ComputeClusterUser struct {
}
type ComputeAllocation struct {
- ID string `json:"id" db:"id"`
- ProjectID string `json:"project_id" db:"project_id"`
- Name string `json:"name" db:"name"`
- Status AllocationStatus `json:"status" db:"status"` // ACTIVE, INACTIVE, DELETED, etc.
- ComputeClusterID string `json:"compute_cluster_id" db:"compute_cluster_id"` // The ID of the compute cluster where the allocation is provisioned.
- InitialSUAmount int64 `json:"initial_su_amount" db:"initial_su_amount"` // SUs allocated at the time of allocation creation.
- StartTime time.Time `json:"start_time" db:"start_time"`
- EndTime time.Time `json:"end_time" db:"end_time"`
+ ID string `json:"id" db:"id"`
+ ProjectID string `json:"project_id" db:"project_id"`
+ Name string `json:"name" db:"name"`
+ Status AllocationStatus `json:"status" db:"status"` // ACTIVE, INACTIVE, DELETED, etc.
+ ComputeClusterID string `json:"compute_cluster_id" db:"compute_cluster_id"` // The ID of the compute cluster where the allocation is provisioned.
+ InitialSUAmount int64 `json:"initial_su_amount" db:"initial_su_amount"` // SUs allocated at the time of allocation creation.
+ StartTime time.Time `json:"start_time" db:"start_time"`
+ EndTime time.Time `json:"end_time" db:"end_time"`
+ PosixGroup *string `json:"posix_group" db:"posix_group"` // Fixed at creation; also the directory and quota name.
+ PosixGroupProvisionedAt *time.Time `json:"posix_group_provisioned_at" db:"posix_group_provisioned_at"` // When the group was provisioned into the registry; nil until then.
}
// Typically store the a paritition information
diff --git a/pkg/posix/username.go b/pkg/posix/username.go
index 2e60f5e9f..549b493f3 100644
--- a/pkg/posix/username.go
+++ b/pkg/posix/username.go
@@ -15,8 +15,8 @@
// specific language governing permissions and limitations
// under the License.
-// Package posix builds and validates POSIX-conformant usernames for HPC
-// account provisioning.
+// Package posix builds and validates POSIX-conformant usernames and group
+// names for HPC account provisioning.
package posix
import (
@@ -36,6 +36,8 @@ const MaxLoginLen = 32
var ErrUnbuildableUsername = errors.New("posix: cannot build username from empty first and last name")
+var ErrUnbuildableGroupName = errors.New("posix: cannot build group name from an allocation name without letters or digits")
+
// BuildBase returns the unsuffixed username. 'truncated' is set when the name
// portion was shortened to fit the 32-char POSIX login cap.
func BuildBase(u *models.User, prefix string) (string, bool, error) {
@@ -53,7 +55,21 @@ func BuildBase(u *models.User, prefix string) (string, bool, error) {
default:
return "", false, fmt.Errorf("%w: user %q (first=%q last=%q)", ErrUnbuildableUsername, u.ID, u.FirstName, u.LastName)
}
+ base, truncated := join(prefix, local)
+ return base, truncated, nil
+}
+// BuildGroupBase returns the unsuffixed group name of an allocation, built like BuildBase.
+func BuildGroupBase(name, prefix string) (string, bool, error) {
+ local := Normalize(name)
+ if local == "" {
+ return "", false, fmt.Errorf("%w: %q", ErrUnbuildableGroupName, name)
+ }
+ base, truncated := join(prefix, local)
+ return base, truncated, nil
+}
+
+func join(prefix, local string) (string, bool) {
// -1 separator, -3 reserved for collision suffix (up to "999").
maxLocal := MaxLoginLen - len(prefix) - 1 - 3
truncated := false
@@ -61,7 +77,18 @@ func BuildBase(u *models.User, prefix string) (string, bool, error) {
local = local[:maxLocal]
truncated = true
}
- return prefix + "-" + local, truncated, nil
+ return prefix + "-" + local, truncated
+}
+
+// ValidateUsername rejects usernames over the POSIX cap or under the group prefix.
+func ValidateUsername(name string) error {
+ if len(name) > MaxLoginLen {
+ return fmt.Errorf("posix: username %q exceeds %d characters", name, MaxLoginLen)
+ }
+ if strings.HasPrefix(name, GroupPrefix()+"-") {
+ return fmt.Errorf("posix: username %q uses the group prefix %q", name, GroupPrefix())
+ }
+ return nil
}
func Normalize(s string) string {
@@ -84,3 +111,10 @@ func Prefix() string {
}
return "custos"
}
+
+func GroupPrefix() string {
+ if v := os.Getenv("POSIX_GROUP_PREFIX"); v != "" {
+ return v
+ }
+ return "proj"
+}
diff --git a/pkg/posix/username_test.go b/pkg/posix/username_test.go
index 95cb6b8f2..800fc01e9 100644
--- a/pkg/posix/username_test.go
+++ b/pkg/posix/username_test.go
@@ -154,3 +154,36 @@ func TestPrefix(t *testing.T) {
t.Errorf("override = %q, want %q", got, "alt")
}
}
+
+func TestBuildGroupBase(t *testing.T) {
+ tests := []struct {
+ name, in, wantBase string
+ wantTrunc bool
+ }{
+ {name: "grant number", in: "CIS250123", wantBase: "proj-cis250123"},
+ {name: "punctuation stripped", in: "Aurora GPU / Mixed", wantBase: "proj-auroragpumixed"},
+ {name: "truncated", in: "abcdefghijklmnopqrstuvwxyz0123456789", wantBase: "proj-abcdefghijklmnopqrstuvwx", wantTrunc: true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, trunc, err := BuildGroupBase(tc.in, "proj")
+ if err != nil || got != tc.wantBase || trunc != tc.wantTrunc {
+ t.Errorf("BuildGroupBase(%q) = %q, %v, %v; want %q, %v", tc.in, got, trunc, err, tc.wantBase, tc.wantTrunc)
+ }
+ })
+ }
+ if _, _, err := BuildGroupBase("日本", "proj"); !errors.Is(err, ErrUnbuildableGroupName) {
+ t.Errorf("expected ErrUnbuildableGroupName, got %v", err)
+ }
+}
+
+func TestGroupPrefix(t *testing.T) {
+ t.Setenv("POSIX_GROUP_PREFIX", "")
+ if got := GroupPrefix(); got != "proj" {
+ t.Errorf("default = %q, want %q", got, "proj")
+ }
+ t.Setenv("POSIX_GROUP_PREFIX", "alt")
+ if got := GroupPrefix(); got != "alt" {
+ t.Errorf("override = %q, want %q", got, "alt")
+ }
+}
diff --git a/pkg/service/compute_allocation.go b/pkg/service/compute_allocation.go
index 28ff999e0..dc410e8e0 100644
--- a/pkg/service/compute_allocation.go
+++ b/pkg/service/compute_allocation.go
@@ -21,10 +21,12 @@ import (
"context"
"database/sql"
"fmt"
+ "strconv"
"github.com/apache/airavata-custos/internal/store"
"github.com/apache/airavata-custos/pkg/events"
"github.com/apache/airavata-custos/pkg/models"
+ "github.com/apache/airavata-custos/pkg/posix"
)
// ListComputeAllocations returns a paginated, filterable slice of compute
@@ -86,15 +88,58 @@ func (s *Service) CreateComputeAllocation(ctx context.Context, alloc *models.Com
return nil, fmt.Errorf("%w: compute cluster %q not found", ErrInvalidInput, alloc.ComputeClusterID)
}
+ base, _, err := posix.BuildGroupBase(alloc.Name, posix.GroupPrefix())
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
+ }
+ // A unique violation aborts the transaction, so each suffix retries in a fresh one.
+ for n := 0; n < posix.MaxCollisionSuffix; n++ {
+ candidate := base
+ if n > 0 {
+ candidate = base + strconv.Itoa(n+1)
+ }
+ alloc.PosixGroup = &candidate
+ err = s.inTx(ctx, func(tx *sql.Tx) error {
+ if err := s.allocs.Create(ctx, tx, alloc); err != nil {
+ return err
+ }
+ return s.eventBus.Publish(ctx, tx, events.ComputeAllocationCreateEvent, alloc)
+ })
+ if !violates(err, "uq_compute_allocations_cluster_posix_group") {
+ break
+ }
+ }
+ if violates(err, "uq_compute_allocations_cluster_posix_group") {
+ return nil, fmt.Errorf("posix group allocator exhausted for base %q", base)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("create compute allocation: %w", err)
+ }
+ return alloc, nil
+}
+
+// MarkComputeAllocationGroupProvisioned stamps posix_group_provisioned_at,
+// signaling that the allocation's group exists in the registry. Only the first
+// stamp publishes an update event.
+func (s *Service) MarkComputeAllocationGroupProvisioned(ctx context.Context, id string) error {
+ if id == "" {
+ return fmt.Errorf("%w: compute allocation id is required", ErrInvalidInput)
+ }
+ alloc, err := s.GetComputeAllocation(ctx, id)
+ if err != nil {
+ return err
+ }
if err := s.inTx(ctx, func(tx *sql.Tx) error {
- if err := s.allocs.Create(ctx, tx, alloc); err != nil {
+ at, err := s.allocs.MarkGroupProvisioned(ctx, tx, id)
+ if err != nil || at == nil {
return err
}
- return s.eventBus.Publish(ctx, tx, events.ComputeAllocationCreateEvent, alloc)
+ alloc.PosixGroupProvisionedAt = at
+ return s.eventBus.Publish(ctx, tx, events.ComputeAllocationUpdateEvent, alloc)
}); err != nil {
- return nil, fmt.Errorf("create compute allocation: %w", err)
+ return fmt.Errorf("mark compute allocation group provisioned: %w", err)
}
- return alloc, nil
+ return nil
}
// GetComputeAllocation retrieves a compute allocation by its ID. Returns
diff --git a/pkg/service/compute_allocation_integration_test.go b/pkg/service/compute_allocation_integration_test.go
new file mode 100644
index 000000000..25a7f4e07
--- /dev/null
+++ b/pkg/service/compute_allocation_integration_test.go
@@ -0,0 +1,87 @@
+//go:build integration
+
+// 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 service
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
+
+ "github.com/apache/airavata-custos/pkg/models"
+)
+
+// Make sure an allocation gets a group name, a taken name gets the next suffix,
+// and only the first mark publishes.
+func TestCreateComputeAllocation_NamesPosixGroup(t *testing.T) {
+ database := setupTestDB(t)
+ svc := newTestService(database)
+ svc.EventBus().SubscribeComputeAllocationUpdated("test-subscriber", func(context.Context, models.ComputeAllocation) error { return nil })
+ cluster, project := newProject(t, svc, database)
+
+ first := newAllocation(t, svc, cluster.ID, project.ID)
+ second := newAllocation(t, svc, cluster.ID, project.ID)
+ if *first.PosixGroup != "proj-grant1" {
+ t.Fatalf("expected proj-grant1, got %q", *first.PosixGroup)
+ }
+ if *second.PosixGroup != "proj-grant12" {
+ t.Fatalf("expected proj-grant12, got %q", *second.PosixGroup)
+ }
+
+ for range 2 {
+ if err := svc.MarkComputeAllocationGroupProvisioned(ctx(), first.ID); err != nil {
+ t.Fatalf("mark: %v", err)
+ }
+ }
+ if n := countDeliveries(t, svc); n != 1 {
+ t.Fatalf("expected 1 delivery for 2 marks, got %d", n)
+ }
+}
+
+func newProject(t *testing.T, svc *Service, database *sqlx.DB) (*models.ComputeCluster, *models.Project) {
+ t.Helper()
+ cluster, err := svc.CreateComputeCluster(ctx(), &models.ComputeCluster{Name: "grp-" + uuid.NewString()[:8]})
+ if err != nil {
+ t.Fatalf("create cluster: %v", err)
+ }
+ pi := seedUser(t, database, fmt.Sprintf("pi-%s@example.edu", uuid.NewString()))
+ project, err := svc.CreateProject(ctx(), &models.Project{
+ OriginatedID: uuid.NewString(), Title: "grp", Origination: "TEST", ProjectPIID: pi, Status: models.ProjectActive,
+ })
+ if err != nil {
+ t.Fatalf("create project: %v", err)
+ }
+ return cluster, project
+}
+
+func newAllocation(t *testing.T, svc *Service, clusterID, projectID string) *models.ComputeAllocation {
+ t.Helper()
+ alloc, err := svc.CreateComputeAllocation(ctx(), &models.ComputeAllocation{
+ Name: "Grant 1", ProjectID: projectID, ComputeClusterID: clusterID,
+ StartTime: time.Now(), EndTime: time.Now().AddDate(1, 0, 0),
+ })
+ if err != nil {
+ t.Fatalf("create allocation: %v", err)
+ }
+ return alloc
+}
diff --git a/pkg/service/compute_cluster_user.go b/pkg/service/compute_cluster_user.go
index 0e59f08ee..a31bf4ae9 100644
--- a/pkg/service/compute_cluster_user.go
+++ b/pkg/service/compute_cluster_user.go
@@ -27,6 +27,7 @@ import (
"github.com/apache/airavata-custos/internal/store"
"github.com/apache/airavata-custos/pkg/events"
"github.com/apache/airavata-custos/pkg/models"
+ "github.com/apache/airavata-custos/pkg/posix"
)
const (
@@ -66,6 +67,9 @@ func (s *Service) CreateComputeClusterUser(ctx context.Context, cu *models.Compu
if cu.LocalUsername == "" {
return nil, fmt.Errorf("%w: local_username is required", ErrInvalidInput)
}
+ if err := posix.ValidateUsername(cu.LocalUsername); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
+ }
if cu.ID == "" {
cu.ID = newID()
}
@@ -127,9 +131,9 @@ func (s *Service) CreateComputeClusterUser(ctx context.Context, cu *models.Compu
return nil
}); err != nil {
switch {
- case isLocalUsernameDuplicate(err):
+ case violates(err, "uq_compute_cluster_users_local_username"):
return nil, fmt.Errorf("%w: %s", ErrAlreadyExists, cu.LocalUsername)
- case isPairDuplicate(err):
+ case violates(err, "uq_compute_cluster_users_pair"):
return nil, fmt.Errorf("%w: user %q is already mapped on cluster %q",
ErrAlreadyExists, cu.UserID, cu.ComputeClusterID)
default:
@@ -246,12 +250,9 @@ func (s *Service) clusterUserForReview(ctx context.Context, id, reviewerID strin
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")
+// violates reports whether err violates the named constraint.
+func violates(err error, constraint string) bool {
+ return err != nil && strings.Contains(err.Error(), constraint)
}
// GetComputeClusterUser retrieves a compute-cluster user by its ID.
@@ -353,6 +354,14 @@ func (s *Service) UpdateComputeClusterUser(ctx context.Context, cu *models.Compu
if cu.LocalUsername == "" {
cu.LocalUsername = existing.LocalUsername
}
+ if err := posix.ValidateUsername(cu.LocalUsername); err != nil {
+ return fmt.Errorf("%w: %v", ErrInvalidInput, err)
+ }
+ // The registry and storage are keyed by these, so they freeze once provisioned.
+ if existing.ProvisionedAt != nil && (cu.ComputeClusterID != existing.ComputeClusterID ||
+ cu.UserID != existing.UserID || cu.LocalUsername != existing.LocalUsername) {
+ return fmt.Errorf("%w: compute cluster user %q is provisioned; its cluster, user and username are fixed", ErrInvalidInput, cu.ID)
+ }
if err := s.inTx(ctx, func(tx *sql.Tx) error {
if err := s.clusterUsers.Update(ctx, tx, cu); err != nil {
return err
@@ -365,26 +374,41 @@ func (s *Service) UpdateComputeClusterUser(ctx context.Context, cu *models.Compu
}
// MarkComputeClusterUserProvisioned stamps provisioned_at on the mapping,
-// signaling that the account exists in the registry.
+// signaling that the account exists in the registry. Only the first stamp
+// publishes an update event.
func (s *Service) MarkComputeClusterUserProvisioned(ctx context.Context, id string) error {
if id == "" {
return fmt.Errorf("%w: compute cluster user id is required", ErrInvalidInput)
}
- cu, err := s.clusterUsers.FindByID(ctx, id)
+ cu, err := s.GetComputeClusterUser(ctx, id)
if err != nil {
- return fmt.Errorf("lookup compute cluster user: %w", err)
- }
- if cu == nil {
- return ErrNotFound
+ return err
}
if err := s.inTx(ctx, func(tx *sql.Tx) error {
- return s.clusterUsers.MarkProvisioned(ctx, tx, id)
+ at, err := s.clusterUsers.MarkProvisioned(ctx, tx, id)
+ if err != nil || at == nil {
+ return err
+ }
+ cu.ProvisionedAt = at
+ return s.eventBus.Publish(ctx, tx, events.ComputeClusterUserUpdateEvent, cu)
}); err != nil {
return fmt.Errorf("mark compute cluster user provisioned: %w", err)
}
return nil
}
+// ListGroupMembersForAllocation returns the members of the allocation's POSIX group.
+func (s *Service) ListGroupMembersForAllocation(ctx context.Context, allocationID string) ([]models.ComputeClusterUser, error) {
+ if allocationID == "" {
+ return nil, fmt.Errorf("%w: compute_allocation_id is required", ErrInvalidInput)
+ }
+ members, err := s.clusterUsers.FindByAllocation(ctx, allocationID)
+ if err != nil {
+ return nil, fmt.Errorf("list group members for allocation: %w", err)
+ }
+ return members, nil
+}
+
// DeleteComputeClusterUser removes a compute-cluster user mapping by ID.
func (s *Service) DeleteComputeClusterUser(ctx context.Context, id string) error {
if id == "" {
diff --git a/pkg/service/compute_cluster_user_integration_test.go b/pkg/service/compute_cluster_user_integration_test.go
index fe631f32a..0a01f4f60 100644
--- a/pkg/service/compute_cluster_user_integration_test.go
+++ b/pkg/service/compute_cluster_user_integration_test.go
@@ -31,9 +31,11 @@ import (
"github.com/apache/airavata-custos/pkg/models"
)
+// Make sure only the first mark publishes, so waiting connectors run once.
func TestMarkComputeClusterUserProvisioned_RoundTrip(t *testing.T) {
database := setupTestDB(t)
svc := newTestService(database)
+ svc.EventBus().SubscribeComputeClusterUserUpdated("test-subscriber", func(context.Context, models.ComputeClusterUser) error { return nil })
cluster, err := svc.CreateComputeCluster(ctx(), &models.ComputeCluster{
Name: "provisioned-" + uuid.NewString()[:8],
@@ -70,8 +72,13 @@ func TestMarkComputeClusterUserProvisioned_RoundTrip(t *testing.T) {
t.Fatalf("new mapping should not be provisioned yet")
}
- if err := svc.MarkComputeClusterUserProvisioned(ctx(), ccu.ID); err != nil {
- t.Fatalf("mark provisioned: %v", err)
+ for range 2 {
+ if err := svc.MarkComputeClusterUserProvisioned(ctx(), ccu.ID); err != nil {
+ t.Fatalf("mark provisioned: %v", err)
+ }
+ }
+ if n := countDeliveries(t, svc); n != 1 {
+ t.Fatalf("expected 1 delivery for 2 marks, got %d", n)
}
got, err := svc.GetComputeClusterUser(ctx(), ccu.ID)
@@ -260,3 +267,70 @@ func TestOnboardUser_ClusterAccountIsApproved(t *testing.T) {
t.Fatalf("expected one delivery of the approve event, got %d", n)
}
}
+
+// Make sure the group holds only approved, provisioned, active members, and
+// empties with the allocation.
+func TestListGroupMembersForAllocation(t *testing.T) {
+ database := setupTestDB(t)
+ svc := newTestService(database)
+ cluster, project := newProject(t, svc, database)
+ alloc := newAllocation(t, svc, cluster.ID, project.ID)
+ admin := seedUser(t, database, fmt.Sprintf("admin-%s@example.edu", uuid.NewString()))
+
+ member := func(approve, provision bool, status models.AllocationStatus) string {
+ userID := seedUser(t, database, fmt.Sprintf("m-%s@example.edu", uuid.NewString()))
+ cu, err := svc.CreateComputeClusterUser(ctx(), &models.ComputeClusterUser{
+ ComputeClusterID: cluster.ID, UserID: userID, LocalUsername: "m-" + uuid.NewString()[:8],
+ })
+ if err != nil {
+ t.Fatalf("create cluster user: %v", err)
+ }
+ if approve {
+ if _, err := svc.ApproveComputeClusterUser(ctx(), cu.ID, admin); err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ }
+ if provision {
+ if err := svc.MarkComputeClusterUserProvisioned(ctx(), cu.ID); err != nil {
+ t.Fatalf("mark: %v", err)
+ }
+ }
+ if _, err := svc.CreateComputeAllocationMembership(ctx(), &models.ComputeAllocationMembership{
+ ComputeAllocationID: alloc.ID, UserID: userID, MembershipStatus: status,
+ }); err != nil {
+ t.Fatalf("create membership: %v", err)
+ }
+ return cu.LocalUsername
+ }
+ want := member(true, true, models.ACTIVE)
+ member(false, true, models.ACTIVE)
+ member(true, false, models.ACTIVE)
+ member(true, true, models.INACTIVE)
+
+ got, err := svc.ListGroupMembersForAllocation(ctx(), alloc.ID)
+ if err != nil || len(got) != 1 || got[0].LocalUsername != want {
+ t.Fatalf("expected only %s, got %+v (%v)", want, got, err)
+ }
+
+ alloc.Status = models.INACTIVE
+ if err := svc.UpdateComputeAllocation(ctx(), alloc); err != nil {
+ t.Fatalf("inactivate: %v", err)
+ }
+ if got, _ := svc.ListGroupMembersForAllocation(ctx(), alloc.ID); len(got) != 0 {
+ t.Fatalf("expected no members once inactive, got %+v", got)
+ }
+}
+
+// Make sure a provisioned account cannot be renamed.
+func TestUpdateComputeClusterUser_UsernameFixedOnceProvisioned(t *testing.T) {
+ database := setupTestDB(t)
+ svc := newTestService(database)
+ cu := newClusterUser(t, svc, database)
+ if err := svc.MarkComputeClusterUserProvisioned(ctx(), cu.ID); err != nil {
+ t.Fatalf("mark: %v", err)
+ }
+ cu.LocalUsername = "renamed-" + uuid.NewString()[:8]
+ if err := svc.UpdateComputeClusterUser(ctx(), cu); !errors.Is(err, ErrInvalidInput) {
+ t.Fatalf("expected ErrInvalidInput, got %v", err)
+ }
+}
diff --git a/pkg/service/user_onboard.go b/pkg/service/user_onboard.go
index 4733e773a..bd2301293 100644
--- a/pkg/service/user_onboard.go
+++ b/pkg/service/user_onboard.go
@@ -135,8 +135,8 @@ func (s *Service) OnboardUser(ctx context.Context, in OnboardUserInput) (*models
}
in.Username = generated
} else {
- if len(in.Username) > posix.MaxLoginLen {
- return nil, fmt.Errorf("%w: username %q is longer than %d characters", ErrInvalidInput, in.Username, posix.MaxLoginLen)
+ if err := posix.ValidateUsername(in.Username); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
taken, err := s.clusterUsers.FindByClusterAndLocalUsername(ctx, in.ComputeClusterID, in.Username)
if err != nil {
diff --git a/web/src/generated/core/types.gen.ts b/web/src/generated/core/types.gen.ts
index c1e4a02ef..4734f4054 100644
--- a/web/src/generated/core/types.gen.ts
+++ b/web/src/generated/core/types.gen.ts
@@ -66,7 +66,7 @@ export type ClusterAccountResponse = {
review_note?: string;
reviewed_at?: string;
/**
- * The admin who approved or denied the account.
+ * The admin who approved or denied the cluster account.
*/
reviewed_by?: string;
user_id?: string;
@@ -84,6 +84,14 @@ export type ComputeAllocation = {
*/
initial_su_amount?: number;
name?: string;
+ /**
+ * Fixed at creation; also the directory and quota name.
+ */
+ posix_group?: string;
+ /**
+ * When the group was provisioned into the registry; nil until then.
+ */
+ posix_group_provisioned_at?: string;
project_id?: string;
start_time?: string;
/**
@@ -326,7 +334,7 @@ export type ComputeClusterUser = {
review_note?: string;
reviewed_at?: string;
/**
- * The admin who approved or denied the account.
+ * The admin who approved or denied the cluster account.
*/
reviewed_by?: string;
user_id?: string;
diff --git a/web/src/generated/core/zod.gen.ts b/web/src/generated/core/zod.gen.ts
index b2eda507e..9b1a0daad 100644
--- a/web/src/generated/core/zod.gen.ts
+++ b/web/src/generated/core/zod.gen.ts
@@ -74,6 +74,8 @@ export const zComputeAllocation = z.object({
id: z.string().optional(),
initial_su_amount: z.int().optional(),
name: z.string().optional(),
+ posix_group: z.string().optional(),
+ posix_group_provisioned_at: z.string().optional(),
project_id: z.string().optional(),
start_time: z.string().optional(),
status: zAllocationStatus.optional()