Filter-based ControlJob validates a bracketed -J value before fetching (400 with the parser's message; unbracketed names, including commands with spaces, unchanged). FetchJobList evaluates bracketed job_name entries through the selection predicates instead of unevaluated compound conditions, so -J 'foo[1]' acts on element 1 of every array named foo and never on ordinary jobs of that name. The most-recent-job rule applies only without an element number and without all-jobs, and treats the element of a one-element array like an ordinary job (isSingleElementArray, a documented approximation). The old index-section parser is deleted. Completes bug-1898 on the owned paths. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3225 lines
105 KiB
Go
3225 lines
105 KiB
Go
package controllers
|
|
|
|
import (
|
|
"apiserver/backend"
|
|
"apiserver/common"
|
|
"apiserver/models"
|
|
"apiserver/services"
|
|
"apiserver/store"
|
|
"apiserver/utils"
|
|
logger "apiserver/utils"
|
|
"crypto/md5"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
//TODO: Need assign clusterID globally.
|
|
clusterID = 1
|
|
apiserverID = 0
|
|
)
|
|
|
|
var (
|
|
SignalMap = map[string]string{
|
|
"kill": "KILL",
|
|
"resume": "CONT",
|
|
"stop": "STOP",
|
|
}
|
|
realCopyLocalFiles []string
|
|
fakeCopyRemoteFile []string
|
|
)
|
|
|
|
type JobController struct{}
|
|
|
|
// GetJobsInfo godoc
|
|
// @Summary Get jobs information
|
|
// @Description Get jobs information
|
|
// @Tags jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param filter query string "filter=[{user:eq:user1},{queue:eq:queue1}]"
|
|
// @Param fields query string "fields=jobid,status,queue"
|
|
// @Success 200 {array} models.JobInfo "Response for jobs info"
|
|
// @Failure 400 {object} models.Error
|
|
// @Router /api/v1/jobs [get]
|
|
func (j JobController) GetJobsInfo(ctx *gin.Context) {
|
|
logger.Debug("Enter GetJobsInfo() function from ", zap.String("client_ip", ctx.ClientIP()))
|
|
var fields []string
|
|
filters := make([]utils.FilterCondition, 0)
|
|
sortOrders := make([]utils.SortOrder, 0)
|
|
|
|
token := ctx.GetHeader("Authorization")
|
|
submitUser, err := GetUsernameFromToken(token)
|
|
if err != nil {
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
if ctx.IsAborted() {
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
|
|
// ---- check parameters ----
|
|
for param := range ctx.Request.URL.Query() {
|
|
logger.Debug("GetJobsInfo param is ", zap.Any("param", param))
|
|
if !utils.ContainsField(utils.SupportedQueryParams, param) && !utils.ContainsField(utils.TypeQueryParams, param) {
|
|
err := errors.New("invalid job info query parameters of : " + param)
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get query parameters
|
|
filterStr := ctx.Query("filter")
|
|
fieldsStr := ctx.Query("fields")
|
|
sortStr := ctx.Query("sort") // sort=job_id:asc,submit_time:desc
|
|
limitStr := ctx.Query("limit") // limit=100
|
|
pagetokenStr := ctx.Query("page_token") // page_token=Aafjakjf0023A
|
|
typeStr := ctx.Query("type") // type=array_stats
|
|
|
|
logger.Debug("Fields, filters values are:",
|
|
zap.String("filterStr", filterStr),
|
|
zap.String("fieldsStr", fieldsStr),
|
|
zap.String("sortStr", sortStr),
|
|
zap.String("limitStr", limitStr),
|
|
zap.String("pagetokenStr", pagetokenStr),
|
|
zap.String("typeStr", typeStr))
|
|
|
|
// ---- check limit value ----
|
|
limit, err := utils.ParseLimit(limitStr)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// ---- check type ----
|
|
typeValue, err := utils.ParseType(typeStr, models.JobAvailableTypes)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// ---- check filter ----
|
|
if filterStr != "" {
|
|
filters = utils.ParseFilters(filterStr)
|
|
if filters == nil {
|
|
err := errors.New("filter " + filterStr + " has syntax error")
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
} else {
|
|
logger.Debug("query filter is not specified, set submit_user as default filter: filter=[{submit_user:eq:" + submitUser)
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "submit_user",
|
|
Operator: "eq",
|
|
Value: submitUser,
|
|
})
|
|
}
|
|
|
|
// ---- validate every filter entry before any store access ----
|
|
// (validate-all-first, job-array-one-based-index): the filter loop below
|
|
// can query the store (exec_hosts) and return early with count 0, so
|
|
// every entry -- including every job selection -- is checked here first
|
|
// and the first rejection, in request order, rejects the whole query.
|
|
// Entries with a bracketed job_id/job_name value become row predicates.
|
|
filters, selPreds, ok := validateJobQueryFilters(ctx, filters, typeValue)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
permission, err := GetRequireResourcePermission(submitUser, "queue:view")
|
|
if err != nil {
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
var accessQueues []string
|
|
if permission.DomainType == models.DomainTypeAll {
|
|
accessQueues = store.GetAllQueueNames()
|
|
} else if permission.DomainType == models.DomainTypeQueue {
|
|
accessQueues = permission.DomainValues
|
|
}
|
|
logger.Debug("get job info accessQueues", zap.Any("accessQueues", accessQueues),
|
|
zap.Any("permission", permission))
|
|
// check job:view permission
|
|
permission, err = GetRequireResourcePermission(submitUser, "job:view")
|
|
logger.Debug("get job info for job:view permission:", zap.Any("permission", permission))
|
|
if err != nil || len(accessQueues) == 0 {
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
|
|
// if submit_user is not specified, use query request submission user
|
|
var specify_user bool = false
|
|
var delindex int = -1
|
|
for i, filter := range filters {
|
|
if filter.Field == "submit_user" {
|
|
specify_user = true
|
|
//handle submit_user = "all"
|
|
if filter.Value == models.DomainTypeAll {
|
|
delindex = i
|
|
}
|
|
if permission.DomainType != models.DomainTypeSelf {
|
|
submitUser = filter.Value
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if delindex != -1 {
|
|
filters = append(filters[:delindex], filters[delindex+1:]...)
|
|
}
|
|
if !specify_user || permission.DomainType == models.DomainTypeSelf {
|
|
logger.Debug("submit_user is not specified in query filter, add submit_user into filter: filter=[{submit_user:eq:" + submitUser)
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "submit_user",
|
|
Operator: "eq",
|
|
Value: submitUser,
|
|
})
|
|
}
|
|
|
|
for i, filter := range filters {
|
|
// check whether exist queue_name for rbac
|
|
if filter.Field == "queue_name" {
|
|
if accessQueues != nil {
|
|
found := false
|
|
for _, q := range accessQueues {
|
|
if filter.Value == q {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
logger.Debug("The queue name does not exist in accessible queues", zap.Any("filter.Value", filter.Value))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== exec_hosts ================
|
|
// filter is exec_hosts need special handling
|
|
// the exec_hosts query request is array, eg: "{exec_hosts:eq:h1,h2,hg1}
|
|
if filters[i].Field == "exec_hosts" {
|
|
if filters[i].Operator != "eq" {
|
|
err := fmt.Errorf("filter exec_hosts doesn't support operator rather than eq")
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
hosts := strings.Split(strings.TrimSpace(filters[i].Value), ",")
|
|
execHosts, err := GetHostList(hosts)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
//change the filter to get unique_job_id list from db.
|
|
subFilters := make([]utils.FilterCondition, 0)
|
|
subFilters = append(subFilters, utils.FilterCondition{
|
|
Field: "host_name",
|
|
Operator: "in",
|
|
Value: "(" + strings.Join(execHosts, ",") + ")",
|
|
})
|
|
|
|
uniqueJobIds, err := FetchJobIdList(subFilters)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
if len(uniqueJobIds) == 0 {
|
|
logger.Debug("no job found")
|
|
ReturnSuccess(ctx, common.SUCCESS_CODE, common.SUCCESS_MSG, map[string]interface{}{
|
|
"count": 0,
|
|
"rows": []interface{}{},
|
|
"page_token": "",
|
|
})
|
|
return
|
|
}
|
|
|
|
//Replace filter condition from "exec_hosts" to "unique_job_id".
|
|
uniqueJobIdStrs := make([]string, len(uniqueJobIds))
|
|
for i, uniqueJobId := range uniqueJobIds {
|
|
uniqueJobIdStrs[i] = strconv.FormatInt(uniqueJobId, 10)
|
|
}
|
|
filters[i].Field = "unique_job_id"
|
|
filters[i].Operator = "in"
|
|
filters[i].Value = "(" + strings.Join(uniqueJobIdStrs, ",")
|
|
} // end of "exec_hosts" filter
|
|
|
|
} // end of for { filters }
|
|
|
|
var job interface{}
|
|
if typeValue == "array_stats" {
|
|
job = &models.ArrayJobInfo{}
|
|
} else {
|
|
job = &models.JobInfo{}
|
|
}
|
|
|
|
// Apply RBAC queue filter exactly once after the filter loop
|
|
filters = applyQueueRBACFilter(filters, accessQueues)
|
|
|
|
logger.Debug("before call ConvertToNewFilter for all jobs", zap.Any("filters", filters))
|
|
|
|
filtercds, err := utils.ConvertToNewFilter(filters, job)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// ---- check fields ----
|
|
// get fields and do check, if not set, use default display columns
|
|
if typeValue == "array_stats" {
|
|
fields = models.ARRAY_JOB_DEFAULT_COLUMN
|
|
} else {
|
|
if fieldsStr != "" {
|
|
fields = utils.ParseFields(fieldsStr)
|
|
|
|
found1 := false
|
|
found2 := false
|
|
found3 := false
|
|
// verify the fields is in supported list
|
|
supportedFields := utils.GetSupportedFields(models.JobInfo{})
|
|
|
|
utils.AddFieldsToSupportedList(&supportedFields, models.JobAdditionalFields{})
|
|
logger.Debug("job supportedFields: %v", zap.Any("supportedFields", supportedFields))
|
|
|
|
for _, field := range fields {
|
|
if !utils.ContainsField(supportedFields, field) {
|
|
err := errors.New("unsupported query field: " + field)
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// if user not specify job_id in fields, add job_id as default field.
|
|
if field == "job_id" {
|
|
found1 = true
|
|
}
|
|
|
|
if field == "unique_job_id" {
|
|
found2 = true
|
|
}
|
|
|
|
if field == "job_name" {
|
|
found3 = true
|
|
}
|
|
}
|
|
if !found1 {
|
|
logger.Debug("user doesn't specify job_id in query fields, add it.")
|
|
fields = append(fields, "job_id")
|
|
}
|
|
if !found2 {
|
|
fields = append(fields, "unique_job_id")
|
|
}
|
|
|
|
if found3 {
|
|
fields = append(fields, "array_idx")
|
|
}
|
|
} else {
|
|
fields = models.JOB_DEFAULT_COLUMN
|
|
}
|
|
}
|
|
|
|
// Map fields to database fields
|
|
for i, field := range fields {
|
|
fields[i] = utils.GetMappedField(field)
|
|
}
|
|
|
|
// add some fields for rbac
|
|
if typeValue == "array_stats" {
|
|
fields = append(fields, "submit_user")
|
|
} else {
|
|
fields = append(fields, "submit_user", "queue_name")
|
|
}
|
|
|
|
// ---- check sort ----
|
|
// handle sort, when no sortStr specified, need add the primary key of job_info
|
|
// table into sortStr as default value
|
|
// parse sortStr
|
|
var sorts []utils.SortOrder
|
|
found := false
|
|
if sortStr != "" {
|
|
if typeValue == "array_stats" {
|
|
sorts, err = utils.ParseSortParam(sortStr, models.ARRAY_JOB_SUPPORT_FILTERS)
|
|
} else {
|
|
sorts, err = utils.ParseSortParam(sortStr, models.JOB_SUPPORT_SORT)
|
|
}
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
//check all sort column is in fields
|
|
err = utils.CheckAllMapKeyInList(sorts, fields)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
//the primary key of job_info table job_id should always be added into sortStr if user doesn't specify.
|
|
if typeValue == "array_stats" {
|
|
for _, sortent := range sorts {
|
|
if sortent.Column == "job_id" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
for _, sortent := range sorts {
|
|
if sortent.Column == "unique_job_id" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
// user not specify the unique_job_id, just append "unique_job_id:asc" at the end.
|
|
if !found || sortStr == "" {
|
|
if typeValue == "array_stats" {
|
|
logger.Debug("job_id is not specified, set sort=job_id:asc as default")
|
|
sorts = append(sorts, utils.SortOrder{
|
|
Column: "job_id",
|
|
Ascending: true,
|
|
})
|
|
} else {
|
|
logger.Debug("unique_job_id is not specified, set sort=unique_job_id:asc as default")
|
|
sorts = append(sorts, utils.SortOrder{
|
|
Column: "unique_job_id",
|
|
Ascending: true,
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
sortOrders = sorts
|
|
|
|
// ---- check paging ---
|
|
if pagetokenStr != "" {
|
|
pagetokenStr, err = utils.ParsePageToken(pagetokenStr)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
queryOpts, err := utils.BuildQueryOptions(filtercds, nil, sortOrders, limit, pagetokenStr)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
// job selections are evaluated as row predicates, before sorting and
|
|
// pagination (bug-1898)
|
|
queryOpts.Predicates = selPreds
|
|
|
|
logger.Debug("the job query request:", zap.Any("jquery request", filtercds), zap.Int("selection predicates", len(selPreds)), zap.Any("sortOrders", sortOrders), zap.Any("limit", limit),
|
|
zap.String("pagetoken", pagetokenStr), zap.Any("fields", fields))
|
|
|
|
var jqueryReply []utils.OrderMap // Declare jqueryReply
|
|
pagetoken := "" // Initialize pagetoken with an empty string or appropriate value
|
|
if typeValue == "array_stats" {
|
|
jqueryReply, pagetoken, err = services.QueryData(services.TableArrayJob, queryOpts, fields)
|
|
} else {
|
|
jqueryReply, pagetoken, err = services.QueryData(services.TableJobInfo, queryOpts, fields)
|
|
}
|
|
|
|
if err != nil {
|
|
logger.Error("Failed to query job.", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
logger.Debug("the job info query reply:", zap.Any("jqueryReply", jqueryReply), zap.String("pagetoken", pagetoken))
|
|
//convert job_status to readable string.
|
|
queryResult := processJobData(jqueryReply, pagetoken)
|
|
|
|
ReturnSuccess(ctx, common.SUCCESS_CODE, common.SUCCESS_MSG, queryResult)
|
|
}
|
|
|
|
// validateJobQueryFilters is GetJobsInfo's validate-all-first pass. It
|
|
// checks every filter entry, in request order and before any store access:
|
|
// the filter field is supported for the query type, job_id/job_name values
|
|
// carry no index under type=array_stats, single-value operators have one
|
|
// value, and job selections parse and suit their operator
|
|
// (BuildSelectionPredicates). On success it returns the plain filters (the
|
|
// converted bracketed entries removed, written bare identity keys normalised
|
|
// to decimal) and the selection predicates; on the first rejection it writes
|
|
// the 400 reply and returns ok == false.
|
|
func validateJobQueryFilters(ctx *gin.Context, filters []utils.FilterCondition,
|
|
typeValue string) ([]utils.FilterCondition, []func(utils.OrderMap) bool, bool) {
|
|
supported := models.JOB_SUPPORT_FILTERS
|
|
if typeValue == "array_stats" {
|
|
supported = models.ARRAY_JOB_SUPPORT_FILTERS
|
|
}
|
|
|
|
plain := make([]utils.FilterCondition, 0, len(filters))
|
|
var preds []func(utils.OrderMap) bool
|
|
for _, filter := range filters {
|
|
if !utils.ContainsField(supported, filter.Field) {
|
|
err := errors.New("unsupported filter: " + filter.Field)
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return nil, nil, false
|
|
}
|
|
|
|
if filter.Field == "job_id" || filter.Field == "job_name" {
|
|
values := extractJobNames(filter.Value)
|
|
for i := range values {
|
|
values[i] = strings.TrimSpace(values[i])
|
|
}
|
|
// statistics rows are arrays, not elements: no index allowed
|
|
// (checked before the selection parsers)
|
|
if typeValue == "array_stats" && strings.ContainsAny(filter.Value, "[]") {
|
|
err := errors.New(filter.Field + " with array index is not supported in statistics type")
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return nil, nil, false
|
|
}
|
|
// only "in", "between", "notin" take multiple values
|
|
if !(filter.Operator == "in" || filter.Operator == "between" || filter.Operator == "notin") && len(values) > 1 {
|
|
err := fmt.Errorf("operator doesn't allow to use multiple values: %v", values)
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return nil, nil, false
|
|
}
|
|
}
|
|
|
|
entryPlain, entryPreds, err := BuildSelectionPredicates([]utils.FilterCondition{filter})
|
|
if err != nil {
|
|
returnSelectionError(ctx, filter.Value, err)
|
|
return nil, nil, false
|
|
}
|
|
plain = append(plain, entryPlain...)
|
|
preds = append(preds, entryPreds...)
|
|
}
|
|
return plain, preds, true
|
|
}
|
|
|
|
// returnSelectionError writes the 400 reply of a rejected job selection or
|
|
// key. A selection-parser rejection (*SelError) passes its message through
|
|
// unchanged (it names the selection as written); any other rejection keeps
|
|
// today's "Invalid parameters: <reason>" text.
|
|
func returnSelectionError(ctx *gin.Context, raw string, err error) {
|
|
var se *SelError
|
|
if errors.As(err, &se) {
|
|
logger.Debug("rejected job selection", zap.String("selection", raw),
|
|
zap.String("rule", se.Rule), zap.String("reason", se.Msg))
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, se.Msg, nil)
|
|
return
|
|
}
|
|
logger.Debug("rejected job selection", zap.String("selection", raw), zap.Error(err))
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
}
|
|
|
|
// GetJobByIDs godoc
|
|
// @Summary Get multiple jobs by their IDs
|
|
// @Description Retrieve information for multiple jobs in a single request with optional field selection
|
|
// @Tags jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param jobids path string true "Comma-separated list of job IDs (e.g., 1001,1002,1003)"
|
|
// @Param fields query string false "Comma-separated list of fields to return (e.g., jobid,status,queue)"
|
|
// @Success 200 {object} JobsResponse "Successfully retrieved jobs"
|
|
// @Failure 400 INVALID_PARAMS_CODE "invalid parameters"
|
|
// @Failure 500 ERROR_MSG
|
|
// @Example {curl} Example Usage:
|
|
// @Example curl -X GET "http://localhost:8080/jobs/1001,1002,1003?fields=jobid,status,queue"
|
|
// @Router /jobs/{jobids} [get]
|
|
func (j JobController) GetJobByIDs(ctx *gin.Context) {
|
|
logger.Debug("Enter GetJobByIDs() function from ", zap.String("client_ip", ctx.ClientIP()))
|
|
|
|
token := ctx.GetHeader("Authorization")
|
|
logonUser, err := GetUsernameFromToken(token)
|
|
if err != nil {
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
if ctx.IsAborted() {
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
|
|
// ---- check parameters ----
|
|
for param := range ctx.Request.URL.Query() {
|
|
logger.Debug("GetJobByIDs param is ", zap.Any("param", param))
|
|
if !utils.ContainsField(utils.SpecificSupportedQueryParams, param) && !utils.ContainsField(utils.TypeQueryParams, param) {
|
|
err := errors.New("invalid specific job info query parameters of : " + param)
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get job IDs from URL parameter
|
|
jobIDsStr := ctx.Param("jobids")
|
|
fieldsStr := ctx.Query("fields")
|
|
typeStr := ctx.Query("type") // type=array_stats
|
|
|
|
logger.Debug("GetJobByIDs origin query string jobid, Fields, filters values are:",
|
|
zap.String("jobid", jobIDsStr),
|
|
zap.String("fieldsStr", fieldsStr),
|
|
zap.String("typeStr", typeStr))
|
|
|
|
// ---- check type ----
|
|
typeValue, err := utils.ParseType(typeStr, models.JobAvailableTypes)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// Extract job IDs using the new function
|
|
jobids := extractJobNames(jobIDsStr)
|
|
|
|
for i := range jobids {
|
|
jobids[i] = strings.TrimSpace(jobids[i])
|
|
}
|
|
logger.Debug("The input jobids", zap.Any("jobids", jobids))
|
|
|
|
// Validate every key before any store query (validate-all-first): the
|
|
// first rejected key, in request order, rejects the whole request.
|
|
sels := make([]Selection, 0, len(jobids))
|
|
for _, jobid := range jobids {
|
|
sel, se := ParseIdentitySelection(jobid)
|
|
if se != nil {
|
|
returnSelectionError(ctx, jobid, se)
|
|
return
|
|
}
|
|
sels = append(sels, sel)
|
|
}
|
|
|
|
mergeJqueryReply := make([]utils.OrderMap, 0)
|
|
var page_token = ""
|
|
for _, sel := range sels {
|
|
sortOrders := make([]utils.SortOrder, 0)
|
|
|
|
var job interface{}
|
|
if typeValue == "array_stats" {
|
|
job = &models.ArrayJobInfo{}
|
|
} else {
|
|
job = &models.JobInfo{}
|
|
}
|
|
filtercds, err := utils.ConvertToNewFilter([]utils.FilterCondition{{
|
|
Field: "job_id",
|
|
Operator: "eq",
|
|
Value: strconv.FormatUint(sel.Key, 10),
|
|
}}, job)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
// An element selection matches only that array's elements in its
|
|
// range (5.2, 5.11); statistics rows are whole arrays, so there the
|
|
// index part selects the array as before.
|
|
var preds []func(utils.OrderMap) bool
|
|
if sel.Form != SelBare && typeValue != "array_stats" {
|
|
preds = append(preds, identityMatcher(sel))
|
|
}
|
|
|
|
var fields []string
|
|
if typeValue == "array_stats" {
|
|
fields = models.ARRAY_JOB_DEFAULT_COLUMN
|
|
} else {
|
|
if fieldsStr != "" {
|
|
fields = utils.ParseFields(fieldsStr)
|
|
|
|
found1 := false
|
|
found2 := false
|
|
// verify the fields is in supported list
|
|
supportedFields := utils.GetSupportedFields(models.JobInfo{})
|
|
utils.AddFieldsToSupportedList(&supportedFields, models.JobAdditionalFields{})
|
|
logger.Debug("job supportedFields: %v", zap.Any("supportedFields", supportedFields))
|
|
|
|
for _, field := range fields {
|
|
if !utils.ContainsField(supportedFields, field) {
|
|
err := errors.New("unsupported query field: " + field)
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// if user not specify job_id in fields, add job_id as default field.
|
|
if field == "job_id" {
|
|
found1 = true
|
|
}
|
|
if field == "unique_job_id" {
|
|
found2 = true
|
|
}
|
|
}
|
|
|
|
if !found1 {
|
|
logger.Debug("user doesn't specify job_id in query fields, add it.")
|
|
fields = append(fields, "job_id")
|
|
}
|
|
if !found2 {
|
|
fields = append(fields, "unique_job_id")
|
|
}
|
|
} else { // default display columns
|
|
fields = models.JOB_DEFAULT_COLUMN
|
|
}
|
|
}
|
|
|
|
// Map fields to database fields
|
|
for i, field := range fields {
|
|
fields[i] = utils.GetMappedField(field)
|
|
}
|
|
// add some fields for rbac
|
|
if typeValue == "array_stats" {
|
|
fields = append(fields, "submit_user")
|
|
} else {
|
|
fields = append(fields, "submit_user", "queue_name")
|
|
}
|
|
|
|
// add a default order: sort=job_id:asc
|
|
if typeValue == "array_stats" {
|
|
sortOrders = append(sortOrders, utils.SortOrder{
|
|
Column: "job_id", // use unique_job_id as primary key
|
|
Ascending: true,
|
|
})
|
|
} else {
|
|
sortOrders = append(sortOrders, utils.SortOrder{
|
|
Column: "unique_job_id", // use unique_job_id as primary key
|
|
Ascending: true,
|
|
})
|
|
}
|
|
|
|
var bookmark string = ""
|
|
var limit int = utils.PER_PAGE_LIMIT_MAX
|
|
for {
|
|
queryOpts, err := utils.BuildQueryOptions(filtercds, nil, sortOrders, limit, bookmark)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
queryOpts.Predicates = preds
|
|
logger.Debug("the job query request:", zap.Any("jquery request", filtercds), zap.String("selection", sel.Raw), zap.Any("sortOrders", sortOrders), zap.Any("limit", limit),
|
|
zap.String("pagetoken", bookmark), zap.Any("fields", fields))
|
|
|
|
var jqueryReply []utils.OrderMap // Declare jqueryReply
|
|
|
|
if typeValue == "array_stats" {
|
|
jqueryReply, page_token, err = services.QueryData(services.TableArrayJob, queryOpts, fields)
|
|
} else {
|
|
jqueryReply, page_token, err = services.QueryData(services.TableJobInfo, queryOpts, fields)
|
|
}
|
|
|
|
if err != nil {
|
|
logger.Error("Failed to query job info.", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
// Here, page_token is escaped and will not be recognized by dbapi,
|
|
// for internal use, need add Unescape here.
|
|
bookmark, _ = url.QueryUnescape(page_token)
|
|
mergeJqueryReply = append(mergeJqueryReply, jqueryReply...)
|
|
if bookmark == "" {
|
|
break
|
|
}
|
|
} // end for
|
|
|
|
} // end for jobid
|
|
logger.Debug("the specific jobid query reply after merge:", zap.Any("jqueryReply", mergeJqueryReply))
|
|
|
|
// check rbac
|
|
removeFlag := false
|
|
for i, row := range mergeJqueryReply {
|
|
jobPermission, err := GetRequireResourcePermission(logonUser, "job:view")
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
queuePermission, err := GetRequireResourcePermission(logonUser, "queue:view")
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
if job_owner, exist := row.Data["submit_user"]; exist {
|
|
if jobPermission.DomainType == models.DomainTypeSelf {
|
|
if logonUser != job_owner.(string) {
|
|
removeFlag = true
|
|
mergeJqueryReply = append(mergeJqueryReply[:i], mergeJqueryReply[i+1:]...)
|
|
}
|
|
}
|
|
}
|
|
if typeValue != "array_stats" {
|
|
if job_queue, exist := row.Data["queue_name"]; exist {
|
|
queueName, _ := job_queue.(string)
|
|
if queuePermission.DomainType == models.DomainTypeQueue {
|
|
permissionQueueValues := queuePermission.DomainValues
|
|
if !utils.ContainsField(permissionQueueValues, queueName) {
|
|
removeFlag = true
|
|
mergeJqueryReply = append(mergeJqueryReply[:i], mergeJqueryReply[i+1:]...)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
if removeFlag && len(mergeJqueryReply) == 0 {
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
|
|
//convert job_status to readable string.
|
|
queryResult := processJobData(mergeJqueryReply, page_token)
|
|
|
|
ReturnSuccess(ctx, common.SUCCESS_CODE, common.SUCCESS_MSG, queryResult)
|
|
}
|
|
|
|
func extractJobNames(jobNamesStr string) []string {
|
|
// Trim surrounding parentheses if present
|
|
if len(jobNamesStr) > 1 && jobNamesStr[0] == '(' && jobNamesStr[len(jobNamesStr)-1] == ')' {
|
|
jobNamesStr = jobNamesStr[1 : len(jobNamesStr)-1]
|
|
}
|
|
|
|
var jobNames []string
|
|
currentJobName := ""
|
|
bracketCount := 0
|
|
|
|
for _, char := range jobNamesStr {
|
|
if char == '[' {
|
|
bracketCount++
|
|
} else if char == ']' {
|
|
bracketCount--
|
|
} else if char == ',' && bracketCount == 0 {
|
|
// If we encounter a comma and we're not inside brackets, we split here
|
|
jobNames = append(jobNames, currentJobName)
|
|
currentJobName = ""
|
|
continue
|
|
}
|
|
currentJobName += string(char)
|
|
}
|
|
// Add the last job name if there's any remaining
|
|
if currentJobName != "" {
|
|
jobNames = append(jobNames, currentJobName)
|
|
}
|
|
|
|
return jobNames
|
|
}
|
|
|
|
func (j JobController) RestartJobByID(ctx *gin.Context) {
|
|
logger.Debug("Enter RestartJobByID() function from ", zap.String("client_ip", ctx.ClientIP()))
|
|
|
|
// get submit user from token
|
|
token := ctx.GetHeader("Authorization")
|
|
restarter, err := GetUsernameFromToken(token)
|
|
if err != nil {
|
|
logger.Error("Get username from token error", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// check jobid value
|
|
jobIDStr := ctx.Param("jobids")
|
|
logger.Debug("Receive job restart request:",
|
|
zap.String("jobId", jobIDStr), zap.String("restarter", restarter))
|
|
|
|
if err = isValidJobId(jobIDStr); err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// check post body whether correct
|
|
var req models.JobRestartRequest
|
|
if err := utils.BindJSONStrict(ctx, &req); err != nil {
|
|
logger.Error("Invalid request parameters", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
err = preCheckJobRestartRequest(&req)
|
|
if err != nil {
|
|
logger.Error("Failed to restart job", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
compfilters := make(utils.CompFilterCondition)
|
|
filtCdArrays := make([]utils.FilterConditionArray, 0)
|
|
subORFilters := make([]utils.FilterCondition, 0)
|
|
|
|
jobid := strings.TrimSpace(jobIDStr)
|
|
filters := make([]utils.FilterCondition, 0)
|
|
sortOrders := make([]utils.SortOrder, 0)
|
|
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "job_id",
|
|
Operator: "eq",
|
|
Value: jobid,
|
|
})
|
|
mainFiltCond := utils.FilterCondition{
|
|
Field: filters[0].Field,
|
|
Operator: filters[0].Operator,
|
|
Value: jobid,
|
|
}
|
|
|
|
fields := utils.GetSupportedFields(models.JobInfo{})
|
|
|
|
filtCdArrays = append(filtCdArrays, utils.FilterConditionArray{
|
|
MainFiltCond: mainFiltCond,
|
|
SubFiltConds: subORFilters,
|
|
})
|
|
|
|
compfilters["job_id"] = filtCdArrays
|
|
|
|
var job interface{}
|
|
|
|
job = &models.JobInfo{}
|
|
|
|
compfiltercd, err := utils.ConvertToNewFilterArray(compfilters, job)
|
|
|
|
var limit int = utils.PER_PAGE_LIMIT_MAX
|
|
var bookmark string = ""
|
|
queryOpts, err := utils.BuildQueryOptions(nil, compfiltercd, sortOrders, limit, bookmark)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
logger.Debug("the job query request:", zap.Any("jquery request arrayjob:", compfiltercd), zap.Any("sortOrders", sortOrders), zap.Any("limit", limit),
|
|
zap.String("pagetoken", bookmark), zap.Any("fields", fields))
|
|
|
|
var jqueryReply []utils.OrderMap
|
|
|
|
jqueryReply, page_token, err := services.QueryData(services.TableJobInfo, queryOpts, fields)
|
|
if err != nil {
|
|
logger.Error("Failed to query job info.", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
// Here, page_token is escaped and will not be recognized by dbapi,
|
|
bookmark, _ = url.QueryUnescape(page_token)
|
|
if len(jqueryReply) == 0 {
|
|
logger.Error("No job found for the given job ID", zap.String("jobID", jobIDStr))
|
|
ReturnError(ctx, common.ERROR_NOT_FOUND_CODE, common.ERROR_JOB_NOT_FOUND_MSG, nil)
|
|
return
|
|
}
|
|
|
|
oldJob := jqueryReply[0].Data // Get the first job info
|
|
logger.Debug("Old job info:", zap.Any("oldJob", oldJob))
|
|
if v, ok := oldJob["submit_user"].(string); ok {
|
|
if v != restarter {
|
|
err := fmt.Errorf("job <%s> is not submitted by <%s>, cannot be restarted by <%s>",
|
|
jobIDStr, v, restarter)
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
jobID, err := backend.GenerateJobID(clusterID, apiserverID, 1)
|
|
if err != nil {
|
|
logger.Error("Failed to generate job ID for restart", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, "failed to generate job ID: "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// Use a concrete type for job
|
|
newJob := models.JobInfo{
|
|
Unique_JobID: int64(jobID),
|
|
JobID: int64(backend.ExtractArrayBaseID(jobID)),
|
|
ArrayIdx: backend.ExtractArrayIdx(jobID),
|
|
SubmitUser: restarter,
|
|
JobStatus: models.JOBSTAT["PEND"],
|
|
}
|
|
|
|
err = processJobRestartRequest(&newJob, &req)
|
|
if err != nil {
|
|
logger.Error("Failed to restart job", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
if v, ok := oldJob["job_name"].(string); ok {
|
|
newJob.JobName = v
|
|
}
|
|
|
|
if chkpntStr, ok := oldJob["chkpnt_info"].(string); ok && chkpntStr != "" {
|
|
var chkpntInfo map[string]interface{}
|
|
if err := json.Unmarshal([]byte(chkpntStr), &chkpntInfo); err == nil {
|
|
if restartCmd, ok := chkpntInfo["restart_command"].(string); ok {
|
|
logger.Debug("Restart job with checkpoint command", zap.String("restart_command", restartCmd))
|
|
newJob.Command = restartCmd
|
|
}
|
|
if ckpt_pid, ok := chkpntInfo["ckpt_pid"].(float64); ok {
|
|
extendData := map[string]interface{}{}
|
|
if v, ok := oldJob["extend_data"].(string); ok && v != "" {
|
|
_ = json.Unmarshal([]byte(v), &extendData)
|
|
}
|
|
if v, ok := extendData["sub_env_vars"]; ok && v != nil {
|
|
if sub, ok := v.(map[string]interface{}); ok {
|
|
sub["XCE_JOB_CHECKPOINT_PID"] = strconv.FormatFloat(ckpt_pid, 'f', -1, 64)
|
|
extendData["sub_env_vars"] = sub
|
|
}
|
|
} else {
|
|
extendData["sub_env_vars"] = map[string]interface{}{
|
|
"XCE_JOB_CHECKPOINT_PID": strconv.FormatFloat(ckpt_pid, 'f', -1, 64),
|
|
}
|
|
}
|
|
extendDataBytes, _ := json.Marshal(extendData)
|
|
newJob.ExtendData = models.Jsonb{String: string(extendDataBytes)}
|
|
} else {
|
|
if v, ok := oldJob["extend_data"].(string); ok && v != "" {
|
|
newJob.ExtendData = models.Jsonb{String: v}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if v, ok := oldJob["command"].(string); ok {
|
|
newJob.Command = v
|
|
}
|
|
}
|
|
|
|
newJob.ChkpntInfo = models.Jsonb{} //Reset checkpoint_info field
|
|
if v, ok := oldJob["queue_name"].(string); ok {
|
|
newJob.QueueName = v
|
|
}
|
|
if v, ok := oldJob["project_name"].(string); ok {
|
|
newJob.ProjectName = v
|
|
}
|
|
if v, ok := oldJob["user_group"].(string); ok {
|
|
newJob.UserGroup = v
|
|
}
|
|
if v, ok := oldJob["job_group"].(string); ok {
|
|
newJob.JobGroup = v
|
|
}
|
|
if v, ok := oldJob["license_project"].(string); ok {
|
|
newJob.LicenseProject = v
|
|
}
|
|
if v, ok := oldJob["job_description"].(string); ok {
|
|
newJob.JobDescription = v
|
|
}
|
|
if v, ok := oldJob["res_req"].(string); ok {
|
|
newJob.ResReq = v
|
|
}
|
|
if v, ok := oldJob["rsv_id"].(string); ok {
|
|
newJob.RsvID = v
|
|
}
|
|
if v, ok := oldJob["sla"].(string); ok {
|
|
newJob.SLA = v
|
|
}
|
|
if v, ok := oldJob["maxnum_processors"].(int); ok {
|
|
newJob.MaxNumProcessors = v
|
|
}
|
|
if v, ok := oldJob["num_processors"].(int); ok {
|
|
newJob.NumProcessors = v
|
|
}
|
|
if newJob.AskedHosts == "" {
|
|
if v, ok := oldJob["asked_hosts"].(string); ok {
|
|
newJob.AskedHosts = v
|
|
}
|
|
}
|
|
if v, ok := oldJob["begin_time"].(time.Time); ok {
|
|
newJob.BeginTime = models.CustomTime{Time: v}
|
|
}
|
|
if v, ok := oldJob["term_time"].(time.Time); ok {
|
|
newJob.TermTime = models.CustomTime{Time: v}
|
|
}
|
|
if v, ok := oldJob["job_priority"].(int); ok {
|
|
newJob.JobPriority = v
|
|
}
|
|
if v, ok := oldJob["user_priority"].(int); ok {
|
|
newJob.UserPriority = v
|
|
}
|
|
if v, ok := oldJob["depend_cond"].(string); ok {
|
|
newJob.DependCond = v
|
|
}
|
|
if v, ok := oldJob["cwd"].(string); ok {
|
|
newJob.CWD = v
|
|
}
|
|
if v, ok := oldJob["from_host"].(string); ok {
|
|
newJob.FromHost = v
|
|
}
|
|
if v, ok := oldJob["pre_exec_cmd"].(string); ok {
|
|
newJob.PreExecCmd = v
|
|
}
|
|
if v, ok := oldJob["post_exec_cmd"].(string); ok {
|
|
newJob.PostExecCmd = v
|
|
}
|
|
if v, ok := oldJob["requeue_error_values"].(string); ok {
|
|
newJob.RequeueErrorValues = v
|
|
}
|
|
if v, ok := oldJob["run_time_estimation"].(int); ok {
|
|
newJob.RunTimeEstimation = v
|
|
}
|
|
if v, ok := oldJob["mig_threshold"].(int); ok {
|
|
newJob.MigThreshold = v
|
|
}
|
|
if v, ok := oldJob["chkpnt_period"].(int); ok {
|
|
newJob.ChkpntPeriod = v
|
|
}
|
|
if v, ok := oldJob["chkpnt_dir"].(string); ok {
|
|
newJob.ChkpntDir = v
|
|
}
|
|
if v, ok := oldJob["out_dir"].(string); ok {
|
|
newJob.OutDir = v
|
|
}
|
|
if v, ok := oldJob["in_file"].(string); ok {
|
|
newJob.InFile = v
|
|
}
|
|
if v, ok := oldJob["err_file"].(string); ok {
|
|
newJob.ErrFile = v
|
|
}
|
|
if v, ok := oldJob["out_file"].(string); ok {
|
|
newJob.OutFile = v
|
|
}
|
|
if v, ok := oldJob["login_shell"].(string); ok {
|
|
newJob.LoginShell = v
|
|
}
|
|
logger.Info("Generated job ID for restart", zap.Int64("jobID", newJob.JobID))
|
|
|
|
err = services.SubmitJob(&newJob, 1)
|
|
if err != nil {
|
|
logger.Error("Failed to submit job for restart process.", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_WRITE_DATA_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
response := models.JobSubmitResponse{
|
|
Message: fmt.Sprintf("Job <%s> will be restarted with new job <%d> to default queue <%s>.", jobid, newJob.JobID, newJob.QueueName),
|
|
JobID: newJob.JobID,
|
|
QueueName: newJob.QueueName,
|
|
}
|
|
ReturnSuccess(ctx, common.CREATED, common.SUCCESS_MSG, response)
|
|
return
|
|
}
|
|
|
|
// CreateJob godoc
|
|
// @Summary Create a new job
|
|
// @Description Submit a new job with specified requirements and parameters
|
|
// @Tags jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param job body JobSubmitRequest true "Job submission parameters"
|
|
// @Success 201 {object} JobSubmitResponse "Job successfully created"
|
|
// @Failure 400 {object} ErrorResponse "Invalid request body or parameters"
|
|
// @Failure 401 {object} ErrorResponse "Unauthorized - Invalid or missing credentials"
|
|
// @Failure 403 {object} ErrorResponse "Forbidden - User lacks permission"
|
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
|
// @Router /jobs [post]
|
|
func (j JobController) CreateJob(ctx *gin.Context) {
|
|
logger.Debug("Enter CreateJob() function from ", zap.String("client_ip", ctx.ClientIP()))
|
|
|
|
token := ctx.GetHeader("Authorization")
|
|
submitUser, err := GetUsernameFromToken(token)
|
|
if err != nil {
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
var req models.JobSubmitRequest
|
|
|
|
if err := utils.BindJSONStrict(ctx, &req); err != nil {
|
|
logger.Error("Invalid request parameters", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// precheck the request parameter before generating the job ID
|
|
arraySize, isArray, err := preCheckJobRequest(&req)
|
|
if err != nil {
|
|
logger.Error("Failed to submit job request", zap.Error(err))
|
|
// An array index-expression error is the user's input: 400 with the
|
|
// message unchanged (2.1-2.8). Every other pre-check error stays 500.
|
|
var ae *ArrayExprError
|
|
if errors.As(err, &ae) {
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, ae.Msg, nil)
|
|
return
|
|
}
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// check rbac
|
|
queuePermission, err := GetRequireResourcePermission(submitUser, "queue:submit")
|
|
if err != nil {
|
|
logger.Error("Permission check failed for queue", zap.String("queue", req.QueueName), zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
|
|
if queuePermission.DomainType == models.DomainTypeQueue {
|
|
reqQueue := req.QueueName
|
|
queuePermValues := queuePermission.DomainValues
|
|
if !utils.ContainsField(queuePermValues, reqQueue) {
|
|
logger.Error("Permission check failed for queue", zap.String("queue", req.QueueName), zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Allocate the ID: an array (one-element arrays included, 1.2, 7.8)
|
|
// through the array allocator, which returns its first element (index
|
|
// 1); an ordinary job through the plain allocator.
|
|
var jobID uint64
|
|
if isArray {
|
|
jobID, err = generateArrayJobID(clusterID, apiserverID, arraySize)
|
|
} else {
|
|
jobID, err = generateJobID(clusterID, apiserverID, 1)
|
|
}
|
|
if err != nil {
|
|
logger.Error("Failed to generate job ID", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, "failed to generate job ID: "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// primaryJobID is the first element for an array, so the submit-time
|
|
// %J/%I substitution gives <array-id>[1] and 1 (9.4).
|
|
primaryJobID := int64(jobID)
|
|
arrayJobID := int64(backend.ExtractArrayBaseID(jobID))
|
|
if isArray {
|
|
logger.Info("Generated job ID", zap.String("array_jobid", backend.FormatJobID(arrayJobID)),
|
|
zap.Int("array_size", arraySize))
|
|
} else {
|
|
logger.Info("Generated job ID", zap.Int64("jobID", primaryJobID), zap.Int("arraySize", arraySize))
|
|
}
|
|
|
|
defaultValues := map[string]string{
|
|
"job_id": backend.FormatJobID(primaryJobID),
|
|
"user_name": submitUser,
|
|
"job_group": req.JobGroup,
|
|
"project_name": req.ProjectName,
|
|
}
|
|
|
|
logger.Debug("realCopyLocalFiles=", zap.Any("realCopyLocalFiles", realCopyLocalFiles))
|
|
logger.Debug("fakeCopyRemoteFile=", zap.Any("fakeCopyRemoteFile", fakeCopyRemoteFile))
|
|
|
|
err = processJobRequest(ctx, &req, defaultValues)
|
|
if err != nil {
|
|
logger.Error("Failed to submit job request", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// Build the single JobInfo to submit
|
|
job := models.JobInfo{
|
|
Unique_JobID: primaryJobID,
|
|
JobID: arrayJobID,
|
|
ArrayIdx: backend.ExtractArrayIdx(uint64(primaryJobID)),
|
|
//SubmitTime: time.Now().Unix(), // backend will fill the submit time;
|
|
SubmitUser: submitUser,
|
|
JobStatus: models.JOBSTAT["PEND"], // new job is pend.
|
|
JobName: req.JobName,
|
|
Command: req.Command,
|
|
QueueName: req.QueueName, //TODO: need set to DEFAULT_QUEUE
|
|
ProjectName: req.ProjectName, //TODO: need set "default" if not specify?
|
|
UserGroup: req.UserGroup,
|
|
LicenseProject: req.LicenseProject,
|
|
JobDescription: req.JobDescription,
|
|
ResReq: req.Resreq,
|
|
RsvID: req.RsvID,
|
|
SLA: req.SLA,
|
|
MaxNumProcessors: req.MaxNumProcessors,
|
|
NumProcessors: req.NumProcessors,
|
|
AskedHosts: req.AskedHosts,
|
|
LaunchType: req.LaunchType,
|
|
//BeginTime: req.BeginTime,
|
|
//TermTime: req.TermTime,
|
|
BeginTime: models.CustomTime{Time: req.BeginTime.Time},
|
|
TermTime: models.CustomTime{Time: req.TermTime.Time},
|
|
JobPriority: req.JobPriority,
|
|
UserPriority: req.JobPriority,
|
|
DependCond: req.DependCond,
|
|
CWD: req.CWD,
|
|
FromHost: req.FromHost, // bug-1676: client-asserted hostname, passed through unchanged
|
|
PreExecCmd: req.PreExecCmd,
|
|
PostExecCmd: req.PostExecCmd,
|
|
RequeueErrorValues: req.RequeueErrorValues,
|
|
RunTimeEstimation: req.RunTimeEstimation,
|
|
MigThreshold: req.MigThreshold,
|
|
ChkpntPeriod: req.ChkpntPeriod,
|
|
ChkpntDir: req.ChkpntDir,
|
|
OutDir: req.OutDir,
|
|
InFile: req.InFile, // transfer file into json with absolute path
|
|
ErrFile: req.ErrFile,
|
|
OutFile: req.OutFile,
|
|
LoginShell: req.LoginShell,
|
|
ExtendData: models.Jsonb{String: req.ExtendData},
|
|
}
|
|
|
|
err = postJobRequest(&job)
|
|
if err != nil {
|
|
logger.Error("Failed to handle postJobRequest ", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
logger.Debug("jobinfo before submit.", zap.Any("job", job))
|
|
|
|
// Submit job to backend (single call handles both normal and array)
|
|
err = submitJob(&job, arraySize)
|
|
if err != nil {
|
|
logger.Error("Failed to submit job.", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_WRITE_DATA_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
if isArray {
|
|
// Report the array identity and its size (3.1, 3.2).
|
|
logger.Info("Job submission successfully from ", zap.String("client_ip", ctx.ClientIP()),
|
|
zap.String("array_jobid", backend.FormatJobID(arrayJobID)), zap.Int("array_size", arraySize))
|
|
response := models.JobSubmitResponse{
|
|
Message: fmt.Sprintf("Job <%d> is submitted to queue <%s> (%d elements).",
|
|
arrayJobID, req.QueueName, arraySize),
|
|
JobID: arrayJobID,
|
|
QueueName: req.QueueName,
|
|
ArraySize: arraySize,
|
|
}
|
|
ReturnSuccess(ctx, common.CREATED, common.SUCCESS_MSG, response)
|
|
return
|
|
}
|
|
|
|
logger.Info("Job submission successfully from ", zap.String("client_ip", ctx.ClientIP()), zap.Int64("jobID", primaryJobID))
|
|
|
|
response := models.JobSubmitResponse{
|
|
Message: fmt.Sprintf("Job <%d> is submitted to default queue <%s>.", primaryJobID, req.QueueName),
|
|
JobID: primaryJobID,
|
|
QueueName: req.QueueName,
|
|
}
|
|
ReturnSuccess(ctx, common.CREATED, common.SUCCESS_MSG, response)
|
|
}
|
|
|
|
func preCheckJobRestartRequest(req *models.JobRestartRequest) error {
|
|
logger.Debug("Enter preCheckJobRestartRequest")
|
|
if err := validateAskedHosts(req.AskedHosts); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateResreqSyntax is the resource-request syntax check used by
|
|
// preCheckJobRequest; a variable so tests can substitute it (bug-1744).
|
|
var validateResreqSyntax = backend.ValidateResreqSyntax
|
|
|
|
// Backend seams used by CreateJob, ControlJobByID and the filter-based
|
|
// ControlJob; variables (like validateResreqSyntax) so the non-cgo handler
|
|
// tests (jobs_handler_test.go) can replace them and observe ID allocation,
|
|
// submission and control dispatch. Production code never reassigns them.
|
|
var (
|
|
generateJobID = backend.GenerateJobID
|
|
generateArrayJobID = backend.GenerateArrayJobID
|
|
submitJob = services.SubmitJob
|
|
controlJob = backend.ControlJob
|
|
)
|
|
|
|
// preCheckJobRequest validates and normalizes a submit request before any ID
|
|
// is allocated. It returns the number of jobs to create and whether they form
|
|
// an array: (1, false, nil) for an ordinary job; (n, true, nil) for a
|
|
// bracketed name accepted by ParseSubmitIndexExpr (req.JobName is then the
|
|
// name without its index part); (0, false, err) on rejection, where an
|
|
// index-expression error is an unwrapped *ArrayExprError (CreateJob maps it
|
|
// to 400).
|
|
func preCheckJobRequest(req *models.JobSubmitRequest) (n int, isArray bool, err error) {
|
|
logger.Debug("Enter preCheckJobRequest")
|
|
if req.QueueName == "" {
|
|
req.QueueName = "normal"
|
|
} else {
|
|
// Reject submissions to the reserved internal recovery queue.
|
|
if req.QueueName == "lost_and_found" {
|
|
logger.Error("Job submission to reserved queue rejected",
|
|
zap.String("queue_name", req.QueueName))
|
|
return 0, false, fmt.Errorf(
|
|
"cannot submit jobs to 'lost_and_found' — this is an internal recovery queue")
|
|
}
|
|
//validate queue_name whether exists
|
|
queueInfo, ok := store.Queues.Get(req.QueueName)
|
|
if !ok {
|
|
logger.Error("Invalid queue name specified", zap.String("queue_name", req.QueueName))
|
|
return 0, false, fmt.Errorf("invalid queue name: %s", req.QueueName)
|
|
}
|
|
queue_statusStr := formatQueueStatus(int64(queueInfo.QueueStatus))
|
|
if strings.HasPrefix(strings.ToLower(queue_statusStr), "closed") {
|
|
logger.Error("Queue is closed", zap.String("queue_name", req.QueueName))
|
|
return 0, false, fmt.Errorf("cannot submit job to queue %s: queue is closed", req.QueueName)
|
|
}
|
|
}
|
|
if req.JobName != "" {
|
|
// A name containing '[' or ']' is an array index expression; it is
|
|
// checked first so the interactive rule (2.1) is reached (2.9).
|
|
if strings.ContainsAny(req.JobName, "[]") {
|
|
arrayName, count, _, ae := ParseSubmitIndexExpr(req.JobName,
|
|
req.Interactive != 0, utils.JobYamlConfig.MaxArraySize)
|
|
if ae != nil {
|
|
logger.Error("job array index expression rejected",
|
|
zap.String("rule", ae.Rule), zap.String("reason", ae.Msg))
|
|
return 0, false, ae // unwrapped; never a typed-nil error
|
|
}
|
|
req.JobName = arrayName
|
|
logger.Debug("preCheckJobRequest parsed array job name",
|
|
zap.String("jobName", req.JobName), zap.Int("arraySize", count))
|
|
return count, true, nil
|
|
}
|
|
|
|
// Names without brackets keep the existing path (1.4).
|
|
arrayName, idx, slotLimit, err := createArrayName(req.JobName)
|
|
if err != nil {
|
|
logger.Error("job name format is invalid", zap.Error(err))
|
|
return 0, false, errors.New("job array list format is invalid: " + err.Error())
|
|
}
|
|
|
|
if arrayName != "" {
|
|
if err = isValidArrayName(arrayName); err != nil {
|
|
logger.Error(err.Error())
|
|
return 0, false, errors.New("job array list format is invalid: " + err.Error())
|
|
}
|
|
}
|
|
|
|
req.JobName = arrayName
|
|
logger.Debug("preCheckJobRequest parsed job name is: ", zap.String("jobName", req.JobName),
|
|
zap.Any("indices", idx), zap.Any("slotLimit", slotLimit))
|
|
return 1, false, nil
|
|
} else {
|
|
if req.Command != "" {
|
|
if len(req.Command) > 4094 {
|
|
req.JobName = utils.TruncateString(req.Command, 4094)
|
|
} else {
|
|
req.JobName = req.Command
|
|
}
|
|
}
|
|
}
|
|
|
|
if req.JobDescription != "" {
|
|
if len(req.JobDescription) > 4094 {
|
|
return 0, false, errors.New("job description length exceeds the limit of 4094 characters")
|
|
}
|
|
}
|
|
|
|
// Validate AskedHosts using the new function
|
|
if err := validateAskedHosts(req.AskedHosts); err != nil {
|
|
return 0, false, err
|
|
}
|
|
|
|
if req.ProjectName != "" {
|
|
if len(req.ProjectName) > 511 {
|
|
return 0, false, errors.New("project name length exceeds the limit of 511 characters")
|
|
}
|
|
}
|
|
|
|
if req.Resreq != "" {
|
|
if len(req.Resreq) > 4094 {
|
|
return 0, false, errors.New("resreq length exceeds the limit of 4094 characters")
|
|
}
|
|
// bug-1744: reject a malformed rusage[] clause now, instead of
|
|
// accepting the job and letting the scheduler silently drop it.
|
|
if err := validateResreqSyntax(req.Resreq); err != nil {
|
|
return 0, false, err
|
|
}
|
|
}
|
|
|
|
if req.ShellLimit.CPULimit != 0 {
|
|
req.CPULimit.Seconds = (req.ShellLimit.CPULimit) / 1000 // convert ms to seconds
|
|
}
|
|
|
|
if req.ShellLimit.FileLimit.MB != 0 {
|
|
req.FileLimit = req.ShellLimit.FileLimit
|
|
}
|
|
if req.ShellLimit.DataLimit.MB != 0 {
|
|
req.DataLimit = req.ShellLimit.DataLimit
|
|
}
|
|
if req.ShellLimit.StackLimit.MB != 0 {
|
|
req.StackLimit = req.ShellLimit.DataLimit
|
|
}
|
|
if req.ShellLimit.StackLimit.MB != 0 {
|
|
req.StackLimit = req.ShellLimit.StackLimit
|
|
}
|
|
if req.ShellLimit.CoreLimit.MB != 0 {
|
|
req.CoreLimit = req.ShellLimit.CoreLimit
|
|
}
|
|
if req.ShellLimit.ProcessLimit.MB != 0 {
|
|
req.ProcessLimit = req.ShellLimit.ProcessLimit
|
|
}
|
|
if req.ShellLimit.ThreadLimit.MB != 0 {
|
|
req.ThreadLimit = req.ShellLimit.ThreadLimit
|
|
}
|
|
if req.ShellLimit.MemLimit.MB != 0 {
|
|
req.MemLimit = req.ShellLimit.MemLimit
|
|
}
|
|
if req.ShellLimit.SwapLimit.MB != 0 {
|
|
req.SwapLimit = req.ShellLimit.SwapLimit
|
|
}
|
|
if req.ShellLimit.NumFiles != 0 {
|
|
req.NumFiles = req.ShellLimit.NumFiles
|
|
}
|
|
// handle environment variables
|
|
if req.Env != nil {
|
|
if len(req.Env) > 4094 {
|
|
return 0, false, errors.New("environment variable length exceeds the limit of 4094 characters")
|
|
}
|
|
for _, value := range req.Env {
|
|
parseEnvs := strings.SplitN(value, "=", 2)
|
|
if len(parseEnvs) != 2 {
|
|
return 0, false, errors.New("environment variable should use key=value format")
|
|
}
|
|
key := parseEnvs[0]
|
|
if strings.TrimSpace(key) == "" {
|
|
return 0, false, errors.New("environment variable key cannot be empty")
|
|
}
|
|
}
|
|
}
|
|
if req.TransferFiles != nil {
|
|
// handle transfer file and update InFile
|
|
realCopyLocalFiles = make([]string, 0)
|
|
fakeCopyRemoteFile = make([]string, 0)
|
|
var extendData []map[string]string
|
|
|
|
for _, transferFileStr := range req.TransferFiles {
|
|
localFile, operator, remoteFile, err := parseTransferFiles(transferFileStr)
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
|
|
/* based on different operator, check the local file and remote file
|
|
>: copy local to remote
|
|
<> and ><: copy local to remote
|
|
<: copy remote to local
|
|
<<: append remote to local
|
|
*/
|
|
if operator == ">" || operator == "<>" || operator == "><" {
|
|
if localFile == "" {
|
|
return 0, false, errors.New("local file is required for operator " + operator)
|
|
}
|
|
// Append to realCopyLocalFiles for later processing
|
|
realCopyLocalFiles = append(realCopyLocalFiles, localFile)
|
|
}
|
|
|
|
if operator == "<" || operator == "<<" {
|
|
if remoteFile == "" {
|
|
return 0, false, errors.New("remote file is required for operator " + operator)
|
|
}
|
|
fakeCopyRemoteFile = append(fakeCopyRemoteFile, remoteFile)
|
|
}
|
|
|
|
// Add to inFileData
|
|
extendData = append(extendData, map[string]string{
|
|
"local_file": localFile,
|
|
"op": operator,
|
|
"remote_file": remoteFile,
|
|
})
|
|
}
|
|
// Convert inFileData to JSON and assign to req.InFile
|
|
extendDataJSON, err := json.Marshal(extendData)
|
|
if err != nil {
|
|
return 0, false, fmt.Errorf("failed to marshal InFile JSON: %v", err)
|
|
}
|
|
req.ExtendData = string(extendDataJSON)
|
|
}
|
|
|
|
if req.NumProcessors != 0 && req.MaxNumProcessors != 0 {
|
|
minTasks := req.NumProcessors
|
|
maxTasks := req.MaxNumProcessors
|
|
|
|
minTasks, maxTasks, err := checkNumProcessors(minTasks, maxTasks)
|
|
if err != nil {
|
|
logger.Error("Failed to parse NumProcessors", zap.Any("numProcessors", req.NumProcessors), zap.Error(err))
|
|
return 0, false, fmt.Errorf("invalid NumProcessors specification: %w", err)
|
|
}
|
|
req.NumProcessors = minTasks
|
|
req.MaxNumProcessors = maxTasks
|
|
} else if req.NumProcessors != 0 {
|
|
req.MaxNumProcessors = req.NumProcessors
|
|
}
|
|
|
|
if req.MigThreshold < 0 || req.MigThreshold > 2147483647 {
|
|
return 0, false, errors.New("Incorrect migration threshold value. Specify a number greater than or equal to zero.")
|
|
}
|
|
|
|
if !utils.ContainsField(models.ValidInteractiveValues, strconv.Itoa(req.Interactive)) {
|
|
return 0, false, errors.New("Interactive field must be 0, 1, or 2")
|
|
}
|
|
|
|
if req.LaunchType != "" {
|
|
launchType := strings.ToLower(req.LaunchType)
|
|
if !utils.ContainsField(models.ValidLaunchTypes, launchType) {
|
|
return 0, false, errors.New("Launch_type must be openmpi or mpich")
|
|
}
|
|
req.LaunchType = launchType
|
|
}
|
|
|
|
return 1, false, nil
|
|
}
|
|
|
|
// It returns minTasks, maxTasks, and an error if parsing fails or constraints are violated.
|
|
func checkNumProcessors(minTasks, maxTasks int) (minValue, maxValue int, err error) {
|
|
logger.Debug("Enter checkNumProcessors", zap.Any("minTasks", minTasks), zap.Any("maxTasks", maxTasks))
|
|
if minTasks > maxTasks {
|
|
err = fmt.Errorf("minTasks (%d) cannot be greater than maxTasks (%d)", minTasks, maxTasks)
|
|
logger.Error("checkNumProcessors validation failed", zap.Error(err))
|
|
return 0, 0, err
|
|
}
|
|
|
|
return minTasks, maxTasks, nil
|
|
}
|
|
|
|
func parseArrayName(jobName string) (models.JobArrayInfo, error) {
|
|
logger.Debug("Enter parseArrayName", zap.String("jobName", jobName))
|
|
|
|
var jobArrayInfo models.JobArrayInfo
|
|
|
|
// Check if the jobName contains brackets
|
|
if !strings.Contains(jobName, "[") {
|
|
// If no brackets, return the job name as is
|
|
jobArrayInfo.KeyID = jobName
|
|
return jobArrayInfo, nil
|
|
}
|
|
|
|
openBracketIndex := strings.Index(jobName, "[")
|
|
closeBracketIndex := strings.Index(jobName, "]")
|
|
|
|
jobArrayInfo.KeyID = jobName[:openBracketIndex]
|
|
if openBracketIndex != -1 && closeBracketIndex != -1 {
|
|
if closeBracketIndex <= openBracketIndex {
|
|
return models.JobArrayInfo{}, fmt.Errorf("invalid [] format")
|
|
}
|
|
indicesPart := jobName[openBracketIndex+1 : len(jobName)-1] // Remove the closing bracket
|
|
|
|
// Parse the indices part
|
|
rangeParts := strings.Split(indicesPart, ",")
|
|
for _, part := range rangeParts {
|
|
part = strings.TrimSpace(part)
|
|
|
|
if strings.Contains(part, "-") {
|
|
// Handle ranges like 1-3
|
|
bounds := strings.Split(part, "-")
|
|
if len(bounds) != 2 {
|
|
return models.JobArrayInfo{}, fmt.Errorf("invalid range format: %s", part)
|
|
}
|
|
|
|
step := 1 // Default step for ranges
|
|
parts := strings.Split(part, ":")
|
|
|
|
if len(parts) == 2 {
|
|
var err error
|
|
step, err = strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return models.JobArrayInfo{}, fmt.Errorf("invalid step value: %v", err)
|
|
}
|
|
if step != 1 {
|
|
return models.JobArrayInfo{}, fmt.Errorf("invalid step value and only support 1 now")
|
|
}
|
|
}
|
|
|
|
part = parts[0]
|
|
jobArrayInfo.Indices = append(jobArrayInfo.Indices, models.JobIndex{Index: part, Step: step})
|
|
} else {
|
|
// Handle single indices
|
|
step := 1 // Default step for single indices
|
|
jobArrayInfo.Indices = append(jobArrayInfo.Indices, models.JobIndex{Index: part, Step: step})
|
|
}
|
|
}
|
|
} else {
|
|
return models.JobArrayInfo{}, fmt.Errorf("no valid indices found in array job")
|
|
}
|
|
return jobArrayInfo, nil
|
|
}
|
|
|
|
// based on jobName to generate arrayName and individual indices
|
|
func createArrayName(jobName string) (arrayName string, indices []int, slimit int, err error) {
|
|
logger.Debug("Enter createArrayName", zap.Any("jobName=", jobName))
|
|
|
|
// find the index of the first '['
|
|
slotLimit := ""
|
|
slotLimitValue := -1
|
|
openBracketIndex := strings.Index(jobName, "[")
|
|
closeBracketIndex := strings.Index(jobName, "]")
|
|
stepIndex := strings.Index(jobName, ":")
|
|
slotLimitIndex := strings.Index(jobName, "%")
|
|
|
|
logger.Debug("Debugging index values",
|
|
zap.Any("openBracketIndex", openBracketIndex),
|
|
zap.Any("closeBracketIndex", closeBracketIndex),
|
|
zap.Any("stepIndex", stepIndex),
|
|
zap.Any("slotLimitIndex", slotLimitIndex))
|
|
|
|
if slotLimitIndex != -1 {
|
|
if slotLimitIndex+1 != len(jobName) {
|
|
slotLimit = jobName[slotLimitIndex+1 : len(jobName)]
|
|
} else {
|
|
return "", nil, -1, fmt.Errorf("invalid slot limit value '%s': must be a positive integer", slotLimit)
|
|
}
|
|
|
|
logger.Debug("slotLimit=", zap.Any("slotLimit", slotLimit))
|
|
slotLimitValue, err = strconv.Atoi(strings.TrimSpace(slotLimit))
|
|
if err != nil || slotLimitValue < 1 {
|
|
return "", nil, -1, fmt.Errorf("invalid slot limit value '%s': must be a positive integer", slotLimit)
|
|
}
|
|
}
|
|
|
|
if openBracketIndex != -1 && closeBracketIndex != -1 {
|
|
if closeBracketIndex <= openBracketIndex {
|
|
return "", nil, -1, fmt.Errorf("invalidate job_name format")
|
|
}
|
|
if stepIndex != -1 && (stepIndex == closeBracketIndex-1 || stepIndex > closeBracketIndex) {
|
|
return "", nil, -1, fmt.Errorf("invalidate step value")
|
|
}
|
|
// extract array_name
|
|
arrayName = jobName[:openBracketIndex]
|
|
// extract the part inside the brackets
|
|
rangePart := jobName[openBracketIndex+1 : closeBracketIndex]
|
|
// split the range parts by comma
|
|
rangeParts := strings.Split(rangePart, ",")
|
|
seenIndices := make(map[int]struct{}) // detect the duplicate element
|
|
for _, part := range rangeParts {
|
|
if part == "" {
|
|
return "", nil, -1, fmt.Errorf("do not use empty range part: %s", part)
|
|
}
|
|
part = strings.TrimSpace(part) // remove spaces
|
|
|
|
// Remove %slotLimitValue suffix if present
|
|
if slotLimitValue != -1 {
|
|
suffixToRemove := fmt.Sprintf("%%%d", slotLimitValue)
|
|
part = strings.TrimSuffix(part, suffixToRemove)
|
|
}
|
|
|
|
if strings.Contains(part, "-") {
|
|
// split by ':' to get the range and optional step
|
|
parts := strings.Split(part, ":")
|
|
if len(parts) < 1 || len(parts) > 2 {
|
|
return "", nil, -1, fmt.Errorf("invalid range format: %s", part)
|
|
}
|
|
|
|
// parse the range bounds
|
|
rangeBounds := strings.Split(parts[0], "-")
|
|
if len(rangeBounds) != 2 {
|
|
return "", nil, -1, fmt.Errorf("invalid range bounds: %s", part)
|
|
}
|
|
|
|
start, err := strconv.Atoi(strings.TrimSpace(rangeBounds[0]))
|
|
if err != nil || start < 1 {
|
|
return "", nil, -1, fmt.Errorf("invalid start value: %v, must be >= 1", err)
|
|
}
|
|
logger.Debug("start is:", zap.Any("start", start))
|
|
|
|
end, err := strconv.Atoi(strings.TrimSpace(rangeBounds[1]))
|
|
if err != nil || end > utils.JobYamlConfig.MaxArraySize {
|
|
return "", nil, -1, fmt.Errorf("invalid end value: %v, must be <= %d", err, utils.JobYamlConfig.MaxArraySize)
|
|
}
|
|
logger.Debug("end is:", zap.Any("end", end))
|
|
// set default step
|
|
step := 1
|
|
if len(parts) == 2 {
|
|
if stepIndex != -1 {
|
|
step, err = strconv.Atoi(jobName[stepIndex+1 : closeBracketIndex])
|
|
if err != nil {
|
|
return "", nil, -1, fmt.Errorf("invalid step value: %v", err)
|
|
}
|
|
}
|
|
}
|
|
logger.Debug("step value is", zap.Any("step", step))
|
|
if start >= end {
|
|
return "", nil, -1, fmt.Errorf("start value (%d) cannot be greater than or equal to the end value (%d)", start, end)
|
|
}
|
|
|
|
if step > end || step == 0 {
|
|
return "", nil, -1, fmt.Errorf("step value (%d) cannot be greater than the end value (%d) and greater than 0", step, end)
|
|
}
|
|
|
|
// count the indices for this range
|
|
for i := start; i <= end; i += step {
|
|
// if there is duplicated index and reject
|
|
if _, exists := seenIndices[i]; exists {
|
|
return "", nil, -1, fmt.Errorf("duplicate index detected: %d", i)
|
|
}
|
|
seenIndices[i] = struct{}{}
|
|
indices = append(indices, i)
|
|
}
|
|
} else {
|
|
// Handle single indices like 1, 2, 3
|
|
if part != "" {
|
|
index, err := strconv.Atoi(part)
|
|
if err != nil || index < 1 {
|
|
return "", nil, -1, fmt.Errorf("index must be a positive integer")
|
|
}
|
|
if err != nil {
|
|
return "", nil, -1, fmt.Errorf("invalid index value: %s", part)
|
|
}
|
|
if _, exists := seenIndices[index]; !exists {
|
|
seenIndices[index] = struct{}{}
|
|
indices = append(indices, index)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
} else if openBracketIndex != -1 || closeBracketIndex != -1 {
|
|
return "", nil, -1, fmt.Errorf("invalid array job name '%s'", jobName)
|
|
} else {
|
|
// no "[]"
|
|
/*
|
|
if slotLimitIndex != -1 {
|
|
arrayName = jobName[:slotLimitIndex]
|
|
} else {
|
|
arrayName = jobName
|
|
}*/
|
|
arrayName = jobName
|
|
}
|
|
|
|
if slotLimit != "" && len(indices) > 0 {
|
|
maxIndex := 0
|
|
for i, index := range indices {
|
|
if i == 0 || index > maxIndex {
|
|
maxIndex = index
|
|
}
|
|
}
|
|
if slotLimitValue > maxIndex {
|
|
return "", nil, -1, fmt.Errorf("slotLimit (%d) cannot be greater than the maximum index (%d)", slotLimitValue, maxIndex)
|
|
}
|
|
}
|
|
logger.Debug("Finish createArrayName function", zap.Any("arrayName", arrayName),
|
|
zap.Any("indices", indices), zap.Any("slotLimitValue", slotLimitValue))
|
|
return arrayName, indices, slotLimitValue, nil
|
|
}
|
|
|
|
func isValidArrayName(arrayName string) error {
|
|
// define the validate chars
|
|
if len(arrayName) > 4094 {
|
|
return fmt.Errorf("job name length exceeds the limit of 4094 characters")
|
|
}
|
|
validArrayName := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
|
if !validArrayName.MatchString(arrayName) {
|
|
return fmt.Errorf("arrayName '%s' contains invalid characters. Allowed characters are [a-zA-Z0-9._-]", arrayName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isValidJobId(jobID string) error {
|
|
matched, _ := regexp.MatchString(`^\d+$`, jobID)
|
|
matchedIndex, _ := regexp.MatchString(`^\d+\[\d+\]$`, jobID)
|
|
if !matched && !matchedIndex {
|
|
return fmt.Errorf("jobid '%s' contains invalid characters. Allowed characters are 0-9 or with index value", jobID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateAskedHosts(askedHosts string) error {
|
|
if askedHosts == "" {
|
|
return nil // Nothing to validate
|
|
}
|
|
|
|
// Split the AskedHosts string by spaces
|
|
hostList := strings.Fields(askedHosts)
|
|
if len(hostList) == 0 {
|
|
// Handle case where AskedHosts might be just whitespace
|
|
logger.Warn("AskedHosts field contains only whitespace.", zap.String("AskedHosts", askedHosts))
|
|
return nil
|
|
}
|
|
|
|
/* Validate each hostname against the cache
|
|
for _, host := range hostList {
|
|
if _, ok := store.Hosts.Get(host); !ok {
|
|
err := fmt.Errorf("invalid hostname specified in AskedHosts: '%s'", host)
|
|
logger.Error(err.Error())
|
|
return err // Return the specific error
|
|
}
|
|
}*/
|
|
logger.Debug("AskedHosts validated successfully.", zap.Strings("hosts", hostList))
|
|
return nil // All hosts are valid
|
|
}
|
|
|
|
// handle TransferFiles field
|
|
func parseTransferFiles(transferFileStr string) (localFile, operator, remoteFile string, err error) {
|
|
// remove space char
|
|
remoteFile = ""
|
|
transferFileStr = strings.TrimSpace(transferFileStr)
|
|
parts := strings.Fields(transferFileStr)
|
|
|
|
if len(parts) < 2 {
|
|
return "", "", "", errors.New("TransferFiles format is invalid, expected format: local_file operator [remote_file]")
|
|
}
|
|
localFile = parts[0]
|
|
operator = parts[1]
|
|
|
|
if parts[0] == "<" || parts[0] == "<<" {
|
|
operator = parts[0]
|
|
pathParts := strings.Split(parts[1], "/")
|
|
localFile = pathParts[len(pathParts)-1]
|
|
remoteFile = parts[1]
|
|
}
|
|
if len(parts) > 2 {
|
|
remoteFile = parts[2]
|
|
}
|
|
// validate the operator
|
|
validOperators := []string{">", "<", "<<", "<>", "><"}
|
|
if !utils.ContainsField(validOperators, operator) {
|
|
return "", "", "", fmt.Errorf("invalid operator: %s, supported operators are: %v", operator, validOperators)
|
|
}
|
|
|
|
return localFile, operator, remoteFile, nil
|
|
}
|
|
|
|
func processJobRestartRequest(newJob *models.JobInfo, req *models.JobRestartRequest) error {
|
|
logger.Debug("Enter processJobRestartRequest function", zap.Any("req", req))
|
|
if req.AskedHosts != "" {
|
|
newJob.AskedHosts = req.AskedHosts
|
|
}
|
|
return nil
|
|
}
|
|
func processJobRequest(ctx *gin.Context, req *models.JobSubmitRequest, defaultValues map[string]string) error {
|
|
|
|
if req.InFile != "" {
|
|
req.InFile = strings.ReplaceAll(req.InFile, "%J", defaultValues["job_id"])
|
|
}
|
|
|
|
if req.OutFile != "" {
|
|
req.OutFile = strings.ReplaceAll(req.OutFile, "%J", defaultValues["job_id"])
|
|
}
|
|
|
|
if req.ErrFile != "" {
|
|
req.ErrFile = strings.ReplaceAll(req.ErrFile, "%J", defaultValues["job_id"])
|
|
}
|
|
|
|
if req.OutDir != "" {
|
|
if defaultValues["job_group"] != "" {
|
|
req.OutDir = strings.ReplaceAll(req.OutDir, "%JG", defaultValues["job_group"])
|
|
}
|
|
req.OutDir = strings.ReplaceAll(req.OutDir, "%J", defaultValues["job_id"])
|
|
req.OutDir = strings.ReplaceAll(req.OutDir, "%U", defaultValues["user_name"])
|
|
req.OutDir = strings.ReplaceAll(req.OutDir, "%P", defaultValues["project_name"])
|
|
}
|
|
|
|
if req.CWD != "" {
|
|
req.CWD = strings.ReplaceAll(req.CWD, "%EJ", defaultValues["job_id"])
|
|
req.CWD = strings.ReplaceAll(req.CWD, "%J", defaultValues["job_id"])
|
|
if defaultValues["job_group"] != "" {
|
|
req.CWD = strings.ReplaceAll(req.CWD, "%JG", defaultValues["job_group"])
|
|
}
|
|
req.CWD = strings.ReplaceAll(req.CWD, "%P", defaultValues["project_name"])
|
|
req.CWD = strings.ReplaceAll(req.CWD, "%U", defaultValues["user_name"])
|
|
}
|
|
// initialize extendDataMap
|
|
extendDataMap := make(map[string]interface{})
|
|
if req.ExtendData != "" {
|
|
_ = json.Unmarshal([]byte(req.ExtendData), &extendDataMap)
|
|
}
|
|
|
|
if req.TransferFiles != nil {
|
|
// Copy local files and check for errors
|
|
result, err := copyLocalFilesConcurrently(ctx, realCopyLocalFiles, defaultValues["user_name"])
|
|
if err != nil {
|
|
return fmt.Errorf("failed to process local files: %v", err)
|
|
}
|
|
|
|
// prepare
|
|
// Update ExtendData with copied URI
|
|
|
|
var extendData []map[string]interface{}
|
|
if err := json.Unmarshal([]byte(req.ExtendData), &extendData); err != nil {
|
|
return fmt.Errorf("failed to parse ExtendData JSON: %v", err)
|
|
}
|
|
if len(result) != 0 {
|
|
for i, entry := range extendData {
|
|
if localFile, ok := entry["local_file"].(string); ok {
|
|
if copiedURI, exists := result[localFile]; exists {
|
|
entry["local_file"] = copiedURI
|
|
}
|
|
}
|
|
extendData[i] = entry
|
|
}
|
|
}
|
|
logger.Debug("In processJobRequest function, after CopyLocalFilesConcurrently, result is:",
|
|
zap.Any("result", result))
|
|
// copy remote files locally and append to result
|
|
remoteResult := copyRemoteFilesLocally(ctx, fakeCopyRemoteFile, defaultValues["user_name"])
|
|
for remoteFile, copiedURI := range remoteResult {
|
|
result[remoteFile] = copiedURI
|
|
}
|
|
if len(remoteResult) != 0 {
|
|
for i, entry := range extendData {
|
|
if localFile, ok := entry["remote_file"].(string); ok {
|
|
if copiedURI, exists := result[localFile]; exists {
|
|
entry["remote_file"] = copiedURI
|
|
}
|
|
}
|
|
extendData[i] = entry
|
|
}
|
|
}
|
|
extendDataMap["transfer_files"] = extendData
|
|
}
|
|
|
|
// handle env into json format
|
|
if req.Env != nil {
|
|
envJsonb, err := utils.ConvertStringToJsonb(req.Env)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to convert env to JSON: %v", err)
|
|
}
|
|
|
|
var envData interface{}
|
|
err = json.Unmarshal([]byte(envJsonb.String), &envData)
|
|
if err != nil {
|
|
logger.Error("Failed to unmarshal envJsonb string", zap.Error(err), zap.String("envJsonb", envJsonb.String))
|
|
return fmt.Errorf("failed to process environment variables: %v", err)
|
|
}
|
|
extendDataMap["sub_env_vars"] = envData
|
|
}
|
|
|
|
// handle limits
|
|
limits := make(map[string]interface{})
|
|
if req.CPULimit.Seconds != 0 {
|
|
limits["cpu_limit"] = req.CPULimit.Seconds
|
|
}
|
|
if req.FileLimit.MB != 0 {
|
|
limits["fsize_limit"] = req.FileLimit.MB
|
|
}
|
|
if req.DataLimit.MB != 0 {
|
|
limits["data_limit"] = req.DataLimit.MB
|
|
}
|
|
if req.StackLimit.MB != 0 {
|
|
limits["stack_limit"] = req.StackLimit.MB
|
|
}
|
|
if req.CoreLimit.MB != 0 {
|
|
limits["core_limit"] = req.CoreLimit.MB
|
|
}
|
|
if req.ProcessLimit.MB != 0 {
|
|
limits["process_limit"] = req.ProcessLimit.MB
|
|
}
|
|
if req.ThreadLimit.MB != 0 {
|
|
limits["thread_limit"] = req.ThreadLimit.MB
|
|
}
|
|
if req.MemLimit.MB != 0 {
|
|
limits["rss_limit"] = req.MemLimit.MB
|
|
}
|
|
if req.SwapLimit.MB != 0 {
|
|
limits["swap_limit"] = req.SwapLimit.MB
|
|
}
|
|
if req.NumFiles != 0 {
|
|
limits["nofile_limit"] = req.NumFiles
|
|
}
|
|
if req.RunLimit.Seconds != 0 {
|
|
limits["run_limit"] = req.RunLimit.Seconds
|
|
}
|
|
if len(limits) > 0 {
|
|
extendDataMap["limits"] = limits
|
|
}
|
|
|
|
if req.Interactive != 0 {
|
|
extendDataMap["interactive"] = req.Interactive
|
|
}
|
|
|
|
if len(extendDataMap) > 0 {
|
|
updatedExtendDataBytes, err := json.Marshal(extendDataMap)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to marshal updated ExtendData: %v", err)
|
|
}
|
|
req.ExtendData = string(updatedExtendDataBytes)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func postJobRequest(job *models.JobInfo) error {
|
|
if job.InFile != "" {
|
|
job.InFile = strings.ReplaceAll(job.InFile, "%I", strconv.Itoa(job.ArrayIdx))
|
|
}
|
|
if job.OutFile != "" {
|
|
job.OutFile = strings.ReplaceAll(job.OutFile, "%I", strconv.Itoa(job.ArrayIdx))
|
|
}
|
|
|
|
if job.ErrFile != "" {
|
|
job.ErrFile = strings.ReplaceAll(job.ErrFile, "%I", strconv.Itoa(job.ArrayIdx))
|
|
}
|
|
|
|
if job.OutDir != "" {
|
|
job.OutDir = strings.ReplaceAll(job.OutDir, "%I", strconv.Itoa(job.ArrayIdx))
|
|
}
|
|
|
|
if job.CWD != "" {
|
|
job.CWD = strings.ReplaceAll(job.CWD, "%I", strconv.Itoa(job.ArrayIdx))
|
|
}
|
|
if len(job.InFile) > 4094 || len(job.OutFile) > 4094 || len(job.ErrFile) > 4094 || len(job.CWD) > 4094 {
|
|
return errors.New("file length exceeds the limit of 4094 characters")
|
|
}
|
|
if len(job.PreExecCmd) > 4094 || len(job.PostExecCmd) > 4094 {
|
|
return errors.New("command path exceeds the limit of 4094 characters")
|
|
}
|
|
if len(job.ChkpntDir) > 4000 {
|
|
return errors.New("checkpoint directory length exceeds the limit of 4000 characters")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ControlJobByID godoc
|
|
// @Summary Perform an action on a job
|
|
// @Description Submit an action to be performed on a specific job identified by jobids
|
|
// @Tags jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param jobids path string true "Job ID"
|
|
// @Param action body JobSignalRequest true "Action to be performed"
|
|
// @Success 202 {object} JobControlResponseSummary "Action successfully accepted"
|
|
// @Failure 400 {object} ErrorResponse "Invalid request body or parameters"
|
|
// @Failure 404 {object} ErrorResponse "Job not found"
|
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
|
// @Router /jobs/{jobids}/control [post]
|
|
func (j JobController) ControlJobByID(ctx *gin.Context) {
|
|
logger.Debug("Enter ControlJobByID() function from ", zap.String("client_ip", ctx.ClientIP()))
|
|
|
|
// get submit user from token
|
|
token := ctx.GetHeader("Authorization")
|
|
submitter, err := GetUsernameFromToken(token)
|
|
if err != nil {
|
|
logger.Error("Get username from token error", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// Get job IDs from URL parameter
|
|
jobIDsStr := ctx.Param("jobids")
|
|
|
|
var req models.JobControlJobIDRequest
|
|
if err := utils.BindJSONStrict(ctx, &req); err != nil {
|
|
logger.Error("Invalid request parameters", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
// Validate command
|
|
convertedReq := models.JobControlRequest{
|
|
Action: req.Action,
|
|
Signal: req.Signal,
|
|
Comment: req.Comment,
|
|
ForceKill: req.ForceKill,
|
|
Method: req.Method,
|
|
}
|
|
|
|
err = preCheckJobControlRequest(&convertedReq)
|
|
if err != nil {
|
|
logger.Error("Failed to send job control request", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
signalval := convertedReq.Signal
|
|
|
|
logger.Debug("Receive job control request:", zap.String("client_ip", ctx.ClientIP()),
|
|
zap.String("jobIds", jobIDsStr), zap.Any("control request:", req))
|
|
|
|
//filters := make([]utils.FilterCondition, 0)
|
|
|
|
fields := models.JOB_DEFAULT_COLUMN
|
|
for i, field := range fields {
|
|
fields[i] = utils.GetMappedField(field)
|
|
}
|
|
|
|
// add a default order: sort=job_id:asc
|
|
sortOrders := make([]utils.SortOrder, 0)
|
|
sortOrders = append(sortOrders, utils.SortOrder{
|
|
Column: "unique_job_id", // use unique_job_id as primary key
|
|
Ascending: true,
|
|
})
|
|
|
|
var acttype int
|
|
switch convertedReq.Action {
|
|
case "kill":
|
|
acttype = models.JOB_KILL
|
|
case "stop":
|
|
acttype = models.JOB_STOP
|
|
case "resume":
|
|
acttype = models.JOB_RESUME
|
|
case "checkpoint":
|
|
acttype = models.JOB_CHKPNT
|
|
default:
|
|
logger.Debug("Unsupported action: " + req.Action)
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, "Unsupported action: "+req.Action, nil)
|
|
return
|
|
}
|
|
|
|
//1. parse and validate every selection before any store query or
|
|
// dispatch (validate-all-first): the first rejected selection, in request
|
|
// order, rejects the whole command, which then acts on no job and reports
|
|
// no "not found" item.
|
|
jobids := extractJobNames(jobIDsStr)
|
|
for i := range jobids {
|
|
jobids[i] = strings.TrimSpace(jobids[i])
|
|
}
|
|
logger.Debug("The input jobids", zap.Any("jobids", jobids))
|
|
|
|
sels := make([]Selection, 0, len(jobids))
|
|
for _, jobid := range jobids {
|
|
sel, se := ParseIdentitySelection(jobid)
|
|
if se != nil {
|
|
returnSelectionError(ctx, jobid, se)
|
|
return
|
|
}
|
|
sels = append(sels, sel)
|
|
}
|
|
|
|
//2. match every selection against the store. The loop only collects:
|
|
// (a) the jobs to act on, de-duplicated by unique_job_id, in selection
|
|
// order, and (b) one "not found" item per missing number per selection
|
|
// (not de-duplicated). Nothing is validated or dispatched inside it
|
|
// (bug-1899: dispatching there re-sent earlier selections' jobs for every
|
|
// later selection).
|
|
actJobs := make([]utils.OrderMap, 0)
|
|
seenJobs := make(map[int64]bool)
|
|
notFoundJobs := []map[string]interface{}{}
|
|
|
|
for _, sel := range sels {
|
|
jobid := int64(sel.Key)
|
|
rows, ok := queryControlRowsByJobID(ctx, sel, fields, sortOrders)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
foundJobs := make([]utils.OrderMap, 0)
|
|
if sel.Form == SelBare {
|
|
// no element part: every element of the array and any ordinary
|
|
// job with that identity (5.6)
|
|
fjobs, err := foundJob(jobid, 0, false, rows)
|
|
if err != nil {
|
|
notFoundJobs = append(notFoundJobs, map[string]interface{}{
|
|
"unique_job_id": jobid,
|
|
"job_id": jobid,
|
|
"array_idx": 0,
|
|
"msg": "job not found",
|
|
})
|
|
continue
|
|
}
|
|
foundJobs = append(foundJobs, fjobs...)
|
|
} else {
|
|
// explicit element numbers: only array elements with that
|
|
// number, never the whole array or an ordinary job (5.2, 5.4,
|
|
// 8.2); each missing number is reported with its would-be
|
|
// element ID (5.3, 5.4)
|
|
for k := sel.Lo; k <= sel.Hi; k++ {
|
|
fjobs, err := foundJob(jobid, k, true, rows)
|
|
if err != nil {
|
|
notFoundJobs = append(notFoundJobs, map[string]interface{}{
|
|
"unique_job_id": int64(backend.MakeElementID(sel.Key, k)),
|
|
"job_id": jobid,
|
|
"array_idx": k,
|
|
"msg": "job not found",
|
|
})
|
|
continue
|
|
}
|
|
foundJobs = append(foundJobs, fjobs...)
|
|
}
|
|
}
|
|
|
|
for _, jobInfo := range foundJobs {
|
|
uid, _ := jobInfo.Data["unique_job_id"].(int64)
|
|
if seenJobs[uid] {
|
|
continue
|
|
}
|
|
seenJobs[uid] = true
|
|
|
|
// check rbac
|
|
if job_owner, exist := jobInfo.Data["submit_user"]; exist {
|
|
jobPermission, err := GetRequireResourcePermission(submitter, "job:control")
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
if jobPermission.DomainType == models.DomainTypeSelf {
|
|
if job_owner.(string) != submitter {
|
|
logger.Error("Permission denied", zap.String("perm", "job:control"), zap.String("logonUser", submitter))
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if job_queue, exist := jobInfo.Data["queue_name"]; exist {
|
|
queuePermission, err := GetRequireResourcePermission(submitter, "queue:control")
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
var accessQueues []string
|
|
if queuePermission.DomainType == models.DomainTypeAll {
|
|
accessQueues = store.GetAllQueueNames()
|
|
}
|
|
if queuePermission.DomainType == models.DomainTypeQueue {
|
|
accessQueues = queuePermission.DomainValues
|
|
}
|
|
// check job:control permission
|
|
if len(accessQueues) == 0 || !utils.ContainsField(accessQueues, job_queue.(string)) {
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
}
|
|
actJobs = append(actJobs, jobInfo)
|
|
}
|
|
}
|
|
logger.Debug("jobs to act on:", zap.Any("jobs", actJobs))
|
|
|
|
//3. validate each job once against the action, then append the not-found
|
|
// items; every result item carries unique_job_id (5.1, 8.3).
|
|
successJobs := []map[string]interface{}{}
|
|
failedJobs := []map[string]interface{}{}
|
|
for _, jobInfo := range actJobs {
|
|
validatedSuccessJobs, validatedFailedJobs := validateJobControl(jobInfo, convertedReq.Action, convertedReq.Signal)
|
|
|
|
for _, successJob := range validatedSuccessJobs {
|
|
successJobs = append(successJobs, map[string]interface{}{
|
|
"unique_job_id": successJob["unique_job_id"].(int64),
|
|
"job_id": successJob["job_id"].(int64),
|
|
"array_idx": successJob["array_idx"].(int),
|
|
"msg": successJob["msg"].(string),
|
|
})
|
|
}
|
|
|
|
for _, failedJob := range validatedFailedJobs {
|
|
failedJobs = append(failedJobs, map[string]interface{}{
|
|
"unique_job_id": failedJob["unique_job_id"].(int64),
|
|
"job_id": failedJob["job_id"].(int64),
|
|
"array_idx": failedJob["array_idx"].(int),
|
|
"msg": failedJob["msg"].(string),
|
|
})
|
|
}
|
|
}
|
|
failedJobs = append(failedJobs, notFoundJobs...)
|
|
|
|
//4. dispatch each accepted job once; a dispatch error is a 500.
|
|
signalInt, _ := strconv.Atoi(signalval)
|
|
for _, successJob := range successJobs {
|
|
jobCtrlEvt := models.JobControlEvent{
|
|
UniqueJobID: successJob["unique_job_id"].(int64),
|
|
Signal: signalInt,
|
|
Comment: convertedReq.Comment,
|
|
ForceKill: convertedReq.ForceKill,
|
|
Method: convertedReq.Method,
|
|
}
|
|
|
|
jobCtrlEvtBytes, err := json.Marshal(jobCtrlEvt)
|
|
if err != nil {
|
|
logger.Error("encode job control event error", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, "encode job control event error"+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
err = controlJob(uint64(successJob["unique_job_id"].(int64)), acttype, string(jobCtrlEvtBytes))
|
|
if err != nil {
|
|
logger.Error("send job control to backend error", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_WRITE_DATA_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
// response to user
|
|
//logger.Debug("job ctrl accept resps:", zap.Any("jobCtrlAcceptResps", jobCtrlAcceptResps))
|
|
//ReturnSuccess(ctx, common.ACCEPTED, "Job control accepted.", jobCtrlAcceptResps)
|
|
response, successMsg := BuildBatchOperationResponse(convertedReq.Action, "Job", successJobs, failedJobs)
|
|
|
|
overallStatus := response["status"].(string)
|
|
logger.Info("Finished processing job control request",
|
|
zap.String("action", convertedReq.Action),
|
|
zap.String("overallStatus", overallStatus),
|
|
zap.Int("succeededCount", len(successJobs)),
|
|
zap.Int("failedCount", len(failedJobs)),
|
|
zap.String("client_ip", ctx.ClientIP()))
|
|
|
|
ReturnSuccess(ctx, common.ACCEPTED, successMsg, response)
|
|
return
|
|
|
|
}
|
|
|
|
// ControlJob godoc
|
|
// @Summary Perform an action on filtered out jobs
|
|
// @Description Submit an action to be performed on specific jobs identified by filter in request body.
|
|
// @Tags jobs
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param jobids path string true "Job ID"
|
|
// @Param action body JobControlRequest true "Action to be performed"
|
|
// @Success 202 {object} JobControlResponseSummary "Action successfully accepted"
|
|
// @Failure 400 {object} ErrorResponse "Invalid request body or parameters"
|
|
// @Failure 404 {object} ErrorResponse "Job not found"
|
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
|
// @Router /jobs/control [post]
|
|
func (j JobController) ControlJob(ctx *gin.Context) {
|
|
logger.Debug("Enter ControlJob() function from ", zap.String("client_ip", ctx.ClientIP()))
|
|
|
|
// Get filter in request body
|
|
var req models.JobControlRequest
|
|
|
|
// get submit user from token
|
|
token := ctx.GetHeader("Authorization")
|
|
submitter, err := GetUsernameFromToken(token)
|
|
if err != nil {
|
|
logger.Error("Get username from token error", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
if err := utils.BindJSONStrict(ctx, &req); err != nil {
|
|
logger.Error("Invalid request parameters", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
err = preCheckJobControlRequest(&req)
|
|
if err != nil {
|
|
logger.Error("Failed to send job control request", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
|
return
|
|
}
|
|
signalval := req.Signal
|
|
logger.Debug("Receive job control request:", zap.Any("control request:", req), zap.String("client_ip", ctx.ClientIP()))
|
|
|
|
filters := make([]utils.FilterCondition, 0)
|
|
|
|
fields := models.JOB_DEFAULT_COLUMN
|
|
for i, field := range fields {
|
|
fields[i] = utils.GetMappedField(field)
|
|
}
|
|
|
|
//
|
|
// TODO: need consider if we need put all job control action together.
|
|
//
|
|
var acttype int
|
|
switch req.Action {
|
|
case "kill":
|
|
acttype = models.JOB_KILL
|
|
case "stop":
|
|
acttype = models.JOB_STOP
|
|
case "resume":
|
|
acttype = models.JOB_RESUME
|
|
case "checkpoint":
|
|
acttype = models.JOB_CHKPNT
|
|
/* --------
|
|
case "requeue":
|
|
acttype = models.JOB_REQUEUE
|
|
case "mig":
|
|
acttype = models.JOB_MIG
|
|
case "modify":
|
|
acttype = models.JOB_MODIFY
|
|
case "switch":
|
|
acttype = models.JOB_SWITCH
|
|
case "post":
|
|
acttype = models.JOB_POST
|
|
case "read":
|
|
acttype = models.JOB_READ
|
|
case "top":
|
|
acttype = models.JOB_TOP
|
|
case "bot":
|
|
acttype = models.JOB_BOT
|
|
*/
|
|
default:
|
|
logger.Debug("Unsupported action: " + req.Action)
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, "Unsupported action: "+req.Action, nil)
|
|
return
|
|
}
|
|
|
|
//query job according query filter
|
|
logger.Debug("request filters user input: ", zap.Any("filters", req.Filter))
|
|
|
|
// TODO: validate the req.Filter
|
|
filters = getFilters(req, submitter)
|
|
logger.Debug("final request filters: ", zap.Any("filters", filters))
|
|
|
|
// A job name with '[' or ']' is an element selection: validate it before
|
|
// fetching (validate-all-first), so a rejection is a 400 with its own
|
|
// message. Names without brackets are matched as written, as before
|
|
// (unnamed jobs carry their command, with spaces, as their name).
|
|
hasElementNumber := strings.ContainsAny(req.Filter.JobName, "[]")
|
|
if hasElementNumber {
|
|
for _, filter := range filters {
|
|
if filter.Field != "job_name" {
|
|
continue
|
|
}
|
|
if _, _, err := BuildSelectionPredicates([]utils.FilterCondition{filter}); err != nil {
|
|
returnSelectionError(ctx, filter.Value, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
//1. fetch jobs from db with req.Filter
|
|
jobList, err := FetchJobList(filters)
|
|
if err != nil {
|
|
logger.Error("fetch job list error", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
if len(jobList) == 0 {
|
|
logger.Debug("no job found")
|
|
ReturnSuccess(ctx, common.SUCCESS_CODE, common.SUCCESS_MSG, map[string]interface{}{
|
|
"count": 0,
|
|
"rows": []interface{}{},
|
|
"page_token": "",
|
|
})
|
|
return
|
|
}
|
|
|
|
var jobInfoList []utils.OrderMap
|
|
if !req.Filter.AllJobs && !hasElementNumber {
|
|
// "most recent job" rule: rows are sorted by unique_job_id, so the
|
|
// last one is the most recent; act on it alone when it is an
|
|
// ordinary job (or a pre-upgrade element 0) or the only stored
|
|
// element of its array (5.10). A filter with an element number acts
|
|
// on exactly the selected elements (5.2).
|
|
last := jobList[len(jobList)-1]
|
|
if last.Data["array_idx"] == 0 || isSingleElementArray(last) {
|
|
jobInfoList = jobList[len(jobList)-1:]
|
|
} else {
|
|
jobInfoList = jobList
|
|
}
|
|
} else {
|
|
jobInfoList = jobList
|
|
}
|
|
logger.Debug("job list got:", zap.Any("jobInfoList", jobInfoList))
|
|
|
|
//2. go through job list and fill in action_control
|
|
|
|
successJobs := []map[string]interface{}{}
|
|
failedJobs := []map[string]interface{}{}
|
|
|
|
for _, job := range jobInfoList {
|
|
|
|
// check rbac begin
|
|
if job_owner, exist := job.Data["submit_user"]; exist {
|
|
jobPermission, err := GetRequireResourcePermission(submitter, "job:control")
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
if jobPermission.DomainType == models.DomainTypeSelf {
|
|
if job_owner.(string) != submitter {
|
|
logger.Error("Permission denied", zap.String("perm", "job:control"), zap.String("logonUser", submitter))
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if job_queue, exist := job.Data["queue_name"]; exist {
|
|
queuePermission, err := GetRequireResourcePermission(submitter, "queue:control")
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
var accessQueues []string
|
|
if queuePermission.DomainType == models.DomainTypeAll {
|
|
accessQueues = store.GetAllQueueNames()
|
|
}
|
|
if queuePermission.DomainType == models.DomainTypeQueue {
|
|
accessQueues = queuePermission.DomainValues
|
|
}
|
|
// check job:control permission
|
|
if err != nil || len(accessQueues) == 0 || !utils.ContainsField(accessQueues, job_queue.(string)) {
|
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
// check job status to see if can do corresponding action
|
|
validatedSuccessJobs, validatedFailedJobs := validateJobControl(job, req.Action, req.Signal)
|
|
|
|
for _, successJob := range validatedSuccessJobs {
|
|
successJobs = append(successJobs, map[string]interface{}{
|
|
"unique_job_id": successJob["unique_job_id"].(int64),
|
|
"job_id": successJob["job_id"].(int64),
|
|
"array_idx": successJob["array_idx"].(int),
|
|
"msg": successJob["msg"].(string),
|
|
})
|
|
}
|
|
|
|
for _, failedJob := range validatedFailedJobs {
|
|
failedJobs = append(failedJobs, map[string]interface{}{
|
|
"unique_job_id": failedJob["unique_job_id"].(int64),
|
|
"job_id": failedJob["job_id"].(int64),
|
|
"array_idx": failedJob["array_idx"].(int),
|
|
"msg": failedJob["msg"].(string),
|
|
})
|
|
}
|
|
}
|
|
if len(successJobs) > 0 {
|
|
signalInt, _ := strconv.Atoi(signalval)
|
|
for _, successJob := range successJobs {
|
|
jobCtrlEvt := models.JobControlEvent{
|
|
UniqueJobID: successJob["unique_job_id"].(int64),
|
|
Signal: signalInt,
|
|
Comment: req.Comment,
|
|
ForceKill: req.ForceKill,
|
|
Method: req.Method,
|
|
}
|
|
|
|
jobCtrlEvtBytes, err := json.Marshal(jobCtrlEvt)
|
|
if err != nil {
|
|
logger.Error("encode job control event error", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, "encode job control event error:"+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
err = controlJob(uint64(successJob["unique_job_id"].(int64)), acttype, string(jobCtrlEvtBytes))
|
|
if err != nil {
|
|
logger.Error("send job control to backend error", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_WRITE_DATA_MSG+": "+err.Error(), nil)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// response to user
|
|
//logger.Debug("job ctrl accept resps:", zap.Any("jobCtrlAcceptResps", jobCtrlAcceptResps))
|
|
//ReturnSuccess(ctx, common.ACCEPTED, "Job control accepted.", jobCtrlAcceptResps)
|
|
response, successMsg := BuildBatchOperationResponse(req.Action, "Job", successJobs, failedJobs)
|
|
|
|
overallStatus := response["status"].(string)
|
|
logger.Info("Finished processing job control request",
|
|
zap.String("action", req.Action),
|
|
zap.String("overallStatus", overallStatus),
|
|
zap.Int("succeededCount", len(successJobs)),
|
|
zap.Int("failedCount", len(failedJobs)),
|
|
zap.String("client_ip", ctx.ClientIP()))
|
|
|
|
ReturnSuccess(ctx, common.ACCEPTED, successMsg, response)
|
|
return
|
|
}
|
|
|
|
func preCheckJobControlRequest(req *models.JobControlRequest) error {
|
|
logger.Debug("Enter preCheckJobControlRequest")
|
|
|
|
if err := utils.ValidateControlAction(req.Action, models.SupportedJobActions); err != nil {
|
|
return err
|
|
}
|
|
req.Action = strings.ToLower(strings.TrimSpace(req.Action))
|
|
// don't change the calling sequence in here,
|
|
// for stop/resume, it doesn't user input signal,
|
|
// but it will set default value later
|
|
if (req.Action == "stop" || req.Action == "resume") && req.Signal != "" {
|
|
return errors.New("signal must be empty for stop or resume actions")
|
|
}
|
|
|
|
// set default signal value if not provided
|
|
if req.Signal == "" {
|
|
if defaultSignal, ok := models.DefaultActionSignal[strings.ToLower(req.Action)]; ok {
|
|
req.Signal = defaultSignal
|
|
}
|
|
|
|
}
|
|
|
|
// transform signal to numeric value if it is a string
|
|
if req.Signal != "" && !utils.IsNumeric(req.Signal) {
|
|
if sigNum, ok := models.JobSignalMapTable[strings.ToUpper(req.Signal)]; ok {
|
|
req.Signal = strconv.Itoa(sigNum)
|
|
} else {
|
|
return fmt.Errorf("unsupported signal: %s", req.Signal)
|
|
}
|
|
}
|
|
|
|
if req.Action == "kill" && req.Signal != "" {
|
|
found := false
|
|
if utils.IsNumeric(req.Signal) {
|
|
for _, v := range models.JobSignalMapTable {
|
|
if strconv.Itoa(v) == req.Signal {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found == false {
|
|
return fmt.Errorf("unsupported signal: %s", req.Signal)
|
|
}
|
|
} else {
|
|
if _, ok := models.JobSignalMapTable[req.Signal]; !ok {
|
|
return fmt.Errorf("unsupported signal: %s", req.Signal)
|
|
}
|
|
}
|
|
}
|
|
if req.Comment != "" {
|
|
if len(req.Comment) > 512 {
|
|
return errors.New("comment length exceeds the limit of 512 characters")
|
|
}
|
|
}
|
|
if req.Action == "checkpoint" {
|
|
if req.Method != "" {
|
|
if len(req.Method) > 64 {
|
|
return errors.New("method length exceeds the limit of 64 characters")
|
|
}
|
|
} else {
|
|
req.Method = "CRIU"
|
|
}
|
|
} else {
|
|
req.Method = ""
|
|
}
|
|
return nil
|
|
}
|
|
func getFilters(req models.JobControlRequest, submitter string) []utils.FilterCondition {
|
|
filters := make([]utils.FilterCondition, 0)
|
|
if req.Filter.Users == "" {
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "submit_user",
|
|
Operator: "eq",
|
|
Value: strings.TrimSpace(submitter),
|
|
})
|
|
} else {
|
|
if req.Filter.Users != "all" {
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "submit_user",
|
|
Operator: "eq",
|
|
Value: strings.TrimSpace(req.Filter.Users), // TODO: need consider usergroup.
|
|
})
|
|
}
|
|
}
|
|
if req.Filter.AllJobs {
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "job_status",
|
|
Operator: "notin",
|
|
Value: "(DONE,EXIT)",
|
|
})
|
|
}
|
|
// TODO: need consider array job ?????
|
|
if req.Filter.JobName != "" {
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "job_name",
|
|
Operator: "eq",
|
|
Value: strings.TrimSpace(req.Filter.JobName),
|
|
})
|
|
}
|
|
|
|
if req.Filter.JobGroupName != "" {
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "job_group",
|
|
Operator: "eq",
|
|
Value: strings.TrimSpace(req.Filter.JobGroupName),
|
|
})
|
|
}
|
|
|
|
if req.Filter.QueueName != "" {
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "queue_name",
|
|
Operator: "eq",
|
|
Value: strings.TrimSpace(req.Filter.QueueName),
|
|
})
|
|
}
|
|
|
|
// TODO: need handle exec host and hostgroup as job query.
|
|
//
|
|
if req.Filter.ExecHosts != "" {
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "exec_hosts",
|
|
Operator: "eq",
|
|
Value: strings.TrimSpace(req.Filter.ExecHosts),
|
|
})
|
|
}
|
|
|
|
if req.Filter.SLA != "" {
|
|
filters = append(filters, utils.FilterCondition{
|
|
Field: "sla",
|
|
Operator: "eq",
|
|
Value: strings.TrimSpace(req.Filter.SLA),
|
|
})
|
|
}
|
|
return filters
|
|
|
|
}
|
|
|
|
/* validate if job status and job control action is match:
|
|
* eg: stop a non run/pend/ssuspend job (psusp/done/exit/ususpend);
|
|
* resume a non ususpend/psusp job(run/pend/done/exit/ssuspend);
|
|
* kill a non active job ( done/exit)
|
|
*/
|
|
func validateJobControl(jobinfo utils.OrderMap, action string,
|
|
signal string) (successJobs []map[string]interface{}, failedJobs []map[string]interface{}) {
|
|
var actionType int
|
|
switch action {
|
|
case "stop":
|
|
actionType = models.JOB_STOP
|
|
case "resume":
|
|
actionType = models.JOB_RESUME
|
|
case "kill":
|
|
actionType = models.JOB_KILL
|
|
case "checkpoint":
|
|
actionType = models.JOB_CHKPNT
|
|
default:
|
|
actionType = models.JOB_KILL
|
|
}
|
|
|
|
// bug-1742: array_idx is a plain Go `int` on models.JobInfo, not `int64`
|
|
// -- utils.IntOrInt64 accepts either, unlike a bare `.(int64)` assertion.
|
|
arrayIdx, arrayOk := utils.IntOrInt64(jobinfo.Data["array_idx"])
|
|
if !arrayOk {
|
|
logger.Error("array_idx is not an int/int64", zap.Any("array_idx", jobinfo.Data["array_idx"]))
|
|
failedJobs = append(failedJobs, map[string]interface{}{
|
|
"unique_job_id": jobinfo.Data["unique_job_id"].(int64),
|
|
"job_id": jobinfo.Data["job_id"].(int64),
|
|
"array_idx": arrayIdx,
|
|
"msg": fmt.Sprintf("Invalid array_idx"),
|
|
})
|
|
return successJobs, failedJobs
|
|
}
|
|
|
|
if rawStatus, exists := jobinfo.Data["job_status"]; exists {
|
|
// bug-1742: job_status is likewise a plain Go `int`, not `int64`.
|
|
if jobStatusInt, ok := utils.IntOrInt64(rawStatus); ok {
|
|
jobStatus := int64(jobStatusInt)
|
|
db_job_status := int(jobStatus)
|
|
job_status := models.JobStateStr(db_job_status)
|
|
jobStatus = int64(models.JOBSTAT[job_status])
|
|
if utils.IsControlActionAllowed(actionType, jobStatus, models.JobActionControlMap) {
|
|
if action == "kill" && signal != "" {
|
|
if !utils.IsControlActionAllowed(actionType, jobStatus, models.JobKillSignalControlMap) {
|
|
failedJobs = append(failedJobs, map[string]interface{}{
|
|
"unique_job_id": jobinfo.Data["unique_job_id"].(int64),
|
|
"job_id": jobinfo.Data["job_id"].(int64),
|
|
"array_idx": arrayIdx,
|
|
"msg": fmt.Sprintf("job cannot be <killed> or <signaled>"),
|
|
})
|
|
} else {
|
|
successJobs = append(successJobs, map[string]interface{}{
|
|
"unique_job_id": jobinfo.Data["unique_job_id"].(int64),
|
|
"job_id": jobinfo.Data["job_id"].(int64),
|
|
"array_idx": arrayIdx,
|
|
"msg": fmt.Sprintf("job is being <%s>", action),
|
|
})
|
|
}
|
|
return successJobs, failedJobs
|
|
}
|
|
successJobs = append(successJobs, map[string]interface{}{
|
|
"unique_job_id": jobinfo.Data["unique_job_id"].(int64),
|
|
"job_id": jobinfo.Data["job_id"].(int64),
|
|
"array_idx": arrayIdx,
|
|
"msg": fmt.Sprintf("job is being <%s>", action),
|
|
})
|
|
} else {
|
|
logger.Error("Action not allowed for job", zap.Any("jobStatus", jobStatus))
|
|
failedJobs = append(failedJobs, map[string]interface{}{
|
|
"unique_job_id": jobinfo.Data["unique_job_id"].(int64),
|
|
"job_id": jobinfo.Data["job_id"].(int64),
|
|
"array_idx": arrayIdx,
|
|
"msg": fmt.Sprintf("job cannot be <%s>", action),
|
|
})
|
|
}
|
|
} else {
|
|
logger.Error("job_status is not an int/int64", zap.Any("job_status", rawStatus))
|
|
failedJobs = append(failedJobs, map[string]interface{}{
|
|
"unique_job_id": jobinfo.Data["unique_job_id"].(int64),
|
|
"job_id": jobinfo.Data["job_id"].(int64),
|
|
"array_idx": arrayIdx,
|
|
"msg": fmt.Sprintf("Invalid job_status"),
|
|
})
|
|
}
|
|
} else {
|
|
logger.Error("job_status not found in jobInfo", zap.Any("rawStatus", rawStatus))
|
|
failedJobs = append(failedJobs, map[string]interface{}{
|
|
"unique_job_id": jobinfo.Data["unique_job_id"].(int64),
|
|
"job_id": jobinfo.Data["job_id"].(int64),
|
|
"array_idx": arrayIdx,
|
|
"msg": fmt.Sprintf("job_status not found for job"),
|
|
})
|
|
}
|
|
|
|
return successJobs, failedJobs
|
|
}
|
|
|
|
// foundJob returns the rows of jobinfos a control selection names.
|
|
//
|
|
// With explicitIndex (the selection has an element part, "<id>[k]" or
|
|
// "<id>[a-b]"), only array elements -- rows whose unique_job_id carries the
|
|
// array marking -- of that array with array_idx == idx match, so "<id>[0]"
|
|
// never falls back to the whole array or to an ordinary job with identity
|
|
// <id> (5.4, 8.2). Without it, every row with that job_id matches: all
|
|
// elements of the array and any ordinary job (5.6).
|
|
func foundJob(jobID int64, idx int, explicitIndex bool, rows []utils.OrderMap) ([]utils.OrderMap, error) {
|
|
jobs := make([]utils.OrderMap, 0)
|
|
for _, row := range rows {
|
|
rowJobID, ok := rowUint64(row, "job_id")
|
|
if !ok || rowJobID != uint64(jobID) {
|
|
continue
|
|
}
|
|
if explicitIndex && !rowElementInRange(row, idx, idx) {
|
|
continue
|
|
}
|
|
jobs = append(jobs, row)
|
|
}
|
|
if len(jobs) == 0 {
|
|
return nil, errors.New("job not found")
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
// queryControlRowsByJobID fetches every stored row whose job_id is the
|
|
// selection's key (all pages), for ControlJobByID to match with foundJob. On
|
|
// a failure it writes the error reply and returns ok == false.
|
|
func queryControlRowsByJobID(ctx *gin.Context, sel Selection, fields []string,
|
|
sortOrders []utils.SortOrder) (rows []utils.OrderMap, ok bool) {
|
|
filtercds, err := utils.ConvertToNewFilter([]utils.FilterCondition{{
|
|
Field: "job_id",
|
|
Operator: "eq",
|
|
Value: strconv.FormatUint(sel.Key, 10),
|
|
}}, &models.JobInfo{})
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
|
return nil, false
|
|
}
|
|
|
|
rows = make([]utils.OrderMap, 0)
|
|
bookmark := ""
|
|
for {
|
|
queryOpts, err := utils.BuildQueryOptions(filtercds, nil, sortOrders, utils.PER_PAGE_LIMIT_MAX, bookmark)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return nil, false
|
|
}
|
|
logger.Debug("the job query request:", zap.String("selection", sel.Raw), zap.Any("filter", filtercds),
|
|
zap.String("pagetoken", bookmark), zap.Any("fields", fields))
|
|
reply, pageToken, err := services.QueryData(services.TableJobInfo, queryOpts, fields)
|
|
if err != nil {
|
|
logger.Error("Failed to query job info.", zap.Error(err))
|
|
ReturnError(ctx, common.ERROR_CODE, common.ERROR_QUERY_JOBINFO_MSG+": "+err.Error(), nil)
|
|
return nil, false
|
|
}
|
|
rows = append(rows, reply...)
|
|
// page_token is escaped; unescape it for internal use
|
|
bookmark, _ = url.QueryUnescape(pageToken)
|
|
if bookmark == "" {
|
|
break
|
|
}
|
|
}
|
|
return rows, true
|
|
}
|
|
|
|
func processJobData(rows []utils.OrderMap, pagetoken string) utils.QueryResult {
|
|
logger.Debug("Enter processJobData() function", zap.Any("rows", rows))
|
|
for _, row := range rows {
|
|
if v, exist := row.Data["job_status"]; exist {
|
|
if v != nil {
|
|
jobStatus, _ := utils.IntOrInt64(v)
|
|
row.Data["job_status"] = models.JobStateStr(jobStatus)
|
|
}
|
|
}
|
|
|
|
// Update job_name if this is an array job child (including idx=0)
|
|
if jobName, exist := row.Data["job_name"]; exist {
|
|
// Use IsArrayJob to detect array children, which covers
|
|
// the first child (idx=0) that the old array_idx > 0 check missed.
|
|
var isArray bool
|
|
if uid, ok := row.Data["unique_job_id"].(int64); ok {
|
|
isArray = backend.IsArrayJob(uint64(uid))
|
|
}
|
|
if isArray {
|
|
if arrayIdx, ok := row.Data["array_idx"].(int); ok {
|
|
row.Data["job_name"] = fmt.Sprintf("%s[%d]", jobName, arrayIdx)
|
|
} else if arrayIdx64, ok := row.Data["array_idx"].(int64); ok {
|
|
row.Data["job_name"] = fmt.Sprintf("%s[%d]", jobName, arrayIdx64)
|
|
}
|
|
} else {
|
|
row.Data["job_name"] = jobName
|
|
}
|
|
}
|
|
|
|
// Update launch_type if exist
|
|
if val, ok := row.Data["launch_type"]; ok && val != nil {
|
|
if int64Val, _ := val.(int64); ok {
|
|
code := int(int64Val)
|
|
if str, exists := models.GetLaunchTypeString(code); exists {
|
|
row.Data["launch_type"] = str
|
|
} else {
|
|
row.Data["launch_type"] = "unknown"
|
|
}
|
|
}
|
|
}
|
|
// Process JSONB fields based on predefined list
|
|
for _, jsonbField := range models.JSONBJFields {
|
|
if v, exist := row.Data[jsonbField]; exist {
|
|
if strValue, ok := v.(string); ok {
|
|
if jsonbData, err := utils.ParseJSONB(strValue); err == nil {
|
|
row.Data[jsonbField] = jsonbData
|
|
} else {
|
|
logger.Error("Failed to parse job info JSONB field.", zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert database fields back to user-recognizable fields
|
|
for userField, dbField := range models.JobFieldMappings {
|
|
//if v, exist := row.Data[dbField]; exist && v != nil {
|
|
if v, exist := row.Data[dbField]; exist {
|
|
row.Data[userField] = v
|
|
// Replace the value in row.Fields with userField
|
|
for i, field := range row.Fields {
|
|
if field == dbField {
|
|
row.Fields[i] = userField
|
|
break
|
|
}
|
|
}
|
|
// Remove the user field from the data map
|
|
delete(row.Data, dbField)
|
|
}
|
|
}
|
|
}
|
|
|
|
return utils.QueryResult{
|
|
Rows: rows,
|
|
Count: len(rows),
|
|
Pagetoken: pagetoken,
|
|
}
|
|
}
|
|
|
|
func copyLocalFilesConcurrently(ctx *gin.Context, files []string, username string) (map[string]string, error) {
|
|
logger.Debug("Enter copyLocalFilesConcurrently function files:", zap.Any("files", files))
|
|
|
|
result := make(map[string]string)
|
|
|
|
webUrl := utils.GetServerHostPort(ctx)
|
|
|
|
for _, localFile := range files {
|
|
if strings.HasPrefix(localFile, webUrl) {
|
|
localFile = strings.TrimPrefix(localFile, webUrl+"/")
|
|
}
|
|
// Extract the date from the localFile path
|
|
parts := strings.Split(localFile, "/")
|
|
if len(parts) < 2 {
|
|
logger.Error("Invalid file path format", zap.String("file", localFile))
|
|
return nil, fmt.Errorf("invalid file path format: %s", localFile)
|
|
}
|
|
dateParts := strings.Split(parts[0], "_")
|
|
if len(dateParts) < 1 {
|
|
logger.Error("Invalid date format in file path", zap.String("file", localFile))
|
|
return nil, fmt.Errorf("invalid date format in file path: %s", localFile)
|
|
}
|
|
date := dateParts[0] // The first part is the date
|
|
logger.Debug("Extracted date from file path", zap.String("date", date))
|
|
|
|
localFile = filepath.Join("/home/", username, "./apiserver_uploads", date, parts[0])
|
|
fmt.Println("localFile=", localFile)
|
|
// Check if the file exists
|
|
if _, err := os.Stat(localFile); os.IsNotExist(err) {
|
|
logger.Error("File does not exist", zap.String("file", localFile))
|
|
return nil, fmt.Errorf("file does not exist: %s", localFile)
|
|
}
|
|
|
|
result[parts[1]] = localFile
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func copyRemoteFilesLocally(ctx *gin.Context, files []string, username string) map[string]string {
|
|
result := make(map[string]string)
|
|
webUrl := utils.GetServerHostPort(ctx)
|
|
submissionDate := time.Now().Format("20060102")
|
|
|
|
for _, localFile := range files {
|
|
// Generate MD5 hash for the file content
|
|
hasher := md5.New()
|
|
contentHash := hasher.Sum(nil)
|
|
|
|
// Generate UUID based on the MD5 hash
|
|
contentUUID := uuid.NewMD5(uuid.NameSpaceOID, contentHash[:]).String()
|
|
copiedURI := webUrl + "/" + submissionDate + "_" + contentUUID + "/" + filepath.Base(localFile)
|
|
result[localFile] = copiedURI
|
|
}
|
|
return result
|
|
}
|
|
|
|
// fetch job list from job_info table according filter condition
|
|
// need special handling for job_name=arrayjob[1-3] (element selections,
|
|
// evaluated as row predicates) and exec_hosts cases.
|
|
func FetchJobList(filters []utils.FilterCondition) ([]utils.OrderMap, error) {
|
|
|
|
logger.Debug("Enter fetchJobList() function",
|
|
zap.Any("filter", filters))
|
|
|
|
tablename := services.TableJobInfo
|
|
var datatype models.JobInfo
|
|
limit := utils.PER_PAGE_LIMIT_MAX
|
|
fields := models.JOB_DEFAULT_COLUMN
|
|
sortOrders := make([]utils.SortOrder, 0)
|
|
jobLists := make([]utils.OrderMap, 0)
|
|
|
|
// Element selections (job_name values with '[' or ']') become row
|
|
// predicates; every other filter stays a plain condition. Parsed before
|
|
// the exec_hosts store lookups below (validate-all-first).
|
|
filters, preds, err := BuildSelectionPredicates(filters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i, filter := range filters {
|
|
if filter.Field == "exec_hosts" {
|
|
hosts := strings.Split(strings.TrimSpace(filters[i].Value), ",")
|
|
execHosts, err := GetHostList(hosts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
//change the filter to get unique_job_id list from db.
|
|
subFilters := make([]utils.FilterCondition, 0)
|
|
subFilters = append(subFilters, utils.FilterCondition{
|
|
Field: "host_name",
|
|
Operator: "in",
|
|
Value: "(" + strings.Join(execHosts, ",") + ")",
|
|
})
|
|
|
|
uniqueJobIds, err := FetchJobIdList(subFilters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(uniqueJobIds) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
//Replace filter condition from "exec_hosts" to "unique_job_id".
|
|
uniqueJobIdStrs := make([]string, len(uniqueJobIds))
|
|
for i, uniqueJobId := range uniqueJobIds {
|
|
uniqueJobIdStrs[i] = strconv.FormatInt(uniqueJobId, 10)
|
|
}
|
|
filters[i].Field = "unique_job_id"
|
|
filters[i].Operator = "in"
|
|
filters[i].Value = "(" + strings.Join(uniqueJobIdStrs, ",")
|
|
}
|
|
}
|
|
|
|
var filtercds []utils.Condition
|
|
if len(filters) > 0 {
|
|
filtercds, err = utils.ConvertToNewFilter(filters, &datatype)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
sortOrders = append(sortOrders, utils.SortOrder{
|
|
Column: "unique_job_id",
|
|
Ascending: true,
|
|
})
|
|
|
|
pagetokenStr := ""
|
|
for {
|
|
queryOpts, err := utils.BuildQueryOptions(filtercds, nil, sortOrders, limit, pagetokenStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
queryOpts.Predicates = preds
|
|
queryReply, pagetoken, err := services.QueryData(tablename, queryOpts, fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
jobLists = append(jobLists, queryReply...)
|
|
|
|
if pagetoken == "" {
|
|
break
|
|
}
|
|
pagetokenStr = pagetoken
|
|
}
|
|
|
|
logger.Debug("In fetchJobList() function",
|
|
zap.Any("joblist", jobLists))
|
|
return jobLists, nil
|
|
}
|
|
|
|
/* fetch job id list from job_exec_hosts table with filters
|
|
* exec_hosts:in:(h1,h2...) and return unique_job_id list.
|
|
*/
|
|
func FetchJobIdList(filters []utils.FilterCondition) ([]int64, error) {
|
|
|
|
logger.Debug("Enter fetchJobIdList() function",
|
|
zap.Any("filter", filters))
|
|
|
|
tablename := services.TableJobExecHosts
|
|
var datatype models.JobExecHosts
|
|
|
|
limit := utils.PER_PAGE_LIMIT_MAX
|
|
fields := []string{"insert_seq", "unique_job_id"}
|
|
|
|
sortOrders := make([]utils.SortOrder, 0)
|
|
jobLists := make([]utils.OrderMap, 0)
|
|
jobIdList := make([]int64, 0)
|
|
|
|
filtercds, err := utils.ConvertToNewFilter(filters, &datatype)
|
|
if err != nil {
|
|
logger.Error(err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
sortOrders = append(sortOrders, utils.SortOrder{
|
|
Column: "insert_seq",
|
|
Ascending: true,
|
|
})
|
|
|
|
pagetokenStr := ""
|
|
for {
|
|
queryOpts, err := utils.BuildQueryOptions(filtercds, nil, sortOrders, limit, pagetokenStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
queryReply, pagetoken, err := services.QueryData(tablename, queryOpts, fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
jobLists = append(jobLists, queryReply...)
|
|
|
|
if pagetoken == "" {
|
|
break
|
|
}
|
|
pagetokenStr = pagetoken
|
|
}
|
|
|
|
if len(jobLists) > 0 {
|
|
for _, job := range jobLists {
|
|
jobIdList = append(jobIdList, job.Data["unique_job_id"].(int64))
|
|
}
|
|
}
|
|
// when a parrallel job runs on multiple host, different host will map to the same unique_job_id.
|
|
// need remove duplicate unique_job_id.
|
|
uniqJobIds := utils.RemoveDuplicates(jobIdList)
|
|
|
|
logger.Debug("fetchJobIdList() function",
|
|
zap.Any("jobIdList", uniqJobIds))
|
|
|
|
return uniqJobIds, nil
|
|
}
|
|
|
|
// user specified host/hostgroup list, get individual host list by query host/hostgroup cache;
|
|
func GetHostList(hosts []string) ([]string, error) {
|
|
|
|
execHosts := make([]string, 0)
|
|
|
|
if len(hosts) == 0 {
|
|
return nil, errors.New("exec_host is empty")
|
|
}
|
|
|
|
for _, host := range hosts {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" {
|
|
continue
|
|
}
|
|
|
|
// TODO: handle hostgroup expansion when hostgroup support is added.
|
|
if _, ok := store.Hosts.Get(host); ok {
|
|
execHosts = append(execHosts, host)
|
|
} else {
|
|
return nil, fmt.Errorf("invalid host/hostgroup %s", host)
|
|
}
|
|
|
|
}
|
|
if len(execHosts) == 0 {
|
|
return nil, errors.New("exec_host is empty")
|
|
}
|
|
return execHosts, nil
|
|
}
|
|
|