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/10/03 21:04:50 UTC

(airavata-custos) 02/04: create vast home directories when a cluster account is provisioned (#590)

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

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

commit db64e2a72299f7a8a24c400468c8f7aa98309b95
Author: Yasith Jayawardana <ya...@icloud.com>
AuthorDate: Sat Oct 3 17:04:38 2026 -0400

    create vast home directories when a cluster account is provisioned (#590)
---
 CONFIG.md                                          |  23 +++++
 config/custos.yaml                                 |  23 +++++
 connectors/VAST/Storage-Provisioner/README.md      |  42 ++++++++
 .../Storage-Provisioner/internal/client/client.go  | 113 +++++++++++++++++++++
 .../internal/client/client_test.go                 |  58 +++++++++++
 .../internal/subscribers/subscriber.go             | 100 ++++++++++++++++++
 .../internal/subscribers/subscriber_test.go        |  71 +++++++++++++
 .../VAST/Storage-Provisioner/pkg/vast/loader.go    |  84 +++++++++++++++
 internal/connectors/loader.go                      |   2 +
 pkg/service/compute_cluster_user.go                |   8 +-
 10 files changed, 523 insertions(+), 1 deletion(-)

diff --git a/CONFIG.md b/CONFIG.md
index 91915a61e..549e12d25 100644
--- a/CONFIG.md
+++ b/CONFIG.md
@@ -132,6 +132,28 @@ connectors:
       http_timeout: "30s"
 ```
 
+#### VAST Storage Provisioner
+
+```yaml
+  vast-storage:
+    type: "vast-storage-provisioner"
+    enabled: true
+    custos_cluster_id: "cluster-001"
+    vms:
+      url: "http://vms.example.org"
+      username: "custos"
+      password: "${VAST_API_PASSWORD}"
+      tenant_id: 1
+    # config/custos.yaml holds the full list.
+    storage:
+      - path: "/home/{user}"
+        owner: "{user}"
+        group: "{user}"
+        mode: "0700"
+        hard_limit: 107374182400      # 100 GiB
+        hard_limit_inodes: 1000000
+```
+
 #### AMIE Processor
 
 ```yaml
@@ -223,6 +245,7 @@ When a connector is disabled:
 - `slurm-association-mapper` - SLURM Association Mapper
 - `slurm-usage-monitor` - SLURM Usage Monitor
 - `comanage-identity-provisioner` - COmanage Identity Provisioner
+- `vast-storage-provisioner` - VAST Storage Provisioner
 - `amie-processor` - AMIE Processor
 
 New connector types can be added by:
diff --git a/config/custos.yaml b/config/custos.yaml
index 53684f48e..f88804459 100644
--- a/config/custos.yaml
+++ b/config/custos.yaml
@@ -77,6 +77,29 @@ connectors:
       homedir_prefix: "/home/"
       http_timeout: "30s"
 
+  vast-storage:
+    type: "vast-storage-provisioner"
+    enabled: false
+    custos_cluster_id: "${CUSTOS_CLUSTER_ID}"
+    vms:
+      url: "${VAST_VMS_URL}"
+      username: "${VAST_API_USER}"
+      password: "${VAST_API_PASSWORD}"
+      tenant_id: ${VAST_TENANT_ID}
+    storage:
+      - path: "/home/{user}"
+        owner: "{user}"
+        group: "{user}"
+        mode: "0700"
+        hard_limit: 107374182400      # 100 GiB
+        hard_limit_inodes: 1000000
+      - path: "/scratch/{user}"
+        owner: "{user}"
+        group: "{user}"
+        mode: "0700"
+        hard_limit: 10995116277760    # 10 TiB
+        hard_limit_inodes: 10000000
+
   amie-processor:
     type: "amie-processor"
     enabled: true
diff --git a/connectors/VAST/Storage-Provisioner/README.md b/connectors/VAST/Storage-Provisioner/README.md
new file mode 100644
index 000000000..cf2f7c3c0
--- /dev/null
+++ b/connectors/VAST/Storage-Provisioner/README.md
@@ -0,0 +1,42 @@
+<!--
+    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.
+-->
+
+# VAST Storage-Provisioner
+
+Creates directories and quotas on a VAST cluster through the VMS REST API, once the COmanage Identity-Provisioner
+on the same Custos cluster has provisioned the account. Nothing is changed once it exists, and nothing is deleted.
+
+The directories come from the `storage` list in the config. Each entry's `path`, `owner` and `group` are templates
+over `{user}`, the account's username. Entries are created in config order, with a quota named after the path.
+
+| Trigger | Runs |
+|---|---|
+| `compute_cluster_user::update` with `provisioned_at` set | The account's directories |
+| Startup, then every 24 hours | The same, for every account on the cluster |
+
+Configuration is in [`CONFIG.md`](../../../CONFIG.md#vast-storage-provisioner).
+
+## Prerequisites on VMS
+
+VAST admins make these changes before the connector is enabled.
+
+| Prerequisite | Why |
+|---|---|
+| The COmanage LDAP is a VMS user-directory provider | VMS resolves owners only from its providers; Custos users are otherwise unknown and every create retries |
+| The service account may create folders and quotas | The account the connector logs in with |
diff --git a/connectors/VAST/Storage-Provisioner/internal/client/client.go b/connectors/VAST/Storage-Provisioner/internal/client/client.go
new file mode 100644
index 000000000..daa480472
--- /dev/null
+++ b/connectors/VAST/Storage-Provisioner/internal/client/client.go
@@ -0,0 +1,113 @@
+// 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 client creates folders and quotas through the VAST VMS REST API.
+// Each create logs in for a fresh JWT from `/api/token/`, since creates are rare.
+// VMS answers most refusals, including an existing folder, with a 503, so a
+// refused create counts as success when the folder or quota exists; any other
+// error goes back to the bus, which retries it.
+package client
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+type Client struct {
+	url, username, password string
+	tenantID                int
+	http                    *http.Client
+}
+
+func New(vmsURL, username, password string, tenantID int) *Client {
+	return &Client{
+		url:      strings.TrimSuffix(vmsURL, "/") + "/api/",
+		username: username,
+		password: password,
+		tenantID: tenantID,
+		http:     &http.Client{Timeout: 10 * time.Second},
+	}
+}
+
+// CreateFolder creates path owned by user:group unless it exists.
+func (c *Client) CreateFolder(ctx context.Context, path, user, group string, mode int) error {
+	return c.create(ctx, "folders/create_folder/", map[string]any{
+		"path": path, "user": user, "group": group, "owner_is_group": false, "create_dir_mode": fmt.Sprintf("%o", mode), "tenant_id": c.tenantID,
+	}, func(token string) bool {
+		return c.do(ctx, token, http.MethodPost, "folders/stat_path/", map[string]any{"path": path, "tenant_id": c.tenantID}, nil) == nil
+	})
+}
+
+// CreateQuota creates a quota on path, named after it, unless a quota by that name exists.
+func (c *Client) CreateQuota(ctx context.Context, path string, hardLimit, hardLimitInodes int64) error {
+	return c.create(ctx, "quotas/", map[string]any{
+		"name": path, "path": path, "create_dir": false, "hard_limit": hardLimit, "hard_limit_inodes": hardLimitInodes, "tenant_id": c.tenantID,
+	}, func(token string) bool {
+		var found []json.RawMessage
+		return c.do(ctx, token, http.MethodGet, "quotas/?name="+url.QueryEscape(path), nil, &found) == nil && len(found) > 0
+	})
+}
+
+func (c *Client) create(ctx context.Context, path string, body map[string]any, exists func(token string) bool) error {
+	var t struct{ Access string }
+	if err := c.do(ctx, "", http.MethodPost, "token/", map[string]string{"username": c.username, "password": c.password}, &t); err != nil {
+		return err
+	}
+	if err := c.do(ctx, t.Access, http.MethodPost, path, body, nil); err != nil && !exists(t.Access) {
+		return err
+	}
+	return nil
+}
+
+func (c *Client) do(ctx context.Context, token, method, path string, body, out any) error {
+	var rdr io.Reader
+	if body != nil {
+		b, err := json.Marshal(body)
+		if err != nil {
+			return err
+		}
+		rdr = bytes.NewReader(b)
+	}
+	req, err := http.NewRequestWithContext(ctx, method, c.url+path, rdr)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	if token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 300 {
+		msg, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
+		return fmt.Errorf("vast: %s %s: %d: %s", method, path, resp.StatusCode, msg)
+	}
+	if out == nil {
+		return nil
+	}
+	return json.NewDecoder(resp.Body).Decode(out)
+}
diff --git a/connectors/VAST/Storage-Provisioner/internal/client/client_test.go b/connectors/VAST/Storage-Provisioner/internal/client/client_test.go
new file mode 100644
index 000000000..ae01cbe58
--- /dev/null
+++ b/connectors/VAST/Storage-Provisioner/internal/client/client_test.go
@@ -0,0 +1,58 @@
+// 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 client
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestCreateFolder(t *testing.T) {
+	tests := []struct {
+		name         string
+		create, stat int
+		wantErr      bool
+	}{
+		{name: "created", create: 200},
+		{name: "already exists", create: 503, stat: 200},
+		{name: "unknown owner", create: 503, stat: 503, wantErr: true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				switch {
+				case r.URL.Path == "/api/token/":
+					_, _ = w.Write([]byte(`{"access":"fresh"}`))
+				case r.Header.Get("Authorization") != "Bearer fresh":
+					w.WriteHeader(http.StatusForbidden)
+				case r.URL.Path == "/api/folders/stat_path/":
+					w.WriteHeader(tt.stat)
+				default:
+					w.WriteHeader(tt.create)
+				}
+			}))
+			defer srv.Close()
+			err := New(srv.URL, "u", "p", 1).CreateFolder(context.Background(), "/home/custos-jdoe", "custos-jdoe", "custos-jdoe", 0o700)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("err=%v", err)
+			}
+		})
+	}
+}
diff --git a/connectors/VAST/Storage-Provisioner/internal/subscribers/subscriber.go b/connectors/VAST/Storage-Provisioner/internal/subscribers/subscriber.go
new file mode 100644
index 000000000..046cb3261
--- /dev/null
+++ b/connectors/VAST/Storage-Provisioner/internal/subscribers/subscriber.go
@@ -0,0 +1,100 @@
+// 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 subscribers creates the configured directories and quotas on VAST
+// once COmanage has provisioned the account, since VAST resolves owners by name
+// from the LDAP that COmanage feeds. Mounts are created in config order, so
+// parents come first.
+package subscribers
+
+import (
+	"context"
+	"errors"
+	"log/slog"
+	"strings"
+	"time"
+
+	"github.com/apache/airavata-custos/connectors/VAST/Storage-Provisioner/internal/client"
+	"github.com/apache/airavata-custos/pkg/events"
+	"github.com/apache/airavata-custos/pkg/models"
+	"github.com/apache/airavata-custos/pkg/service"
+)
+
+// Mount is a directory created for each account. Path, Owner and Group are
+// templates over {user}, the account's username; its quota is named after the path.
+type Mount struct {
+	Path, Owner, Group         string
+	Mode                       int
+	HardLimit, HardLimitInodes int64
+}
+
+type StorageSubscriber struct {
+	vms             *client.Client
+	bus             *events.Bus
+	core            service.CoreService
+	custosClusterID string
+	mounts          []Mount
+}
+
+func NewStorageSubscriber(vms *client.Client, bus *events.Bus, core service.CoreService, custosClusterID string, mounts []Mount) *StorageSubscriber {
+	return &StorageSubscriber{vms: vms, bus: bus, core: core, custosClusterID: custosClusterID, mounts: mounts}
+}
+
+// RegisterSubscribers waits for the "provisioned" update COmanage publishes, which alone releases the work.
+func (s *StorageSubscriber) RegisterSubscribers(subscriber string) {
+	s.bus.SubscribeComputeClusterUserUpdated(subscriber, s.ensureUser)
+}
+
+// ensureUser needs no approval check: COmanage provisions approved accounts only.
+func (s *StorageSubscriber) ensureUser(ctx context.Context, cu models.ComputeClusterUser) error {
+	if cu.ComputeClusterID != s.custosClusterID || cu.ProvisionedAt == nil {
+		return nil
+	}
+	r := strings.NewReplacer("{user}", cu.LocalUsername)
+	for _, m := range s.mounts {
+		path := r.Replace(m.Path)
+		if err := s.vms.CreateFolder(ctx, path, r.Replace(m.Owner), r.Replace(m.Group), m.Mode); err != nil {
+			return err
+		}
+		if err := s.vms.CreateQuota(ctx, path, m.HardLimit, m.HardLimitInodes); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *StorageSubscriber) StartReconciler(ctx context.Context) {
+	for {
+		s.reconcile(ctx)
+		select {
+		case <-ctx.Done():
+			return
+		case <-time.After(24 * time.Hour):
+		}
+	}
+}
+
+func (s *StorageSubscriber) reconcile(ctx context.Context) {
+	users, err := s.core.ListComputeClusterUsersByCluster(ctx, s.custosClusterID)
+	errs := []error{err}
+	for _, cu := range users {
+		errs = append(errs, s.ensureUser(ctx, cu))
+	}
+	if err := errors.Join(errs...); err != nil {
+		slog.Error("VAST reconciler", "err", err)
+	}
+}
diff --git a/connectors/VAST/Storage-Provisioner/internal/subscribers/subscriber_test.go b/connectors/VAST/Storage-Provisioner/internal/subscribers/subscriber_test.go
new file mode 100644
index 000000000..efd6e6a36
--- /dev/null
+++ b/connectors/VAST/Storage-Provisioner/internal/subscribers/subscriber_test.go
@@ -0,0 +1,71 @@
+// 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 subscribers
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"slices"
+	"testing"
+	"time"
+
+	"github.com/apache/airavata-custos/connectors/VAST/Storage-Provisioner/internal/client"
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+var layout = []Mount{
+	{Path: "/home/{user}", Owner: "{user}", Group: "{user}", Mode: 0o700, HardLimit: 1, HardLimitInodes: 1},
+	{Path: "/scratch/{user}", Owner: "{user}", Group: "{user}", Mode: 0o700, HardLimit: 1, HardLimitInodes: 1},
+}
+
+func TestEnsureUser(t *testing.T) {
+	now := time.Now()
+	tests := []struct {
+		name          string
+		provisionedAt *time.Time
+		want          []string
+	}{
+		{name: "creates in order", provisionedAt: &now, want: []string{"folder /home/custos-jdoe", "quota /home/custos-jdoe", "folder /scratch/custos-jdoe", "quota /scratch/custos-jdoe"}},
+		{name: "skips unprovisioned"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var log []string
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				var body struct{ Path string }
+				_ = json.NewDecoder(r.Body).Decode(&body)
+				switch r.URL.Path {
+				case "/api/token/":
+					_, _ = w.Write([]byte(`{"access":"t"}`))
+				case "/api/folders/create_folder/":
+					log = append(log, "folder "+body.Path)
+				case "/api/quotas/":
+					log = append(log, "quota "+body.Path)
+				}
+			}))
+			defer srv.Close()
+			s := NewStorageSubscriber(client.New(srv.URL, "u", "p", 1), nil, nil, "c1", layout)
+			cu := models.ComputeClusterUser{ComputeClusterID: "c1", LocalUsername: "custos-jdoe", ProvisionedAt: tt.provisionedAt}
+			if err := s.ensureUser(context.Background(), cu); err != nil || !slices.Equal(log, tt.want) {
+				t.Fatalf("err=%v calls=%v, want %v", err, log, tt.want)
+			}
+		})
+	}
+}
diff --git a/connectors/VAST/Storage-Provisioner/pkg/vast/loader.go b/connectors/VAST/Storage-Provisioner/pkg/vast/loader.go
new file mode 100644
index 000000000..7d361677a
--- /dev/null
+++ b/connectors/VAST/Storage-Provisioner/pkg/vast/loader.go
@@ -0,0 +1,84 @@
+// 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 vast is the VAST Storage-Provisioner entry point. Wired from
+// internal/connectors/loader.go.
+package vast
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"strconv"
+	"sync"
+
+	"github.com/jmoiron/sqlx"
+
+	"github.com/apache/airavata-custos/connectors/VAST/Storage-Provisioner/internal/client"
+	"github.com/apache/airavata-custos/connectors/VAST/Storage-Provisioner/internal/subscribers"
+	"github.com/apache/airavata-custos/internal/config"
+	"github.com/apache/airavata-custos/pkg/events"
+	"github.com/apache/airavata-custos/pkg/identity"
+	"github.com/apache/airavata-custos/pkg/service"
+)
+
+// Type is the connector type in the config file and the subscriber name on the bus.
+const Type = "vast-storage-provisioner"
+
+func LoadConnector(ctx context.Context, _ *sqlx.DB, eventBus *events.Bus, coreService *service.Service, wg *sync.WaitGroup, _ *identity.Router, connectorConfig *config.ConnectorConfig) error {
+	vms, _ := connectorConfig.GetNestedConfig("vms")
+	vmsURL, _ := vms["url"].(string)
+	username, _ := vms["username"].(string)
+	password, _ := vms["password"].(string)
+	tenantID, _ := vms["tenant_id"].(int)
+	clusterID, _ := connectorConfig.GetStringField("custos_cluster_id")
+	storage, _ := connectorConfig.Config["storage"].([]interface{})
+	if vmsURL == "" || username == "" || password == "" || tenantID == 0 || clusterID == "" || len(storage) == 0 {
+		slog.Info("vast provisioner: required config not set; skipping")
+		return nil
+	}
+	mounts, err := parseMounts(storage)
+	if err != nil {
+		return err
+	}
+	subscriber := subscribers.NewStorageSubscriber(client.New(vmsURL, username, password, tenantID), eventBus, coreService, clusterID, mounts)
+	subscriber.RegisterSubscribers(Type)
+	slog.Info("vast provisioner: subscriber registered", "vms", vmsURL, "cluster_id", clusterID)
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		subscriber.StartReconciler(ctx)
+	}()
+	return nil
+}
+
+func parseMounts(storage []interface{}) ([]subscribers.Mount, error) {
+	var mounts []subscribers.Mount
+	for i, e := range storage {
+		m, _ := e.(map[string]interface{})
+		str := func(k string) string { v, _ := m[k].(string); return v }
+		hardLimit, _ := m["hard_limit"].(int)
+		hardLimitInodes, _ := m["hard_limit_inodes"].(int)
+		mode, err := strconv.ParseInt(str("mode"), 8, 32)
+		if err != nil {
+			return nil, fmt.Errorf("vast provisioner: storage[%d].mode must be octal, such as \"0700\": %w", i, err)
+		}
+		mounts = append(mounts, subscribers.Mount{Path: str("path"), Owner: str("owner"), Group: str("group"),
+			Mode: int(mode), HardLimit: int64(hardLimit), HardLimitInodes: int64(hardLimitInodes)})
+	}
+	return mounts, nil
+}
diff --git a/internal/connectors/loader.go b/internal/connectors/loader.go
index 562c0aa37..09fad4663 100644
--- a/internal/connectors/loader.go
+++ b/internal/connectors/loader.go
@@ -28,6 +28,7 @@ import (
 	"github.com/apache/airavata-custos/connectors/SLURM/Association-Mapper/pkg/smapper"
 	"github.com/apache/airavata-custos/connectors/SLURM/Usage-Monitor/pkg/monitor"
 	"github.com/apache/airavata-custos/connectors/TempAccount/pkg/tempaccount"
+	"github.com/apache/airavata-custos/connectors/VAST/Storage-Provisioner/pkg/vast"
 	"github.com/apache/airavata-custos/internal/config"
 	"github.com/apache/airavata-custos/pkg/events"
 	"github.com/apache/airavata-custos/pkg/identity"
@@ -45,6 +46,7 @@ func LoadConnectorsFromConfig(ctx context.Context, cfg *config.Config, database
 		monitor.Type:     monitor.LoadConnector,
 		tempaccount.Type: tempaccount.LoadConnector,
 		analytics.Type:   analytics.LoadConnector,
+		vast.Type:        vast.LoadConnector,
 	}
 
 	for connectorName, connectorCfg := range cfg.Connectors {
diff --git a/pkg/service/compute_cluster_user.go b/pkg/service/compute_cluster_user.go
index 4c921f790..1a773edfb 100644
--- a/pkg/service/compute_cluster_user.go
+++ b/pkg/service/compute_cluster_user.go
@@ -23,6 +23,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"strings"
+	"time"
 
 	"github.com/apache/airavata-custos/internal/store"
 	"github.com/apache/airavata-custos/pkg/events"
@@ -382,7 +383,12 @@ func (s *Service) MarkComputeClusterUserProvisioned(ctx context.Context, id stri
 		return ErrNotFound
 	}
 	if err := s.inTx(ctx, func(tx *sql.Tx) error {
-		return s.clusterUsers.MarkProvisioned(ctx, tx, id)
+		if err := s.clusterUsers.MarkProvisioned(ctx, tx, id); err != nil {
+			return err
+		}
+		now := time.Now()
+		cu.ProvisionedAt = &now
+		return s.eventBus.Publish(ctx, tx, events.ComputeClusterUserUpdateEvent, cu)
 	}); err != nil {
 		return fmt.Errorf("mark compute cluster user provisioned: %w", err)
 	}