blob: 930826c687a222b5af79813bdc84e2ab0a2b638a [file]
// Package service holds the process vertical's business rules: submitting a run under
// the caller's own identity, and recording what happened to it.
package service
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
"github.com/apache/airavata/internal/auth"
"github.com/apache/airavata/internal/httpx"
applicationdto "github.com/apache/airavata/api/application/dto"
applicationmodel "github.com/apache/airavata/api/application/model"
applicationrepo "github.com/apache/airavata/api/application/repository"
credsvc "github.com/apache/airavata/api/credentials/service"
iamrepo "github.com/apache/airavata/api/iam/repository"
dto "github.com/apache/airavata/api/process/dto"
model "github.com/apache/airavata/api/process/model"
"github.com/apache/airavata/api/process/repository"
)
func notFoundAs(err error, format string, args ...any) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return httpx.NotFound(format, args...)
}
return err
}
// ProcessService manages processes: individual runs, of whatever kind.
//
// Everything a run carries is managed from here. A batch process is written, read and
// deleted as a section of the process that owns it, never addressed on its own, so
// there is no service for it either — the API surface and the service surface say the
// same thing about what is a resource.
//
// Note the asymmetry with SSH endpoint credentials and SCP data. Submitting is
// self-service and ownership is taken from the token, but reads are not owner-scoped:
// listing by deployment and fetching by id carry no authorisation at all, matching
// the Java service. That is worth revisiting, but it is the behaviour clients have.
type ProcessService struct {
db *gorm.DB
processes *repository.ProcessRepository
deployments *applicationrepo.BatchDeploymentRepository
credentials *credsvc.CredentialAccess
users *iamrepo.UserRepository
statuses *StatusService
}
// NewProcessService returns a process service.
func NewProcessService(
db *gorm.DB,
processes *repository.ProcessRepository,
deployments *applicationrepo.BatchDeploymentRepository,
credentials *credsvc.CredentialAccess,
users *iamrepo.UserRepository,
statuses *StatusService,
) *ProcessService {
return &ProcessService{
db: db,
processes: processes,
deployments: deployments,
credentials: credentials,
users: users,
statuses: statuses,
}
}
// List returns every process across every user. Admin only.
func (s *ProcessService) List(ctx context.Context) ([]dto.Response, error) {
if _, err := auth.RequireAdmin(ctx); err != nil {
return nil, err
}
found, err := s.processes.FindAll(ctx)
if err != nil {
return nil, err
}
return dto.ToResponses(found), nil
}
// ListByDeployment returns every process run against one deployment.
func (s *ProcessService) ListByDeployment(ctx context.Context, deploymentID string) ([]dto.Response, error) {
found, err := s.processes.FindByDeploymentID(ctx, deploymentID)
if err != nil {
return nil, err
}
return dto.ToResponses(found), nil
}
// Get returns one process with every section it carries.
func (s *ProcessService) Get(ctx context.Context, id string) (*dto.Response, error) {
proc, err := s.processes.FindByID(ctx, id)
if err != nil {
return nil, notFoundAs(err, "Process not found: %s", id)
}
out := dto.ToResponse(proc)
return &out, nil
}
// Create submits a process for the calling user.
//
// Any authenticated caller may submit for themselves — this is self-service, not an
// administrative operation — and the owner always comes from the token.
func (s *ProcessService) Create(ctx context.Context, req *dto.Request) (*dto.Response, error) {
principal, err := auth.RequireAuthenticated(ctx)
if err != nil {
return nil, err
}
var out dto.Response
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
processes, users := s.processes.WithTx(tx), s.users.WithTx(tx)
owner, err := users.FindByID(ctx, principal.Name)
if err != nil {
return notFoundAs(err, "No user record found for authenticated principal: %s", principal.Name)
}
proc := &model.Process{OwnerID: &owner.ID, ProcessType: req.ProcessType}
if err := processes.Save(ctx, proc); err != nil {
return err
}
if req.BatchProcess != nil {
batch, err := s.saveBatchProcess(ctx, tx, proc.ID, nil, req.BatchProcess)
if err != nil {
return err
}
if err := s.saveMappings(ctx, tx, batch.ID, req.BatchProcess); err != nil {
return err
}
}
// Recorded in the same transaction as the process itself, so a caller never
// observes a process that exists but has no status yet.
if _, err := s.statuses.RecordTx(ctx, tx, proc.ID, model.ProcessStatusTypeCreated, nil); err != nil {
return err
}
// Read back rather than assembled from the request, so the response is the
// stored row — including last_status_id, which the status write just set.
return s.render(ctx, processes, proc.ID, &out)
})
if err != nil {
return nil, err
}
return &out, nil
}
// Update is an administrative correction of a process: its deployment, the resources
// it asked for, or its template mappings.
//
// Two things are deliberately immutable. Ownership, because re-deriving it from the
// caller's token would reassign the process to whichever admin issued the request.
// And the process type, because the sections a process carries follow from it — a
// BATCH_JOB turned into something else would strand its batch process with no way to
// reach it.
func (s *ProcessService) Update(ctx context.Context, id string, req *dto.Request) (*dto.Response, error) {
if _, err := auth.RequireAdmin(ctx); err != nil {
return nil, err
}
var out dto.Response
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
processes := s.processes.WithTx(tx)
proc, err := processes.FindByID(ctx, id)
if err != nil {
return notFoundAs(err, "Process not found: %s", id)
}
if proc.ProcessType != nil && req.ProcessType != nil && *proc.ProcessType != *req.ProcessType {
return httpx.Conflict("Process type cannot be changed: %s is a %s process", id, *proc.ProcessType)
}
if req.BatchProcess != nil {
batch, err := s.saveBatchProcess(ctx, tx, proc.ID, proc.BatchProcess, req.BatchProcess)
if err != nil {
return err
}
if err := s.saveMappings(ctx, tx, batch.ID, req.BatchProcess); err != nil {
return err
}
}
return s.render(ctx, processes, proc.ID, &out)
})
if err != nil {
return nil, err
}
return &out, nil
}
// Delete removes a process and everything it owns: its batch process and that
// section's resource request, its status history, its tasks and its mappings. Admin
// only.
func (s *ProcessService) Delete(ctx context.Context, id string) error {
if _, err := auth.RequireAdmin(ctx); err != nil {
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
processes := s.processes.WithTx(tx)
proc, err := processes.FindByID(ctx, id)
if err != nil {
return notFoundAs(err, "Process not found: %s", id)
}
return processes.Delete(ctx, proc)
})
}
// saveBatchProcess writes the batchProcess section, creating it on first write and
// mutating it afterwards.
//
// existing is the section already stored, or nil when there is none. Its ids are
// carried over rather than regenerated: a new BatchJobConfig id on every update would
// orphan the previous row, which is exactly what AfterDelete exists to prevent.
func (s *ProcessService) saveBatchProcess(
ctx context.Context,
tx *gorm.DB,
processID string,
existing *model.BatchJobProcess,
req *dto.BatchProcessRequest,
) (*model.BatchJobProcess, error) {
processes, deployments := s.processes.WithTx(tx), s.deployments.WithTx(tx)
deployment, err := deployments.FindByID(ctx, req.DeploymentID)
if err != nil {
return nil, notFoundAs(err, "Deployment not found: %s", req.DeploymentID)
}
submissionCredentialID, err := s.resolveSubmissionCredential(ctx, tx, req)
if err != nil {
return nil, err
}
batch := &model.BatchJobProcess{ProcessID: &processID}
config := &applicationmodel.BatchJobConfig{}
if existing != nil {
batch.ID = existing.ID
batch.BatchJobConfigID = existing.BatchJobConfigID
if existing.BatchJobConfig != nil {
config = existing.BatchJobConfig
} else {
config.ID = existing.BatchJobConfigID
}
}
applicationdto.ApplyBatchJobConfigRequest(config, req.BatchJobConfig)
if err := processes.SaveBatchJobConfig(ctx, config); err != nil {
return nil, err
}
dto.ApplyBatchProcessRequest(batch, req)
batch.DeploymentID = &deployment.ID
batch.SubmissionCredentialID = submissionCredentialID
batch.BatchJobConfigID = config.ID
if err := processes.SaveBatchProcess(ctx, batch); err != nil {
return nil, err
}
return batch, nil
}
// resolveSubmissionCredential authorises the SSH endpoint credential binding this run
// submits under.
//
// 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,
req *dto.BatchProcessRequest,
) (string, error) {
if req.SubmissionCredentialID == nil || strings.TrimSpace(*req.SubmissionCredentialID) == "" {
return "", httpx.BadRequest("Submission credential id cannot be blank")
}
credential, err := s.credentials.WithTx(tx).RequireUsable(ctx, *req.SubmissionCredentialID)
if err != nil {
return "", err
}
return credential.ID, nil
}
// saveMappings replaces a batch process's template input and output mapping sets.
//
// The sets are part of the batchProcess section rather than of the process, so this
// runs after the section has been written and is keyed by its id. A process with no
// batch section carries no mappings at all — there is no field to send them in.
func (s *ProcessService) saveMappings(ctx context.Context, tx *gorm.DB, batchProcessID string, req *dto.BatchProcessRequest) error {
processes := s.processes.WithTx(tx)
if err := processes.ReplaceInputMappings(ctx, batchProcessID, dto.ToInputMappingEntities(batchProcessID, req.InputMappings)); err != nil {
return err
}
return processes.ReplaceOutputMappings(ctx, batchProcessID, dto.ToOutputMappingEntities(batchProcessID, req.OutputMappings))
}
// render reloads a process and writes its response into out.
func (s *ProcessService) render(ctx context.Context, processes *repository.ProcessRepository, id string, out *dto.Response) error {
stored, err := processes.FindByID(ctx, id)
if err != nil {
return err
}
*out = dto.ToResponse(stored)
return nil
}