You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by di...@apache.org on 2026/09/03 00:37:24 UTC
(airavata) branch refactor-single-module updated: Refactoring slurm deployment data model
This is an automated email from the ASF dual-hosted git repository.
DImuthuUpe pushed a commit to branch refactor-single-module
in repository https://gitbox.apache.org/repos/asf/airavata.git
The following commit(s) were added to refs/heads/refactor-single-module by this push:
new 8825a52da9 Refactoring slurm deployment data model
8825a52da9 is described below
commit 8825a52da9893d9daa40684e1c6afd2c57deb2c3
Author: DImuthuUpe <di...@gmail.com>
AuthorDate: Wed Sep 2 20:37:17 2026 -0400
Refactoring slurm deployment data model
---
api/application/dto/application.go | 51 +++---
api/application/model/deployment.go | 20 +--
api/application/repository/repository.go | 8 +-
api/application/service/service.go | 69 ++++----
api/process/dto/process.go | 9 +-
api/process/service/launch.go | 5 -
api/process/service/process.go | 20 +--
docs/api.md | 96 +++++------
internal/app/app.go | 2 +-
internal/db/foreignkey_test.go | 3 +-
internal/db/migrate_test.go | 24 +--
.../migrations/0003_deployment_default_naming.sql | 29 ++++
internal/server/server_test.go | 187 +++++++++++----------
13 files changed, 252 insertions(+), 271 deletions(-)
diff --git a/api/application/dto/application.go b/api/application/dto/application.go
index 5ad054735a..4e2d478baa 100644
--- a/api/application/dto/application.go
+++ b/api/application/dto/application.go
@@ -276,17 +276,15 @@ func ToBatchJobConfigResponse(c *model.BatchJobConfig) *BatchJobConfigResponse {
//
// Java: org.apache.airavata.application.dto.deployment.BatchApplicationDeploymentRequestDto
type BatchDeploymentRequest struct {
- TemplateID string `json:"templateId"`
- SlurmClusterID *string `json:"slurmClusterId"`
- SlurmRunSection string `json:"slurmRunSection"`
- BatchJobConfig *BatchJobConfigRequest `json:"batchJobConfig"`
+ TemplateID string `json:"templateId"`
+ SlurmClusterID *string `json:"slurmClusterId"`
+ SlurmRunSection string `json:"slurmRunSection"`
- // DefaultSubmissionCredentialID names an SSH endpoint credential binding (the id
- // returned by POST /api/v1/ssh-endpoint-credentials), not a bare SSH credential —
- // a binding ties the submitting identity to both a host and an owner.
- DefaultSubmissionCredentialID string `json:"defaultSubmissionCredentialId"`
-
- Partition *string `json:"partition"`
+ // The resource request and partition a run of this deployment starts from. A run
+ // carries its own copy and may depart from both, so these are defaults rather than
+ // what any particular job is submitted with.
+ DefaultBatchJobConfig *BatchJobConfigRequest `json:"defaultBatchJobConfig"`
+ DefaultPartition *string `json:"defaultPartition"`
}
// Validate implements httpx.Validator.
@@ -294,11 +292,10 @@ func (r *BatchDeploymentRequest) Validate() []httpx.FieldError {
var c httpx.Constraints
c.NotBlank("templateId", "Template id cannot be blank", r.TemplateID)
c.NotBlank("slurmRunSection", "Slurm run section cannot be blank", r.SlurmRunSection)
- c.NotBlank("defaultSubmissionCredentialId", "Default submission credential id cannot be blank", r.DefaultSubmissionCredentialID)
- if r.BatchJobConfig == nil {
- c.Add("batchJobConfig", "Batch job config cannot be null")
+ if r.DefaultBatchJobConfig == nil {
+ c.Add("defaultBatchJobConfig", "Default batch job config cannot be null")
} else {
- c.Nested("batchJobConfig", r.BatchJobConfig)
+ c.Nested("defaultBatchJobConfig", r.DefaultBatchJobConfig)
}
return c.Fields()
}
@@ -307,24 +304,22 @@ func (r *BatchDeploymentRequest) Validate() []httpx.FieldError {
//
// Java: org.apache.airavata.application.dto.deployment.BatchApplicationDeploymentResponseDto
type BatchDeploymentResponse struct {
- DeploymentID string `json:"deploymentId"`
- TemplateID *string `json:"templateId"`
- SlurmClusterID *string `json:"slurmClusterId"`
- SlurmRunSection string `json:"slurmRunSection"`
- BatchJobConfig *BatchJobConfigResponse `json:"batchJobConfig"`
- DefaultSubmissionCredentialID string `json:"defaultSubmissionCredentialId"`
- Partition *string `json:"partition"`
+ DeploymentID string `json:"deploymentId"`
+ TemplateID *string `json:"templateId"`
+ SlurmClusterID *string `json:"slurmClusterId"`
+ SlurmRunSection string `json:"slurmRunSection"`
+ DefaultBatchJobConfig *BatchJobConfigResponse `json:"defaultBatchJobConfig"`
+ DefaultPartition *string `json:"defaultPartition"`
}
func ToBatchDeploymentResponse(d *model.BatchDeployment) BatchDeploymentResponse {
return BatchDeploymentResponse{
- DeploymentID: d.ID,
- TemplateID: d.TemplateID,
- SlurmClusterID: d.ClusterID,
- SlurmRunSection: d.SlurmRunSection,
- BatchJobConfig: ToBatchJobConfigResponse(d.BatchJobConfig),
- DefaultSubmissionCredentialID: d.DefaultSubmissionCredentialID,
- Partition: d.Partition,
+ DeploymentID: d.ID,
+ TemplateID: d.TemplateID,
+ SlurmClusterID: d.ClusterID,
+ SlurmRunSection: d.SlurmRunSection,
+ DefaultBatchJobConfig: ToBatchJobConfigResponse(d.DefaultBatchJobConfig),
+ DefaultPartition: d.DefaultPartition,
}
}
diff --git a/api/application/model/deployment.go b/api/application/model/deployment.go
index 5bb3521fc6..120db6562d 100644
--- a/api/application/model/deployment.go
+++ b/api/application/model/deployment.go
@@ -5,7 +5,6 @@ import (
"gorm.io/gorm"
computemodel "github.com/apache/airavata/api/compute/model"
- cred "github.com/apache/airavata/api/credentials/model"
)
// BatchJobConfig is a batch-scheduler resource request.
@@ -76,16 +75,13 @@ type BatchDeployment struct {
// Owned one-to-one. The foreign key lives on this side and is unique, so no two
// deployments can share a config. Because the key points outward, the orphaned
// config row is removed by AfterDelete rather than by a database cascade.
- BatchJobConfigID string `gorm:"column:batch_job_config_id;type:varchar(36);not null;uniqueIndex" json:"batchJobConfigId"`
- BatchJobConfig *BatchJobConfig `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"batchJobConfig,omitempty"`
+ //
+ // It is the deployment's default: a run copies it into a config of its own and may
+ // depart from it, which is why the run's snapshot is what a job is submitted with.
+ DefaultBatchJobConfigID string `gorm:"column:default_batch_job_config_id;type:varchar(36);not null;uniqueIndex" json:"defaultBatchJobConfigId"`
+ DefaultBatchJobConfig *BatchJobConfig `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"defaultBatchJobConfig,omitempty"`
- // The default submission credential is an SSH endpoint credential binding rather
- // than a bare SSH credential: it ties the submitting identity to a specific host
- // and owner, the same association a job submitted under it will run as.
- DefaultSubmissionCredentialID string `gorm:"column:default_submission_credential_id;type:varchar(36);not null;index" json:"defaultSubmissionCredentialId"`
- DefaultSubmissionCredential *cred.SSHEndpointCredential `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"`
-
- Partition *string `gorm:"column:partition;type:varchar(255)" json:"partition,omitempty"`
+ DefaultPartition *string `gorm:"column:default_partition;type:varchar(255)" json:"defaultPartition,omitempty"`
}
// TableName returns the table backing BatchDeployment.
@@ -103,9 +99,9 @@ func (d *BatchDeployment) BeforeCreate(*gorm.DB) error {
// orphanRemoval: the foreign key points from the deployment to the config, so the
// database cannot cascade in this direction and the cleanup has to be explicit.
func (d *BatchDeployment) AfterDelete(tx *gorm.DB) error {
- if d.BatchJobConfigID == "" {
+ if d.DefaultBatchJobConfigID == "" {
return nil
}
- return tx.Where("batch_job_config_id = ?", d.BatchJobConfigID).
+ return tx.Where("batch_job_config_id = ?", d.DefaultBatchJobConfigID).
Delete(&BatchJobConfig{}).Error
}
diff --git a/api/application/repository/repository.go b/api/application/repository/repository.go
index 07dcc4f632..f6af9bf7cf 100644
--- a/api/application/repository/repository.go
+++ b/api/application/repository/repository.go
@@ -110,14 +110,14 @@ func (r *BatchDeploymentRepository) WithTx(tx *gorm.DB) *BatchDeploymentReposito
// FindAll returns every deployment with its owned batch job config.
func (r *BatchDeploymentRepository) FindAll(ctx context.Context) ([]model.BatchDeployment, error) {
var out []model.BatchDeployment
- err := r.db.WithContext(ctx).Preload("BatchJobConfig").Find(&out).Error
+ err := r.db.WithContext(ctx).Preload("DefaultBatchJobConfig").Find(&out).Error
return out, err
}
// FindByTemplateID returns every deployment of one template.
func (r *BatchDeploymentRepository) FindByTemplateID(ctx context.Context, templateID string) ([]model.BatchDeployment, error) {
var out []model.BatchDeployment
- err := r.db.WithContext(ctx).Preload("BatchJobConfig").
+ err := r.db.WithContext(ctx).Preload("DefaultBatchJobConfig").
Where("template_id = ?", templateID).Find(&out).Error
return out, err
}
@@ -125,7 +125,7 @@ func (r *BatchDeploymentRepository) FindByTemplateID(ctx context.Context, templa
// FindByID returns one deployment, or gorm.ErrRecordNotFound.
func (r *BatchDeploymentRepository) FindByID(ctx context.Context, id string) (*model.BatchDeployment, error) {
var out model.BatchDeployment
- err := r.db.WithContext(ctx).Preload("BatchJobConfig").
+ err := r.db.WithContext(ctx).Preload("DefaultBatchJobConfig").
First(&out, "deployment_id = ?", id).Error
if err != nil {
return nil, err
@@ -146,7 +146,7 @@ func (r *BatchDeploymentRepository) ExistsByTemplateID(ctx context.Context, temp
// SaveConfig, so that an update mutates the existing config row rather than
// replacing it and leaving an orphan.
func (r *BatchDeploymentRepository) Save(ctx context.Context, d *model.BatchDeployment) error {
- return r.db.WithContext(ctx).Omit("BatchJobConfig").Save(d).Error
+ return r.db.WithContext(ctx).Omit("DefaultBatchJobConfig").Save(d).Error
}
// SaveConfig inserts or updates an owned batch job config.
diff --git a/api/application/service/service.go b/api/application/service/service.go
index 73088e7380..83891f06f7 100644
--- a/api/application/service/service.go
+++ b/api/application/service/service.go
@@ -16,8 +16,6 @@ import (
model "github.com/apache/airavata/api/application/model"
"github.com/apache/airavata/api/application/repository"
computerepo "github.com/apache/airavata/api/compute/repository"
- cred "github.com/apache/airavata/api/credentials/model"
- credrepo "github.com/apache/airavata/api/credentials/repository"
)
func notFoundAs(err error, format string, args ...any) error {
@@ -166,11 +164,10 @@ func (s *TemplateService) Delete(ctx context.Context, id string) error {
// BatchDeploymentService manages deployments: a template made runnable somewhere.
type BatchDeploymentService struct {
- db *gorm.DB
- deployments *repository.BatchDeploymentRepository
- templates *repository.TemplateRepository
- clusters *computerepo.ClusterRepository
- endpointCreds *credrepo.SSHEndpointCredentialRepository
+ db *gorm.DB
+ deployments *repository.BatchDeploymentRepository
+ templates *repository.TemplateRepository
+ clusters *computerepo.ClusterRepository
}
// NewBatchDeploymentService returns a deployment service.
@@ -179,9 +176,8 @@ func NewBatchDeploymentService(
deployments *repository.BatchDeploymentRepository,
templates *repository.TemplateRepository,
clusters *computerepo.ClusterRepository,
- endpointCreds *credrepo.SSHEndpointCredentialRepository,
) *BatchDeploymentService {
- return &BatchDeploymentService{db: db, deployments: deployments, templates: templates, clusters: clusters, endpointCreds: endpointCreds}
+ return &BatchDeploymentService{db: db, deployments: deployments, templates: templates, clusters: clusters}
}
// List returns every deployment, or only those of templateID when it is non-empty.
@@ -225,25 +221,24 @@ func (s *BatchDeploymentService) Create(ctx context.Context, req *dto.BatchDeplo
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
deployments := s.deployments.WithTx(tx)
- template, cluster, endpointCred, err := s.resolveReferences(ctx, tx, req)
+ template, cluster, err := s.resolveReferences(ctx, tx, req)
if err != nil {
return err
}
config := &model.BatchJobConfig{}
- dto.ApplyBatchJobConfigRequest(config, req.BatchJobConfig)
+ dto.ApplyBatchJobConfigRequest(config, req.DefaultBatchJobConfig)
if err := deployments.SaveConfig(ctx, config); err != nil {
return err
}
deployment := &model.BatchDeployment{
- TemplateID: &template.ID,
- ClusterID: cluster,
- SlurmRunSection: req.SlurmRunSection,
- BatchJobConfigID: config.ID,
- BatchJobConfig: config,
- DefaultSubmissionCredentialID: endpointCred.ID,
- Partition: req.Partition,
+ TemplateID: &template.ID,
+ ClusterID: cluster,
+ SlurmRunSection: req.SlurmRunSection,
+ DefaultBatchJobConfigID: config.ID,
+ DefaultBatchJobConfig: config,
+ DefaultPartition: req.DefaultPartition,
}
if err := deployments.Save(ctx, deployment); err != nil {
return err
@@ -272,16 +267,16 @@ func (s *BatchDeploymentService) Update(ctx context.Context, id string, req *dto
if err != nil {
return notFoundAs(err, "Deployment not found: %s", id)
}
- template, cluster, endpointCred, err := s.resolveReferences(ctx, tx, req)
+ template, cluster, err := s.resolveReferences(ctx, tx, req)
if err != nil {
return err
}
- config := deployment.BatchJobConfig
+ config := deployment.DefaultBatchJobConfig
if config == nil {
- config = &model.BatchJobConfig{ID: deployment.BatchJobConfigID}
+ config = &model.BatchJobConfig{ID: deployment.DefaultBatchJobConfigID}
}
- dto.ApplyBatchJobConfigRequest(config, req.BatchJobConfig)
+ dto.ApplyBatchJobConfigRequest(config, req.DefaultBatchJobConfig)
if err := deployments.SaveConfig(ctx, config); err != nil {
return err
}
@@ -289,10 +284,9 @@ func (s *BatchDeploymentService) Update(ctx context.Context, id string, req *dto
deployment.TemplateID = &template.ID
deployment.ClusterID = cluster
deployment.SlurmRunSection = req.SlurmRunSection
- deployment.BatchJobConfigID = config.ID
- deployment.BatchJobConfig = config
- deployment.DefaultSubmissionCredentialID = endpointCred.ID
- deployment.Partition = req.Partition
+ deployment.DefaultBatchJobConfigID = config.ID
+ deployment.DefaultBatchJobConfig = config
+ deployment.DefaultPartition = req.DefaultPartition
if err := deployments.Save(ctx, deployment); err != nil {
return err
@@ -318,31 +312,26 @@ func (s *BatchDeploymentService) Delete(ctx context.Context, id string) error {
return s.deployments.Delete(ctx, deployment)
}
-// resolveReferences looks up the template, optional cluster and submission credential
-// a deployment request names.
+// resolveReferences looks up the template and the optional cluster a deployment
+// request names.
//
-// The cluster is the only optional one: an absent or blank id is legitimate and
-// leaves the deployment unbound to a cluster, while an id that is supplied but
-// unknown is an error. Conflating those would let a typo silently unbind a deployment.
-func (s *BatchDeploymentService) resolveReferences(ctx context.Context, tx *gorm.DB, req *dto.BatchDeploymentRequest) (*model.Template, *string, *cred.SSHEndpointCredential, error) {
+// The cluster is the optional one: an absent or blank id is legitimate and leaves the
+// deployment unbound to a cluster, while an id that is supplied but unknown is an
+// error. Conflating those would let a typo silently unbind a deployment.
+func (s *BatchDeploymentService) resolveReferences(ctx context.Context, tx *gorm.DB, req *dto.BatchDeploymentRequest) (*model.Template, *string, error) {
template, err := s.templates.WithTx(tx).FindByID(ctx, req.TemplateID)
if err != nil {
- return nil, nil, nil, notFoundAs(err, "Template not found: %s", req.TemplateID)
+ return nil, nil, notFoundAs(err, "Template not found: %s", req.TemplateID)
}
var clusterID *string
if req.SlurmClusterID != nil && strings.TrimSpace(*req.SlurmClusterID) != "" {
cluster, err := s.clusters.WithTx(tx).FindByID(ctx, *req.SlurmClusterID)
if err != nil {
- return nil, nil, nil, notFoundAs(err, "Cluster not found: %s", *req.SlurmClusterID)
+ return nil, nil, notFoundAs(err, "Cluster not found: %s", *req.SlurmClusterID)
}
clusterID = &cluster.ID
}
- endpointCred, err := s.endpointCreds.WithTx(tx).FindByID(ctx, req.DefaultSubmissionCredentialID)
- if err != nil {
- return nil, nil, nil, notFoundAs(err, "SSH endpoint credential not found: %s", req.DefaultSubmissionCredentialID)
- }
-
- return template, clusterID, endpointCred, nil
+ return template, clusterID, nil
}
diff --git a/api/process/dto/process.go b/api/process/dto/process.go
index 1975aac7ac..732b735746 100644
--- a/api/process/dto/process.go
+++ b/api/process/dto/process.go
@@ -36,10 +36,9 @@ type BatchProcessRequest struct {
// under — the id returned by POST /api/v1/ssh-endpoint-credentials, not a bare SSH
// credential.
//
- // Optional, and the reason the deployment's is called a *default*: a run that names
- // none submits under the deployment's, which is the ordinary case. Naming one is how
- // a caller submits under their own identity instead, and only a binding they own or
- // that is shared with them will be accepted.
+ // Required: a deployment carries no credential to fall back on, so every run says
+ // which identity it submits under, and only a binding the caller owns or that is
+ // shared with them will be accepted.
SubmissionCredentialID *string `json:"submissionCredentialId"`
// BaseWorkDir is the parent directory on the cluster this run works under; it gets
@@ -61,6 +60,7 @@ type BatchProcessRequest struct {
func (r *BatchProcessRequest) Validate() []httpx.FieldError {
var c httpx.Constraints
c.NotBlank("deploymentId", "Deployment id cannot be blank", r.DeploymentID)
+ c.NotBlankPtr("submissionCredentialId", "Submission credential id cannot be blank", r.SubmissionCredentialID)
if r.BatchJobConfig == nil {
c.Add("batchJobConfig", "Batch job config cannot be null")
} else {
@@ -84,7 +84,6 @@ type BatchProcessResponse struct {
JobName *string `json:"jobName"`
BatchJobConfig *applicationdto.BatchJobConfigResponse `json:"batchJobConfig"`
- // Always set, whether the run named a credential or inherited the deployment's.
SubmissionCredentialID string `json:"submissionCredentialId"`
BaseWorkDir *string `json:"baseWorkDir"`
diff --git a/api/process/service/launch.go b/api/process/service/launch.go
index a77c6f691e..08b1449823 100644
--- a/api/process/service/launch.go
+++ b/api/process/service/launch.go
@@ -60,11 +60,6 @@ func (s *LaunchService) launchBatchProcess(ctx context.Context, process *model.P
clusterStorage := cluster.SCPDataStorage
- if sshCredential == nil {
- // TODO: Validate the access to this submittion credential before using it
- sshCredential = batchDeployment.DefaultSubmissionCredential
- }
-
if sshCredential == nil {
return fmt.Errorf("No SSH credential available for batch process")
}
diff --git a/api/process/service/process.go b/api/process/service/process.go
index 98bedae92c..930826c687 100644
--- a/api/process/service/process.go
+++ b/api/process/service/process.go
@@ -227,7 +227,7 @@ func (s *ProcessService) saveBatchProcess(
if err != nil {
return nil, notFoundAs(err, "Deployment not found: %s", req.DeploymentID)
}
- submissionCredentialID, err := s.resolveSubmissionCredential(ctx, tx, deployment, req)
+ submissionCredentialID, err := s.resolveSubmissionCredential(ctx, tx, req)
if err != nil {
return nil, err
}
@@ -259,23 +259,21 @@ func (s *ProcessService) saveBatchProcess(
return batch, nil
}
-// resolveSubmissionCredential decides which SSH endpoint credential binding this run
-// submits under: the one it named, or the deployment's default when it named none.
+// resolveSubmissionCredential authorises the SSH endpoint credential binding this run
+// submits under.
//
-// Only a named binding is authorised, and it is authorised against the caller — this is
-// the one place in a self-service submission where a caller supplies an identity to act
-// under, so RequireUsable is what keeps them to their own bindings and the ones shared
-// with them. The deployment's default is exempt because it is not the caller's choice
-// at all: an admin configured it as how that deployment submits, and requiring every
-// user to hold it directly would leave ordinary submissions rejected.
+// The binding is authorised against the caller — this is the one place in a
+// self-service submission where a caller supplies an identity to act under, so
+// RequireUsable is what keeps them to their own bindings and the ones shared with
+// them. A deployment carries no default to fall back on, so validation already
+// rejects a request that names none.
func (s *ProcessService) resolveSubmissionCredential(
ctx context.Context,
tx *gorm.DB,
- deployment *applicationmodel.BatchDeployment,
req *dto.BatchProcessRequest,
) (string, error) {
if req.SubmissionCredentialID == nil || strings.TrimSpace(*req.SubmissionCredentialID) == "" {
- return deployment.DefaultSubmissionCredentialID, nil
+ return "", httpx.BadRequest("Submission credential id cannot be blank")
}
credential, err := s.credentials.WithTx(tx).RequireUsable(ctx, *req.SubmissionCredentialID)
if err != nil {
diff --git a/docs/api.md b/docs/api.md
index faa1a5336c..548a278714 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -29,7 +29,7 @@ Every failure — validation, authorization, missing record — comes back in th
}
```
-`fieldErrors` is omitted on every other kind of failure. Field names are the JSON paths of the request body, including indexes and nesting — `inputs[0].inputType`, `batchJobConfig.wallTimeMinutes`.
+`fieldErrors` is omitted on every other kind of failure. Field names are the JSON paths of the request body, including indexes and nesting — `inputs[0].inputType`, `defaultBatchJobConfig.wallTimeMinutes`.
The statuses in use:
@@ -416,7 +416,7 @@ curl -s localhost:9095/api/v1/clusters/"$CLUSTER_ID" \
Every path is scoped by its cluster, so a `partitionId` belonging to one cluster is a `404` when reached through another — an id alone is never enough to move or read a partition across clusters.
-`DELETE` returns `204 No Content`. Nothing holds a foreign key to a partition, so removing one never conflicts; note that a deployment names its partition by name rather than by id, and deleting the partition it names is not detected here.
+`DELETE` returns `204 No Content`. Nothing holds a foreign key to a partition, so removing one never conflicts; note that a deployment names its default partition by name rather than by id, and deleting the partition it names is not detected here.
### Create SSH Key
@@ -568,11 +568,11 @@ POST /api/v1/ssh-endpoint-credentials
Binds an SSH credential to an [SSH endpoint](#ssh-endpoints) so the caller can act on that host. Requires only an authenticated principal — not admin — but that principal must have a matching row in `users` (see INSTALL.md's "Owning resources requires a matching `users` row"), since the binding's owner is resolved from the token rather than accepted as a request field.
-The resulting binding id is what [Create Batch Deployment](#create-batch-deployment) below uses as `defaultSubmissionCredentialId`: a deployment submits under a specific endpoint credential, not a bare SSH credential, since the binding is what ties the submitting identity to both a host and an owner.
+The resulting binding id is what [Create Process](#create-process) below uses as `submissionCredentialId`: a run submits under a specific endpoint credential, not a bare SSH credential, since the binding is what ties the submitting identity to both a host and an owner.
**curl example**
-Uses `$SSH_ENDPOINT_ID` from [Create SSH Endpoint](#create-ssh-endpoint) and `$SSH_CREDENTIAL_ID` from [Create SSH Credential](#create-ssh-credential) above, and captures `sshEndpointCredentialId` into `$ENDPOINT_CREDENTIAL_ID` for the batch deployment step.
+Uses `$SSH_ENDPOINT_ID` from [Create SSH Endpoint](#create-ssh-endpoint) and `$SSH_CREDENTIAL_ID` from [Create SSH Credential](#create-ssh-credential) above, and captures `sshEndpointCredentialId` into `$ENDPOINT_CREDENTIAL_ID` for the process submission step.
```bash
ENDPOINT_CREDENTIAL_ID=$(curl -s -X POST localhost:9095/api/v1/ssh-endpoint-credentials \
@@ -1028,7 +1028,7 @@ Returned when e.g. `templateName` is blank, an `inputType`/`outputType` is missi
## Batch Deployments
-A batch deployment binds an application template to a Slurm cluster: the run script section, resource request (walltime, nodes, GPUs, ...) and the credential used to submit jobs. Deployments reference a template by id (see [Create Application Template](#create-application-template)).
+A batch deployment binds an application template to a Slurm cluster: the run script section and the resource request (walltime, nodes, GPUs, ...) a run of it starts from. Which identity a job submits under is a property of the run, not of the deployment, so it is named on the process instead. Deployments reference a template by id (see [Create Application Template](#create-application-template)).
### Create Batch Deployment
@@ -1040,7 +1040,7 @@ Requires `ADMIN` or `SUPER_ADMIN` authority.
**curl example**
-Uses `$TEMPLATE_ID` from [Create Application Template](#create-application-template), `$CLUSTER_ID` from [Create Cluster](#create-cluster), and `$ENDPOINT_CREDENTIAL_ID` from [Create SSH Endpoint Credential](#create-ssh-endpoint-credential) above.
+Uses `$TEMPLATE_ID` from [Create Application Template](#create-application-template) and `$CLUSTER_ID` from [Create Cluster](#create-cluster) above.
```bash
TOKEN='<the token printed at startup>'
@@ -1052,7 +1052,7 @@ curl -s -X POST localhost:9095/api/v1/slurm-deployments \
"templateId": "'"$TEMPLATE_ID"'",
"slurmClusterId": "'"$CLUSTER_ID"'",
"slurmRunSection": "module load alphafold\nrun_alphafold.sh --fasta_paths=$fastaFile --model_preset=$modelPreset",
- "batchJobConfig": {
+ "defaultBatchJobConfig": {
"wallTimeMinutes": 720,
"allocation": "TG-BIO210001",
"cpus": 8,
@@ -1062,8 +1062,7 @@ curl -s -X POST localhost:9095/api/v1/slurm-deployments \
"gres": "gpu:1",
"gpus": 1
},
- "defaultSubmissionCredentialId": "'"$ENDPOINT_CREDENTIAL_ID"'",
- "partition": "gpu"
+ "defaultPartition": "gpu"
}'
```
@@ -1074,31 +1073,30 @@ curl -s -X POST localhost:9095/api/v1/slurm-deployments \
| `templateId` | string | required, must reference an existing application template |
| `slurmClusterId` | string \| null | optional; if supplied, must reference an existing cluster — a typo is an error, not a silent unbind |
| `slurmRunSection` | string | required, cannot be blank |
-| `batchJobConfig` | object | required |
-| `batchJobConfig.wallTimeMinutes` | integer | required, must be positive |
-| `batchJobConfig.allocation` | string | required, cannot be blank |
-| `batchJobConfig.cpus` | integer \| null | optional |
-| `batchJobConfig.mem` | string \| null | optional |
-| `batchJobConfig.memPerCpu` | string \| null | optional |
-| `batchJobConfig.ntasksPerNode` | integer \| null | optional |
-| `batchJobConfig.cpusPerTask` | integer \| null | optional |
-| `batchJobConfig.nodes` | integer \| null | optional |
-| `batchJobConfig.ntasks` | integer \| null | optional |
-| `batchJobConfig.gres` | string \| null | optional |
-| `batchJobConfig.gpus` | integer \| null | optional |
-| `batchJobConfig.memPerGpu` | string \| null | optional |
-| `batchJobConfig.cpusPerGpu` | string \| null | optional |
-| `batchJobConfig.gpusPerNode` | integer \| null | optional |
-| `batchJobConfig.constraints` | string \| null | optional |
-| `defaultSubmissionCredentialId` | string | required, must reference an existing SSH endpoint credential binding (see [Create SSH Endpoint Credential](#create-ssh-endpoint-credential)), not a bare SSH credential |
-| `partition` | string \| null | optional |
+| `defaultBatchJobConfig` | object | required — the resource request a run of this deployment starts from |
+| `defaultBatchJobConfig.wallTimeMinutes` | integer | required, must be positive |
+| `defaultBatchJobConfig.allocation` | string | required, cannot be blank |
+| `defaultBatchJobConfig.cpus` | integer \| null | optional |
+| `defaultBatchJobConfig.mem` | string \| null | optional |
+| `defaultBatchJobConfig.memPerCpu` | string \| null | optional |
+| `defaultBatchJobConfig.ntasksPerNode` | integer \| null | optional |
+| `defaultBatchJobConfig.cpusPerTask` | integer \| null | optional |
+| `defaultBatchJobConfig.nodes` | integer \| null | optional |
+| `defaultBatchJobConfig.ntasks` | integer \| null | optional |
+| `defaultBatchJobConfig.gres` | string \| null | optional |
+| `defaultBatchJobConfig.gpus` | integer \| null | optional |
+| `defaultBatchJobConfig.memPerGpu` | string \| null | optional |
+| `defaultBatchJobConfig.cpusPerGpu` | string \| null | optional |
+| `defaultBatchJobConfig.gpusPerNode` | integer \| null | optional |
+| `defaultBatchJobConfig.constraints` | string \| null | optional |
+| `defaultPartition` | string \| null | optional |
```json
{
"templateId": "b6f1c2de-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
"slurmClusterId": "c1d2e3f4-5678-4abc-9def-0123456789ab",
"slurmRunSection": "module load alphafold\nrun_alphafold.sh --fasta_paths=$fastaFile --model_preset=$modelPreset",
- "batchJobConfig": {
+ "defaultBatchJobConfig": {
"wallTimeMinutes": 720,
"allocation": "TG-BIO210001",
"cpus": 8,
@@ -1108,14 +1106,13 @@ curl -s -X POST localhost:9095/api/v1/slurm-deployments \
"gres": "gpu:1",
"gpus": 1
},
- "defaultSubmissionCredentialId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
- "partition": "gpu"
+ "defaultPartition": "gpu"
}
```
**Response — `201 Created`**
-`deploymentId` and `batchJobConfig.batchJobConfigId` are server-generated (UUIDs).
+`deploymentId` and `defaultBatchJobConfig.batchJobConfigId` are server-generated (UUIDs).
```json
{
@@ -1123,7 +1120,7 @@ curl -s -X POST localhost:9095/api/v1/slurm-deployments \
"templateId": "b6f1c2de-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
"slurmClusterId": "c1d2e3f4-5678-4abc-9def-0123456789ab",
"slurmRunSection": "module load alphafold\nrun_alphafold.sh --fasta_paths=$fastaFile --model_preset=$modelPreset",
- "batchJobConfig": {
+ "defaultBatchJobConfig": {
"batchJobConfigId": "f6a7b8c9-de01-4f23-9456-789abcdef012",
"wallTimeMinutes": 720,
"allocation": "TG-BIO210001",
@@ -1141,14 +1138,13 @@ curl -s -X POST localhost:9095/api/v1/slurm-deployments \
"gpusPerNode": null,
"constraints": null
},
- "defaultSubmissionCredentialId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
- "partition": "gpu"
+ "defaultPartition": "gpu"
}
```
**Validation errors — `400 Bad Request`**
-Returned when e.g. `templateId`, `slurmRunSection` or `defaultSubmissionCredentialId` is blank, `batchJobConfig` is missing, or `batchJobConfig.wallTimeMinutes`/`allocation` fail their checks. A `templateId`, `slurmClusterId` or `defaultSubmissionCredentialId` that does not resolve to an existing record is returned as `404 Not Found` instead.
+Returned when e.g. `templateId` or `slurmRunSection` is blank, `defaultBatchJobConfig` is missing, or `defaultBatchJobConfig.wallTimeMinutes`/`allocation` fail their checks. A `templateId` or `slurmClusterId` that does not resolve to an existing record is returned as `404 Not Found` instead.
```json
{
@@ -1157,7 +1153,7 @@ Returned when e.g. `templateId`, `slurmRunSection` or `defaultSubmissionCredenti
"message": "Validation failed",
"fieldErrors": [
{ "field": "templateId", "message": "Template id cannot be blank" },
- { "field": "batchJobConfig.wallTimeMinutes", "message": "Wall time must be positive" }
+ { "field": "defaultBatchJobConfig.wallTimeMinutes", "message": "Wall time must be positive" }
]
}
```
@@ -1202,8 +1198,9 @@ Java service, which did the same.
POST /api/v1/processes
```
-Uses `$DEPLOYMENT_ID` from [Create Batch Deployment](#create-batch-deployment), and the
-`inputId`/`outputId` values returned by
+Uses `$DEPLOYMENT_ID` from [Create Batch Deployment](#create-batch-deployment),
+`$ENDPOINT_CREDENTIAL_ID` from [Create SSH Endpoint Credential](#create-ssh-endpoint-credential),
+and the `inputId`/`outputId` values returned by
[Create Application Template](#create-application-template).
```bash
@@ -1214,6 +1211,7 @@ curl -s -X POST localhost:9095/api/v1/processes \
"processType": "BATCH_JOB",
"batchProcess": {
"deploymentId": "'"$DEPLOYMENT_ID"'",
+ "submissionCredentialId": "'"$ENDPOINT_CREDENTIAL_ID"'",
"jobName": "alphafold-run-1",
"baseWorkDir": "/scratch/$USER/alphafold",
"batchJobConfig": {
@@ -1244,18 +1242,18 @@ There is no `userId`: ownership comes from the token.
| Field | Type | Notes |
|---|---|---|
| `deploymentId` | string | required, cannot be blank |
-| `batchJobConfig` | object | required — the same shape a deployment carries |
-| `submissionCredentialId` | string \| null | optional; the SSH endpoint credential binding this run submits under. Omitted, the run inherits the deployment's `defaultSubmissionCredentialId` |
+| `batchJobConfig` | object | required — the same shape a deployment's `defaultBatchJobConfig` carries |
+| `submissionCredentialId` | string | required, cannot be blank; the SSH endpoint credential binding this run submits under |
| `jobName` | string \| null | optional |
| `jobId` | string \| null | optional. Writable rather than server-generated: it is the scheduler's identifier for the submitted job, learned at submission time and recorded afterwards |
| `baseWorkDir` | string \| null | optional; the parent directory on the cluster this run works under. Launching a run that omits it fails, since there is nowhere to stage its files |
| `inputMappings` | array | optional; replaced wholesale by a `PUT` |
| `outputMappings` | array | optional; replaced wholesale by a `PUT` |
-The resource request is carried here rather than copied from the deployment's default,
-which is what lets a caller ask for different resources for a particular run. Each run
-owns its own `batchJobConfig` row, distinct from the deployment's, and deleting the
-process deletes it.
+The resource request is carried here rather than copied from the deployment's
+`defaultBatchJobConfig`, which is what lets a caller ask for different resources for a
+particular run. Each run owns its own `batchJobConfig` row, distinct from the
+deployment's, and deleting the process deletes it.
`baseWorkDir` is carried here for the same reason, and used the same way: the run works
in a subdirectory of it named for its `processId`, which is where the template's file
@@ -1264,12 +1262,10 @@ former `workDir`, so two runs of one deployment can work under different directo
`submissionCredentialId` is the one field of a self-service submission that names an
identity to act under, so it is authorized against the caller: a binding that does not
-exist is `404`, and one that is neither theirs nor shared with them is `403`. The
-deployment's default is exempt from that check — an admin configured it as how that
-deployment submits, and requiring every user to hold it directly would leave ordinary
-submissions rejected. A `PUT` re-resolves the field the same way, so omitting it falls
-back to the deployment's default rather than keeping what the run was created with. The
-response always carries the binding the run actually submits under, inherited or named.
+exist is `404`, and one that is neither theirs nor shared with them is `403`. A
+deployment carries no credential to fall back on, which is why the field is required
+rather than optional. A `PUT` re-resolves it the same way, so a body that omits it is
+rejected rather than keeping what the run was created with.
**`batchProcess.inputMappings` / `batchProcess.outputMappings`**
diff --git a/internal/app/app.go b/internal/app/app.go
index 1e5b638aa8..d6dbc5cb33 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -130,7 +130,7 @@ func New(cfg config.Config, db *gorm.DB) *Services {
ClusterPartition: computesvc.NewClusterPartitionService(db, partitions, clusters),
Template: applicationsvc.NewTemplateService(db, templates, deployments),
- BatchDeployment: applicationsvc.NewBatchDeploymentService(db, deployments, templates, clusters, bindings),
+ BatchDeployment: applicationsvc.NewBatchDeploymentService(db, deployments, templates, clusters),
SCPDataStorage: datasvc.NewSCPDataStorageService(db, storages, storageShares, endpoints, products, users, groupMembers),
SCPDataStorageSharing: datasvc.NewSCPDataStorageSharingService(db, storages, storageShares, groups, users, groupMembers),
diff --git a/internal/db/foreignkey_test.go b/internal/db/foreignkey_test.go
index ccdb565fd9..536114785b 100644
--- a/internal/db/foreignkey_test.go
+++ b/internal/db/foreignkey_test.go
@@ -50,8 +50,7 @@ func TestForeignKeyDirections(t *testing.T) {
{"application_template_outputs", "template_id", "application_templates", "template_id"},
{"batch_application_deployments", "cluster_id", "clusters", "cluster_id"},
{"batch_application_deployments", "template_id", "application_templates", "template_id"},
- {"batch_application_deployments", "batch_job_config_id", "batch_job_configs", "batch_job_config_id"},
- {"batch_application_deployments", "default_submission_credential_id", "ssh_endpoint_credentials", "ssh_endpoint_credential_id"},
+ {"batch_application_deployments", "default_batch_job_config_id", "batch_job_configs", "batch_job_config_id"},
{"processes", "user_id", "users", "user_id"},
{"batch_processes", "parent_process_id", "processes", "process_id"},
{"batch_processes", "deployment_id", "batch_application_deployments", "deployment_id"},
diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go
index e4f4bd0a94..f06707e42a 100644
--- a/internal/db/migrate_test.go
+++ b/internal/db/migrate_test.go
@@ -232,23 +232,6 @@ func TestDeletingClusterCascadesToPartitions(t *testing.T) {
func TestDeletingDeploymentRemovesOwnedBatchJobConfig(t *testing.T) {
gdb := newTestDB(t)
- key := &credentialsmodel.SSHKey{SSHKeyName: "k", PublicKey: "ssh-ed25519 AAAA", PrivateKey: "secret"}
- if err := gdb.Create(key).Error; err != nil {
- t.Fatalf("create ssh key: %v", err)
- }
- cred := &credentialsmodel.SSHUserCredential{Username: "dimuthu", SSHKeyID: &key.ID}
- if err := gdb.Create(cred).Error; err != nil {
- t.Fatalf("create ssh credential: %v", err)
- }
- endpoint := seedSSHEndpoint(t, gdb, "anvil")
- cluster := &computemodel.Cluster{ClusterName: "anvil", SSHEndpointID: &endpoint.ID, SlurmHome: "/usr/bin"}
- if err := gdb.Create(cluster).Error; err != nil {
- t.Fatalf("create cluster: %v", err)
- }
- binding := &credentialsmodel.SSHEndpointCredential{SSHEndpointID: &endpoint.ID, SSHCredentialID: &cred.ID}
- if err := gdb.Create(binding).Error; err != nil {
- t.Fatalf("create ssh endpoint credential: %v", err)
- }
tmpl := &applicationmodel.Template{TemplateName: ptr.To("gromacs")}
if err := gdb.Create(tmpl).Error; err != nil {
t.Fatalf("create template: %v", err)
@@ -259,10 +242,9 @@ func TestDeletingDeploymentRemovesOwnedBatchJobConfig(t *testing.T) {
}
dep := &applicationmodel.BatchDeployment{
- TemplateID: &tmpl.ID,
- SlurmRunSection: "module load gromacs\ngmx mdrun",
- BatchJobConfigID: cfg.ID,
- DefaultSubmissionCredentialID: binding.ID,
+ TemplateID: &tmpl.ID,
+ SlurmRunSection: "module load gromacs\ngmx mdrun",
+ DefaultBatchJobConfigID: cfg.ID,
}
if err := gdb.Create(dep).Error; err != nil {
t.Fatalf("create deployment: %v", err)
diff --git a/internal/db/migrations/0003_deployment_default_naming.sql b/internal/db/migrations/0003_deployment_default_naming.sql
new file mode 100644
index 0000000000..285ecd4019
--- /dev/null
+++ b/internal/db/migrations/0003_deployment_default_naming.sql
@@ -0,0 +1,29 @@
+-- A deployment's resource request and partition are defaults; its credential is gone.
+--
+-- default_submission_credential_id was the binding a run submitted under when it named
+-- none of its own. Which identity a job runs as is a property of the run, not of the
+-- deployment it was launched from, so batch_processes.submission_credential_id is now
+-- the only place it is recorded and a run has to name one. Dropping the column takes
+-- its foreign key and index with it.
+--
+-- The two remaining columns are renamed to say what they are. A run copies the
+-- deployment's resource request into a batch_job_configs row of its own and may depart
+-- from it, so what the deployment holds is a starting point rather than what any
+-- particular job is submitted with — the same relationship the partition has.
+--
+-- The constraint and the index are renamed alongside the column they guard: AutoMigrate
+-- derives both names from the field, so leaving them as they were would drift a
+-- migrated database from a development one, which is what
+-- TestBaselineMigrationMatchesAutoMigrate catches.
+
+ALTER TABLE "batch_application_deployments" DROP COLUMN "default_submission_credential_id";
+
+ALTER TABLE "batch_application_deployments" RENAME COLUMN "batch_job_config_id" TO "default_batch_job_config_id";
+ALTER TABLE "batch_application_deployments" RENAME COLUMN "partition" TO "default_partition";
+
+ALTER TABLE "batch_application_deployments"
+ RENAME CONSTRAINT "fk_batch_application_deployments_batch_job_config"
+ TO "fk_batch_application_deployments_default_batch_job_config";
+
+ALTER INDEX "idx_batch_application_deployments_batch_job_config_id"
+ RENAME TO "idx_batch_application_deployments_default_batch_job_config_id";
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index d5e42e17bd..2a08347532 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -201,8 +201,7 @@ func (h *harness) seedSSHKeyAndCredential(name string) (keyID, credentialID stri
}
// seedEndpointCredential creates an endpoint, an SSH credential, and a binding between
-// them, returning the binding's id — what a deployment's defaultSubmissionCredentialId
-// points at.
+// them, returning the binding's id — what a run's submissionCredentialId points at.
func (h *harness) seedEndpointCredential(name, token string) (endpointID, bindingID string) {
h.t.Helper()
endpointID = h.seedSSHEndpoint(name)
@@ -396,7 +395,6 @@ func TestDeletingSSHKeyInUseConflicts(t *testing.T) {
func TestDeletingTemplateWithDeploymentsConflicts(t *testing.T) {
h := newHarness(t)
- _, bindingID := h.seedEndpointCredential("deploy", tokenAdmin)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin, map[string]any{
"templateName": "gromacs",
@@ -408,8 +406,7 @@ func TestDeletingTemplateWithDeploymentsConflicts(t *testing.T) {
h.mustDo(http.MethodPost, "/api/v1/slurm-deployments", tokenAdmin, map[string]any{
"templateId": templateID, "slurmRunSection": "gmx mdrun",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "TG-1"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "TG-1"},
}, http.StatusCreated)
if rec := h.do(http.MethodDelete, "/api/v1/application-templates/"+templateID, tokenAdmin, nil); rec.Code != http.StatusConflict {
@@ -469,14 +466,12 @@ func TestDuplicateTemplateInputNamesAreRejected(t *testing.T) {
// unknown is an error rather than silently ignored.
func TestDeploymentClusterIsOptionalButValidatedWhenPresent(t *testing.T) {
h := newHarness(t)
- _, bindingID := h.seedEndpointCredential("optional-cluster", tokenAdmin)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin,
map[string]any{"templateName": "t"}, http.StatusCreated)
base := map[string]any{
"templateId": tmpl["templateId"], "slurmRunSection": "run",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 30, "allocation": "TG-2"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 30, "allocation": "TG-2"},
}
// Absent cluster: accepted.
@@ -1244,19 +1239,19 @@ func TestDataSharingRejectsBadRequests(t *testing.T) {
// seedProcess submits a process owned by the caller of token and returns its id.
func (h *harness) seedProcess(name, token string) string {
h.t.Helper()
- _, bindingID := h.seedEndpointCredential(name, tokenAdmin)
+ _, bindingID := h.seedEndpointCredential(name, token)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin,
map[string]any{"templateName": name}, http.StatusCreated)
deployment := h.mustDo(http.MethodPost, "/api/v1/slurm-deployments", tokenAdmin, map[string]any{
"templateId": tmpl["templateId"], "slurmRunSection": "run",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "DEFAULT"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "DEFAULT"},
}, http.StatusCreated)
proc := h.mustDo(http.MethodPost, "/api/v1/processes", token, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "ALLOC"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "ALLOC"},
},
}, http.StatusCreated)
return proc["processId"].(string)
@@ -1431,20 +1426,20 @@ func TestProcessTaskRejectsBadRequests(t *testing.T) {
func TestProcessSubmissionIsSelfServiceWithRequestedResources(t *testing.T) {
h := newHarness(t)
- _, bindingID := h.seedEndpointCredential("proc", tokenAdmin)
+ _, bindingID := h.seedEndpointCredential("proc", tokenAlice)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin,
map[string]any{"templateName": "proc"}, http.StatusCreated)
deployment := h.mustDo(http.MethodPost, "/api/v1/slurm-deployments", tokenAdmin, map[string]any{
"templateId": tmpl["templateId"], "slurmRunSection": "run",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "DEFAULT"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "DEFAULT"},
}, http.StatusCreated)
created := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 120, "allocation": "ALICE-ALLOC", "gpus": 2},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 120, "allocation": "ALICE-ALLOC", "gpus": 2},
},
}, http.StatusCreated)
@@ -1457,18 +1452,17 @@ func TestProcessSubmissionIsSelfServiceWithRequestedResources(t *testing.T) {
t.Errorf("config = %v, want the values from the request, not the deployment default", cfg)
}
// The process owns its own config, distinct from the deployment's.
- if cfg["batchJobConfigId"] == deployment["batchJobConfig"].(map[string]any)["batchJobConfigId"] {
+ if cfg["batchJobConfigId"] == deployment["defaultBatchJobConfig"].(map[string]any)["batchJobConfigId"] {
t.Error("process and deployment share one batch job config; each must own its own")
}
}
// Which SSH endpoint credential a run submits under is the one place a self-service
-// submission names an identity to act under, so it is authorised against the caller —
-// while the deployment's default, which an admin chose, is not.
+// submission names an identity to act under, so it is authorised against the caller.
+// A deployment carries no default to fall back on, so naming one is required.
func TestProcessSubmitsUnderACredentialTheCallerMayUse(t *testing.T) {
h := newHarness(t)
- deployment, _, _ := h.seedDeploymentWithIO("submission")
- deploymentDefault := deployment["defaultSubmissionCredentialId"].(string)
+ deployment, bindingID, _, _ := h.seedDeploymentWithIO("submission")
batch := func(credentialID any) map[string]any {
body := map[string]any{
"deploymentId": deployment["deploymentId"],
@@ -1484,20 +1478,19 @@ func TestProcessSubmitsUnderACredentialTheCallerMayUse(t *testing.T) {
map[string]any{"processType": "BATCH_JOB", "batchProcess": batch(credentialID)})
}
- // Naming none submits under the deployment's default, which is the ordinary case:
- // alice holds no binding of her own here, and the deployment's belongs to admin.
- created := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice,
- map[string]any{"processType": "BATCH_JOB", "batchProcess": batch(nil)}, http.StatusCreated)
- if got := created["batchProcess"].(map[string]any)["submissionCredentialId"]; got != deploymentDefault {
- t.Errorf("submissionCredentialId = %v, want the deployment default %s", got, deploymentDefault)
+ // Naming her own binding submits under it.
+ own := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice,
+ map[string]any{"processType": "BATCH_JOB", "batchProcess": batch(bindingID)}, http.StatusCreated)
+ if got := own["batchProcess"].(map[string]any)["submissionCredentialId"]; got != bindingID {
+ t.Errorf("submissionCredentialId = %v, want the named binding %s", got, bindingID)
}
- // Naming one of her own submits under that instead.
- _, aliceBinding := h.seedEndpointCredential("alice-submits", tokenAlice)
- own := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice,
- map[string]any{"processType": "BATCH_JOB", "batchProcess": batch(aliceBinding)}, http.StatusCreated)
- if got := own["batchProcess"].(map[string]any)["submissionCredentialId"]; got != aliceBinding {
- t.Errorf("submissionCredentialId = %v, want the named binding %s", got, aliceBinding)
+ // Naming none is a validation error rather than a run with no identity behind it.
+ rec := create(tokenAlice, nil)
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("no credential: status = %d, want 400", rec.Code)
+ } else if field := firstFieldError(t, rec); field != "batchProcess.submissionCredentialId" {
+ t.Errorf("field = %q, want it reported under the section", field)
}
// A binding that does not exist is a 404, and one that is neither hers nor shared
@@ -1510,13 +1503,16 @@ func TestProcessSubmitsUnderACredentialTheCallerMayUse(t *testing.T) {
t.Errorf("another user's credential: status = %d, want 403", rec.Code)
}
- // An update re-resolves it the same way, so dropping the field falls back to the
- // deployment's default rather than keeping what the run was created with.
+ // An update re-resolves it the same way, so it is required there too.
processID := own["processId"].(string)
+ if rec := h.do(http.MethodPut, "/api/v1/processes/"+processID, tokenAdmin,
+ map[string]any{"processType": "BATCH_JOB", "batchProcess": batch(nil)}); rec.Code != http.StatusBadRequest {
+ t.Errorf("update with no credential: status = %d, want 400", rec.Code)
+ }
updated := h.mustDo(http.MethodPut, "/api/v1/processes/"+processID, tokenAdmin,
- map[string]any{"processType": "BATCH_JOB", "batchProcess": batch(nil)}, http.StatusOK)
- if got := updated["batchProcess"].(map[string]any)["submissionCredentialId"]; got != deploymentDefault {
- t.Errorf("submissionCredentialId after update = %v, want the deployment default", got)
+ map[string]any{"processType": "BATCH_JOB", "batchProcess": batch(bindingID)}, http.StatusOK)
+ if got := updated["batchProcess"].(map[string]any)["submissionCredentialId"]; got != bindingID {
+ t.Errorf("submissionCredentialId after update = %v, want the named binding", got)
}
}
@@ -1524,9 +1520,9 @@ func TestProcessSubmitsUnderACredentialTheCallerMayUse(t *testing.T) {
// output, and returns the deployment together with those two declaration ids. The
// mapping tests need real declarations to point at: a mapping carries a foreign key to
// the template input it supplies a value for.
-func (h *harness) seedDeploymentWithIO(name string) (deployment map[string]any, inputID, outputID string) {
+func (h *harness) seedDeploymentWithIO(name string) (deployment map[string]any, bindingID, inputID, outputID string) {
h.t.Helper()
- _, bindingID := h.seedEndpointCredential(name, tokenAdmin)
+ _, bindingID = h.seedEndpointCredential(name, tokenAlice)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin, map[string]any{
"templateName": name,
"inputs": []any{map[string]any{"inputName": "sequence", "inputType": "FILE"}},
@@ -1534,23 +1530,23 @@ func (h *harness) seedDeploymentWithIO(name string) (deployment map[string]any,
}, http.StatusCreated)
deployment = h.mustDo(http.MethodPost, "/api/v1/slurm-deployments", tokenAdmin, map[string]any{
"templateId": tmpl["templateId"], "slurmRunSection": "run",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "DEFAULT"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "DEFAULT"},
}, http.StatusCreated)
inputs := tmpl["inputs"].([]any)
outputs := tmpl["outputs"].([]any)
- return deployment, inputs[0].(map[string]any)["inputId"].(string), outputs[0].(map[string]any)["outputId"].(string)
+ return deployment, bindingID, inputs[0].(map[string]any)["inputId"].(string), outputs[0].(map[string]any)["outputId"].(string)
}
// A batch job is not a resource of its own: it is configured as a section of the
// process body, so which sections a body may carry follows from its process type.
func TestProcessRejectsSectionsThatDoNotMatchItsType(t *testing.T) {
h := newHarness(t)
- deployment, _, _ := h.seedDeploymentWithIO("sections")
+ deployment, bindingID, _, _ := h.seedDeploymentWithIO("sections")
batch := map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
}
for _, tc := range []struct {
@@ -1578,8 +1574,9 @@ func TestProcessRejectsSectionsThatDoNotMatchItsType(t *testing.T) {
if rec := h.do(http.MethodPost, "/api/v1/processes", tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": "nope",
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
+ "deploymentId": "nope",
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
},
}); rec.Code != http.StatusNotFound {
t.Errorf("unknown deployment: status = %d, want 404", rec.Code)
@@ -1611,11 +1608,12 @@ func TestProcessWithoutABatchSectionIsStillAProcess(t *testing.T) {
// back nested in it, and replaced wholesale by an update.
func TestProcessCarriesItsTemplateMappings(t *testing.T) {
h := newHarness(t)
- deployment, inputID, outputID := h.seedDeploymentWithIO("mappings")
+ deployment, bindingID, inputID, outputID := h.seedDeploymentWithIO("mappings")
batch := func(extra map[string]any) map[string]any {
body := map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
}
for k, v := range extra {
body[k] = v
@@ -1712,13 +1710,14 @@ func TestProcessCarriesItsTemplateMappings(t *testing.T) {
// one orphaned with nothing pointing at it.
func TestUpdatingProcessKeepsItsOwnedConfig(t *testing.T) {
h := newHarness(t)
- deployment, _, _ := h.seedDeploymentWithIO("update")
+ deployment, bindingID, _, _ := h.seedDeploymentWithIO("update")
created := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
},
}, http.StatusCreated)
processID := created["processId"].(string)
@@ -1727,10 +1726,11 @@ func TestUpdatingProcessKeepsItsOwnedConfig(t *testing.T) {
updated := h.mustDo(http.MethodPut, "/api/v1/processes/"+processID, tokenAdmin, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 90, "allocation": "B"},
- "jobId": "4821577",
- "jobName": "fold-1",
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 90, "allocation": "B"},
+ "jobId": "4821577",
+ "jobName": "fold-1",
},
}, http.StatusOK)
@@ -1766,8 +1766,9 @@ func TestUpdatingProcessKeepsItsOwnedConfig(t *testing.T) {
if rec := h.do(http.MethodPut, "/api/v1/processes/"+processID, tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 90, "allocation": "B"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 90, "allocation": "B"},
},
}); rec.Code != http.StatusForbidden {
t.Errorf("update by the owner: status = %d, want 403", rec.Code)
@@ -1778,12 +1779,13 @@ func TestUpdatingProcessKeepsItsOwnedConfig(t *testing.T) {
// named there rather than on the process.
func TestListingProcessesByDeployment(t *testing.T) {
h := newHarness(t)
- deployment, _, _ := h.seedDeploymentWithIO("by-deployment")
+ deployment, bindingID, _, _ := h.seedDeploymentWithIO("by-deployment")
body := map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "A"},
},
}
h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice, body, http.StatusCreated)
@@ -1812,19 +1814,19 @@ func TestListingProcessesByDeployment(t *testing.T) {
// outward and the database cannot cascade in that direction.
func TestDeletingProcessRemovesItsOwnedConfig(t *testing.T) {
h := newHarness(t)
- _, bindingID := h.seedEndpointCredential("cleanup", tokenAdmin)
+ _, bindingID := h.seedEndpointCredential("cleanup", tokenAlice)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin,
map[string]any{"templateName": "cleanup"}, http.StatusCreated)
deployment := h.mustDo(http.MethodPost, "/api/v1/slurm-deployments", tokenAdmin, map[string]any{
"templateId": tmpl["templateId"], "slurmRunSection": "run",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
}, http.StatusCreated)
proc := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
},
}, http.StatusCreated)
@@ -1844,19 +1846,19 @@ func TestDeletingProcessRemovesItsOwnedConfig(t *testing.T) {
// reference before removing its statuses, or the RESTRICT constraints deadlock.
func TestDeletingProcessRemovesItsStatuses(t *testing.T) {
h := newHarness(t)
- _, bindingID := h.seedEndpointCredential("status-cleanup", tokenAdmin)
+ _, bindingID := h.seedEndpointCredential("status-cleanup", tokenAlice)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin,
map[string]any{"templateName": "status-cleanup"}, http.StatusCreated)
deployment := h.mustDo(http.MethodPost, "/api/v1/slurm-deployments", tokenAdmin, map[string]any{
"templateId": tmpl["templateId"], "slurmRunSection": "run",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
}, http.StatusCreated)
proc := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
},
}, http.StatusCreated)
processID := proc["processId"].(string)
@@ -1874,19 +1876,19 @@ func TestDeletingProcessRemovesItsStatuses(t *testing.T) {
// a caller never observes a process that exists but has no status history yet.
func TestCreatingProcessRecordsInitialCreatedStatus(t *testing.T) {
h := newHarness(t)
- _, bindingID := h.seedEndpointCredential("initial-status", tokenAdmin)
+ _, bindingID := h.seedEndpointCredential("initial-status", tokenAlice)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin,
map[string]any{"templateName": "initial-status"}, http.StatusCreated)
deployment := h.mustDo(http.MethodPost, "/api/v1/slurm-deployments", tokenAdmin, map[string]any{
"templateId": tmpl["templateId"], "slurmRunSection": "run",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
}, http.StatusCreated)
proc := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
},
}, http.StatusCreated)
processID := proc["processId"].(string)
@@ -1918,19 +1920,19 @@ func TestCreatingProcessRecordsInitialCreatedStatus(t *testing.T) {
// it — so the two write methods on the same path must fail, not silently succeed.
func TestProcessStatusWritesAreNotExposed(t *testing.T) {
h := newHarness(t)
- _, bindingID := h.seedEndpointCredential("status-readonly", tokenAdmin)
+ _, bindingID := h.seedEndpointCredential("status-readonly", tokenAlice)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin,
map[string]any{"templateName": "status-readonly"}, http.StatusCreated)
deployment := h.mustDo(http.MethodPost, "/api/v1/slurm-deployments", tokenAdmin, map[string]any{
"templateId": tmpl["templateId"], "slurmRunSection": "run",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
}, http.StatusCreated)
proc := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
},
}, http.StatusCreated)
processID := proc["processId"].(string)
@@ -1948,27 +1950,28 @@ func TestProcessStatusWritesAreNotExposed(t *testing.T) {
// the same scoping ClusterPartition already applies to its cluster.
func TestGetProcessStatusIsScopedToItsProcess(t *testing.T) {
h := newHarness(t)
- _, bindingID := h.seedEndpointCredential("status-scope", tokenAdmin)
+ _, bindingID := h.seedEndpointCredential("status-scope", tokenAlice)
tmpl := h.mustDo(http.MethodPost, "/api/v1/application-templates", tokenAdmin,
map[string]any{"templateName": "status-scope"}, http.StatusCreated)
deployment := h.mustDo(http.MethodPost, "/api/v1/slurm-deployments", tokenAdmin, map[string]any{
"templateId": tmpl["templateId"], "slurmRunSection": "run",
- "defaultSubmissionCredentialId": bindingID,
- "batchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
+ "defaultBatchJobConfig": map[string]any{"wallTimeMinutes": 60, "allocation": "A"},
}, http.StatusCreated)
procA := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
},
}, http.StatusCreated)
procB := h.mustDo(http.MethodPost, "/api/v1/processes", tokenAlice, map[string]any{
"processType": "BATCH_JOB",
"batchProcess": map[string]any{
- "deploymentId": deployment["deploymentId"],
- "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
+ "deploymentId": deployment["deploymentId"],
+ "submissionCredentialId": bindingID,
+ "batchJobConfig": map[string]any{"wallTimeMinutes": 10, "allocation": "B"},
},
}, http.StatusCreated)