diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml
index 9c42f73e84a..b960e0adb90 100644
--- a/api/v2.0/swagger.yaml
+++ b/api/v2.0/swagger.yaml
@@ -1795,7 +1795,7 @@ paths:
items:
$ref: '#/definitions/AuditLogEventType'
'401':
- $ref: '#/responses/401'
+ $ref: '#/responses/401'
/projects/{project_name}/logs:
get:
summary: Get recent logs of the projects (deprecated)
@@ -1863,7 +1863,7 @@ paths:
'401':
$ref: '#/responses/401'
'500':
- $ref: '#/responses/500'
+ $ref: '#/responses/500'
/p2p/preheat/providers:
get:
summary: List P2P providers
@@ -6949,8 +6949,8 @@ definitions:
description: The time when this operation is triggered.
AuditLogEventType:
type: object
- properties:
- event_type:
+ properties:
+ event_type:
type: string
description: the event type, such as create_user.
example: create_user
@@ -7446,6 +7446,12 @@ definitions:
type: boolean
description: Whether to enable copy by chunk.
x-isnullable: true
+ single_active_replication:
+ type: boolean
+ description: |-
+ Whether to defer execution until the previous active execution finishes,
+ avoiding the execution of the same replication rules multiple times in parallel.
+ x-isnullable: true # make this field optional to keep backward compatibility
ReplicationTrigger:
type: object
properties:
diff --git a/make/migrations/postgresql/0160_2.13.0_schema.up.sql b/make/migrations/postgresql/0160_2.13.0_schema.up.sql
index 88efb21b456..77df1028a8e 100644
--- a/make/migrations/postgresql/0160_2.13.0_schema.up.sql
+++ b/make/migrations/postgresql/0160_2.13.0_schema.up.sql
@@ -21,3 +21,5 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_ext_op_time ON audit_log_ext (op_time);
CREATE INDEX IF NOT EXISTS idx_audit_log_ext_project_id_optime ON audit_log_ext (project_id, op_time);
CREATE INDEX IF NOT EXISTS idx_audit_log_ext_project_id_resource_type ON audit_log_ext (project_id, resource_type);
CREATE INDEX IF NOT EXISTS idx_audit_log_ext_project_id_operation ON audit_log_ext (project_id, operation);
+
+ALTER TABLE replication_policy ADD COLUMN IF NOT EXISTS single_active_replication boolean;
diff --git a/src/controller/replication/execution.go b/src/controller/replication/execution.go
index 3c92250946f..6b653baa36b 100644
--- a/src/controller/replication/execution.go
+++ b/src/controller/replication/execution.go
@@ -109,10 +109,33 @@ func (c *controller) Start(ctx context.Context, policy *replicationmodel.Policy,
if op := operator.FromContext(ctx); op != "" {
extra["operator"] = op
}
+
id, err := c.execMgr.Create(ctx, job.ReplicationVendorType, policy.ID, trigger, extra)
if err != nil {
return 0, err
}
+
+ // If running executions are found, skip the current execution and mark it as skipped.
+ if policy.SingleActiveReplication {
+ count, err := c.execMgr.Count(ctx, &q.Query{
+ Keywords: map[string]interface{}{
+ "VendorType": job.ReplicationVendorType,
+ "VendorID": policy.ID,
+ "Status": job.RunningStatus.String(),
+ },
+ })
+ if err != nil {
+ return 0, err
+ }
+
+ if count > 1 {
+ if err = c.execMgr.MarkSkipped(ctx, id, "Execution skipped: active replication still in progress."); err != nil {
+ return 0, err
+ }
+ return id, nil
+ }
+ }
+
// start the replication flow in background
// as the process runs inside a goroutine, the transaction in the outer ctx
// may be submitted already when the process starts, so create an new context
diff --git a/src/controller/replication/execution_test.go b/src/controller/replication/execution_test.go
index de4791f09ad..41f8ef8ed9e 100644
--- a/src/controller/replication/execution_test.go
+++ b/src/controller/replication/execution_test.go
@@ -101,6 +101,38 @@ func (r *replicationTestSuite) TestStart() {
r.execMgr.AssertExpectations(r.T())
r.flowCtl.AssertExpectations(r.T())
r.ormCreator.AssertExpectations(r.T())
+
+ r.SetupTest()
+
+ // run replication flow with SingleActiveReplication, flow should not start
+ r.execMgr.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil)
+ r.execMgr.On("MarkSkipped", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+ r.execMgr.On("Count", mock.Anything, mock.Anything).Return(int64(2), nil) // Simulate an existing running execution
+ id, err = r.ctl.Start(context.Background(), &repctlmodel.Policy{Enabled: true, SingleActiveReplication: true}, nil, task.ExecutionTriggerManual)
+ r.Require().Nil(err)
+ r.Equal(int64(1), id)
+ time.Sleep(1 * time.Second) // wait the functions called in the goroutine
+ r.flowCtl.AssertNumberOfCalls(r.T(), "Start", 0)
+ r.execMgr.AssertNumberOfCalls(r.T(), "MarkSkipped", 1) // Ensure execution marked as skipped
+ r.execMgr.AssertExpectations(r.T())
+ r.flowCtl.AssertExpectations(r.T())
+ r.ormCreator.AssertExpectations(r.T())
+
+ r.SetupTest()
+
+ // no error when running the replication flow with SingleActiveReplication
+ r.execMgr.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil)
+ r.execMgr.On("Get", mock.Anything, mock.Anything).Return(&task.Execution{}, nil)
+ r.flowCtl.On("Start", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
+ r.ormCreator.On("Create").Return(nil)
+ r.execMgr.On("Count", mock.Anything, mock.Anything).Return(int64(1), nil) // Simulate no running execution
+ id, err = r.ctl.Start(context.Background(), &repctlmodel.Policy{Enabled: true, SingleActiveReplication: true}, nil, task.ExecutionTriggerManual)
+ r.Require().Nil(err)
+ r.Equal(int64(1), id)
+ time.Sleep(1 * time.Second) // wait the functions called in the goroutine
+ r.execMgr.AssertExpectations(r.T())
+ r.flowCtl.AssertExpectations(r.T())
+ r.ormCreator.AssertExpectations(r.T())
}
func (r *replicationTestSuite) TestStop() {
diff --git a/src/controller/replication/model/model.go b/src/controller/replication/model/model.go
index 63202d17973..70d98916a65 100644
--- a/src/controller/replication/model/model.go
+++ b/src/controller/replication/model/model.go
@@ -47,6 +47,7 @@ type Policy struct {
UpdateTime time.Time `json:"update_time"`
Speed int32 `json:"speed"`
CopyByChunk bool `json:"copy_by_chunk"`
+ SingleActiveReplication bool `json:"single_active_replication"`
}
// IsScheduledTrigger returns true when the policy is scheduled trigger and enabled
@@ -141,6 +142,7 @@ func (p *Policy) From(policy *replicationmodel.Policy) error {
p.UpdateTime = policy.UpdateTime
p.Speed = policy.Speed
p.CopyByChunk = policy.CopyByChunk
+ p.SingleActiveReplication = policy.SingleActiveReplication
if policy.SrcRegistryID > 0 {
p.SrcRegistry = &model.Registry{
@@ -186,6 +188,7 @@ func (p *Policy) To() (*replicationmodel.Policy, error) {
UpdateTime: p.UpdateTime,
Speed: p.Speed,
CopyByChunk: p.CopyByChunk,
+ SingleActiveReplication: p.SingleActiveReplication,
}
if p.SrcRegistry != nil {
policy.SrcRegistryID = p.SrcRegistry.ID
diff --git a/src/jobservice/common/rds/scripts.go b/src/jobservice/common/rds/scripts.go
index ca43ec489f7..fd176312988 100644
--- a/src/jobservice/common/rds/scripts.go
+++ b/src/jobservice/common/rds/scripts.go
@@ -42,7 +42,7 @@ local function compare(status, revision)
local aCode = stCode(ARGV[1])
local aRev = tonumber(ARGV[2]) or 0
local aCheckInT = tonumber(ARGV[3]) or 0
- if revision < aRev or
+ if revision < aRev or
( revision == aRev and sCode <= aCode ) or
( revision == aRev and aCheckInT ~= 0 )
then
@@ -96,7 +96,7 @@ if res then
redis.call('persist', KEYS[1])
end
end
-
+
return 'ok'
end
end
diff --git a/src/jobservice/job/status.go b/src/jobservice/job/status.go
index 6e38785346a..89053a2ee23 100644
--- a/src/jobservice/job/status.go
+++ b/src/jobservice/job/status.go
@@ -29,6 +29,8 @@ const (
SuccessStatus Status = "Success"
// ScheduledStatus : job status scheduled
ScheduledStatus Status = "Scheduled"
+ // SkippedStatus : job status skipped
+ SkippedStatus Status = "Skipped"
)
// Status of job
@@ -62,6 +64,8 @@ func (s Status) Code() int {
return 3
case "Success":
return 3
+ case "Skipped":
+ return 3
default:
}
diff --git a/src/jobservice/runner/redis.go b/src/jobservice/runner/redis.go
index 5237e634843..0831782311f 100644
--- a/src/jobservice/runner/redis.go
+++ b/src/jobservice/runner/redis.go
@@ -174,7 +174,7 @@ func (rj *RedisJob) Run(j *work.Job) (err error) {
case job.PendingStatus, job.ScheduledStatus:
// do nothing now
break
- case job.StoppedStatus:
+ case job.StoppedStatus, job.SkippedStatus:
// Probably jobs has been stopped by directly mark status to stopped.
// Directly exit and no retry
return nil
diff --git a/src/pkg/replication/model/model.go b/src/pkg/replication/model/model.go
index 04d17237c8f..6a4915cde55 100644
--- a/src/pkg/replication/model/model.go
+++ b/src/pkg/replication/model/model.go
@@ -43,6 +43,7 @@ type Policy struct {
UpdateTime time.Time `orm:"column(update_time);auto_now"`
Speed int32 `orm:"column(speed_kb)"`
CopyByChunk bool `orm:"column(copy_by_chunk)"`
+ SingleActiveReplication bool `orm:"column(single_active_replication)"`
}
// TableName set table name for ORM
diff --git a/src/pkg/task/execution.go b/src/pkg/task/execution.go
index 31c993e36e2..2b7ff2922fc 100644
--- a/src/pkg/task/execution.go
+++ b/src/pkg/task/execution.go
@@ -49,6 +49,8 @@ type ExecutionManager interface {
// In other cases, the execution status can be calculated from the referenced tasks automatically
// and no need to update it explicitly
MarkDone(ctx context.Context, id int64, message string) (err error)
+ // MarkSkipped marks the status of the specified execution as skipped.
+ MarkSkipped(ctx context.Context, id int64, message string) (err error)
// MarkError marks the status of the specified execution as error.
// It must be called to update the execution status when failed to create tasks.
// In other cases, the execution status can be calculated from the referenced tasks automatically
@@ -139,6 +141,17 @@ func (e *executionManager) UpdateExtraAttrs(ctx context.Context, id int64, extra
return e.executionDAO.Update(ctx, execution, "ExtraAttrs", "UpdateTime")
}
+func (e *executionManager) MarkSkipped(ctx context.Context, id int64, message string) error {
+ now := time.Now()
+ return e.executionDAO.Update(ctx, &dao.Execution{
+ ID: id,
+ Status: job.SkippedStatus.String(),
+ StatusMessage: message,
+ UpdateTime: now,
+ EndTime: now,
+ }, "Status", "StatusMessage", "UpdateTime", "EndTime")
+}
+
func (e *executionManager) MarkDone(ctx context.Context, id int64, message string) error {
now := time.Now()
return e.executionDAO.Update(ctx, &dao.Execution{
diff --git a/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.html b/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.html
index 3bfcbb21346..a40399823bb 100644
--- a/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.html
+++ b/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.html
@@ -825,6 +825,37 @@
{{ headerTitle | translate }}
'REPLICATION.ENABLED_RULE' | translate
}}
+
+
+
+
diff --git a/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.scss b/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.scss
index 5114fe7663c..5a7c282f503 100644
--- a/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.scss
+++ b/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.scss
@@ -246,6 +246,10 @@ clr-modal {
width: 8.6rem;
}
+.single-active {
+ width: 16rem;
+}
+
.des-tooltip {
margin-left: 0.5rem;
}
diff --git a/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.ts b/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.ts
index 5b66125146e..b30620eea6e 100644
--- a/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.ts
+++ b/src/portal/src/app/base/left-side-nav/replication/replication/create-edit-rule/create-edit-rule.component.ts
@@ -334,6 +334,7 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
override: true,
speed: -1,
copy_by_chunk: false,
+ single_active_replication: false,
});
}
@@ -367,6 +368,7 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
dest_namespace_replace_count: Flatten_Level.FLATTEN_LEVEl_1,
speed: -1,
copy_by_chunk: false,
+ single_active_replication: false,
});
this.isPushMode = true;
this.selectedUnit = BandwidthUnit.KB;
@@ -410,6 +412,7 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
override: rule.override,
speed: speed,
copy_by_chunk: rule.copy_by_chunk,
+ single_active_replication: rule.single_active_replication,
});
let filtersArray = this.getFilterArray(rule);
this.noSelectedEndpoint = false;
diff --git a/src/portal/src/i18n/lang/de-de-lang.json b/src/portal/src/i18n/lang/de-de-lang.json
index b9379409fed..2d7c9948b56 100644
--- a/src/portal/src/i18n/lang/de-de-lang.json
+++ b/src/portal/src/i18n/lang/de-de-lang.json
@@ -76,6 +76,7 @@
"PULL_BASED": "Lade die Ressourcen von der entfernten Registry auf den lokalen Harbor runter.",
"DESTINATION_NAMESPACE": "Spezifizieren des Ziel-Namespace. Wenn das Feld leer ist, werden die Ressourcen unter dem gleichen Namespace abgelegt wie in der Quelle.",
"OVERRIDE": "Spezifizieren, ob die Ressourcen am Ziel überschrieben werden sollen, falls eine Ressource mit gleichem Namen existiert.",
+ "SINGLE_ACTIVE_REPLICATION": "Specify whether to defer execution until the previous active execution finishes, avoiding the execution of the same replication rules multiple times in parallel.",
"EMAIL": "E-Mail sollte eine gültige E-Mail-Adresse wie name@example.com sein.",
"USER_NAME": "Darf keine Sonderzeichen enthalten und sollte kürzer als 255 Zeichen sein.",
"FULL_NAME": "Maximale Länge soll 20 Zeichen sein.",
@@ -560,6 +561,7 @@
"ALLOWED_CHARACTERS": "Erlaubte Sonderzeichen",
"TOTAL": "Gesamt",
"OVERRIDE": "Überschreiben",
+ "SINGLE_ACTIVE_REPLICATION": "Single active replication",
"ENABLED_RULE": "Aktiviere Regel",
"OVERRIDE_INFO": "Überschreiben",
"OPERATION": "Operation",
diff --git a/src/portal/src/i18n/lang/en-us-lang.json b/src/portal/src/i18n/lang/en-us-lang.json
index 88f756b027a..e22e7a6fb73 100644
--- a/src/portal/src/i18n/lang/en-us-lang.json
+++ b/src/portal/src/i18n/lang/en-us-lang.json
@@ -76,6 +76,7 @@
"PULL_BASED": "Pull the resources from the remote registry to the local Harbor.",
"DESTINATION_NAMESPACE": "Specify the destination namespace. If empty, the resources will be put under the same namespace as the source.",
"OVERRIDE": "Specify whether to override the resources at the destination if a resource with the same name exists.",
+ "SINGLE_ACTIVE_REPLICATION": "Specify whether to defer execution until the previous active execution finishes, avoiding the execution of the same replication rules multiple times in parallel.",
"EMAIL": "Email should be a valid email address like name@example.com.",
"USER_NAME": "Cannot contain special characters and maximum length should be 255 characters.",
"FULL_NAME": "Maximum length should be 20 characters.",
@@ -560,6 +561,7 @@
"ALLOWED_CHARACTERS": "Allowed special characters",
"TOTAL": "Total",
"OVERRIDE": "Override",
+ "SINGLE_ACTIVE_REPLICATION": "Single active replication",
"ENABLED_RULE": "Enable rule",
"OVERRIDE_INFO": "Override",
"OPERATION": "Operation",
diff --git a/src/portal/src/i18n/lang/es-es-lang.json b/src/portal/src/i18n/lang/es-es-lang.json
index a62c804463b..cde007da8fc 100644
--- a/src/portal/src/i18n/lang/es-es-lang.json
+++ b/src/portal/src/i18n/lang/es-es-lang.json
@@ -76,6 +76,7 @@
"PULL_BASED": "Pull de recursos del remote registry al local Harbor.",
"DESTINATION_NAMESPACE": "Especificar el namespace de destino. Si esta vacio, los recursos se colocan en el mismo namespace del recurso.",
"OVERRIDE": "Especifique si desea anular los recursos en el destino si existe un recurso con el mismo nombre.",
+ "SINGLE_ACTIVE_REPLICATION": "Specify whether to defer execution until the previous active execution finishes, avoiding the execution of the same replication rules multiple times in parallel.",
"EMAIL": "El email debe ser una dirección válida como nombre@ejemplo.com.",
"USER_NAME": "Debe tener una longitud máxima de 255 caracteres y no puede contener caracteres especiales.",
"FULL_NAME": "La longitud máxima debería ser de 20 caracteres.",
@@ -560,6 +561,7 @@
"ALLOWED_CHARACTERS": "Caracteres Especiales Permitidos",
"TOTAL": "Total",
"OVERRIDE": "Sobreescribir",
+ "SINGLE_ACTIVE_REPLICATION": "Single active replication",
"ENABLED_RULE": "Activar regla",
"OVERRIDE_INFO": "Sobreescribir",
"CURRENT": "Actual",
diff --git a/src/portal/src/i18n/lang/fr-fr-lang.json b/src/portal/src/i18n/lang/fr-fr-lang.json
index 578026d8b88..ec0c35866be 100644
--- a/src/portal/src/i18n/lang/fr-fr-lang.json
+++ b/src/portal/src/i18n/lang/fr-fr-lang.json
@@ -76,6 +76,7 @@
"PULL_BASED": "Pull les ressources du registre distant vers le Harbor local.",
"DESTINATION_NAMESPACE": "Spécifier l'espace de nom de destination. Si vide, les ressources seront placées sous le même espace de nom que la source.",
"OVERRIDE": "Spécifier s'il faut remplacer les ressources dans la destination si une ressource avec le même nom existe.",
+ "SINGLE_ACTIVE_REPLICATION": "Specify whether to defer execution until the previous active execution finishes, avoiding the execution of the same replication rules multiple times in parallel.",
"EMAIL": "L'e-mail doit être une adresse e-mail valide comme name@example.com.",
"USER_NAME": "Ne peut pas contenir de caractères spéciaux et la longueur maximale est de 255 caractères.",
"FULL_NAME": "La longueur maximale est de 20 caractères.",
@@ -560,6 +561,7 @@
"ALLOWED_CHARACTERS": "Caractères spéciaux autorisés",
"TOTAL": "Total",
"OVERRIDE": "Surcharger",
+ "SINGLE_ACTIVE_REPLICATION": "Single active replication",
"ENABLED_RULE": "Activer la règle",
"OVERRIDE_INFO": "Surcharger",
"OPERATION": "Opération",
diff --git a/src/portal/src/i18n/lang/ko-kr-lang.json b/src/portal/src/i18n/lang/ko-kr-lang.json
index 777ec5bd3be..7a9959d8e74 100644
--- a/src/portal/src/i18n/lang/ko-kr-lang.json
+++ b/src/portal/src/i18n/lang/ko-kr-lang.json
@@ -76,6 +76,7 @@
"PULL_BASED": "원격 레지스트리의 리소스를 로컬 'Harbor'로 가져옵니다.",
"DESTINATION_NAMESPACE": "대상 네임스페이스를 지정합니다. 비어 있으면 리소스는 소스와 동일한 네임스페이스에 배치됩니다.",
"OVERRIDE": "동일한 이름의 리소스가 있는 경우 대상의 리소스를 재정의할지 여부를 지정합니다.",
+ "SINGLE_ACTIVE_REPLICATION": "Specify whether to defer execution until the previous active execution finishes, avoiding the execution of the same replication rules multiple times in parallel.",
"EMAIL": "이메일은 name@example.com과 같은 유효한 이메일 주소여야 합니다.",
"USER_NAME": "특수 문자를 포함할 수 없으며 최대 길이는 255자입니다.",
"FULL_NAME": "최대 길이는 20자입니다.",
@@ -557,6 +558,7 @@
"ALLOWED_CHARACTERS": "허용되는 특수 문자",
"TOTAL": "총",
"OVERRIDE": "Override",
+ "SINGLE_ACTIVE_REPLICATION": "Single active replication",
"ENABLED_RULE": "규칙 활성화",
"OVERRIDE_INFO": "Override",
"OPERATION": "작업",
diff --git a/src/portal/src/i18n/lang/pt-br-lang.json b/src/portal/src/i18n/lang/pt-br-lang.json
index 095420e1424..44578148ec0 100644
--- a/src/portal/src/i18n/lang/pt-br-lang.json
+++ b/src/portal/src/i18n/lang/pt-br-lang.json
@@ -76,6 +76,7 @@
"PULL_BASED": "Trazer recursos do repositório remoto para o Harbor local.",
"DESTINATION_NAMESPACE": "Especificar o namespace de destino. Se vazio, os recursos serão colocados no mesmo namespace que a fonte.",
"OVERRIDE": "Sobrescrever recursos no destino se já existir com o mesmo nome.",
+ "SINGLE_ACTIVE_REPLICATION": "Specify whether to defer execution until the previous active execution finishes, avoiding the execution of the same replication rules multiple times in parallel.",
"EMAIL": "Deve ser um endereço de e-mail válido como nome@exemplo.com.",
"USER_NAME": "Não pode conter caracteres especiais. Tamanho máximo de 255 caracteres.",
"FULL_NAME": "Tamanho máximo de 20 caracteres.",
@@ -558,6 +559,7 @@
"ALLOWED_CHARACTERS": "Símbolos permitidos",
"TOTAL": "Total",
"OVERRIDE": "Sobrescrever",
+ "SINGLE_ACTIVE_REPLICATION": "Single active replication",
"ENABLED_RULE": "Habiltar regra",
"OVERRIDE_INFO": "Sobrescrever",
"CURRENT": "atual",
diff --git a/src/portal/src/i18n/lang/tr-tr-lang.json b/src/portal/src/i18n/lang/tr-tr-lang.json
index 8e30de6eefd..947a662fb7d 100644
--- a/src/portal/src/i18n/lang/tr-tr-lang.json
+++ b/src/portal/src/i18n/lang/tr-tr-lang.json
@@ -76,6 +76,7 @@
"PULL_BASED": "Kaynakları uzak kayıt defterinden yerel Harbora çekin.",
"DESTINATION_NAMESPACE": "Hedef ad alanını belirtin. Boşsa, kaynaklar, kaynak ile aynı ad alanına yerleştirilir.",
"OVERRIDE": "Aynı adı taşıyan bir kaynak varsa, hedefteki kaynakları geçersiz kılmayacağınızı belirtin.",
+ "SINGLE_ACTIVE_REPLICATION": "Specify whether to defer execution until the previous active execution finishes, avoiding the execution of the same replication rules multiple times in parallel.",
"EMAIL": "E-posta, ad@example.com gibi geçerli bir e-posta adresi olmalıdır.",
"USER_NAME": "Özel karakterler içeremez ve maksimum uzunluk 255 karakter olmalıdır.",
"FULL_NAME": "Maksimum uzunluk 20 karakter olmalıdır.",
@@ -560,6 +561,7 @@
"ALLOWED_CHARACTERS": "İzin verilen özel karakterler",
"TOTAL": "Toplam",
"OVERRIDE": "Geçersiz Kıl",
+ "SINGLE_ACTIVE_REPLICATION": "Single active replication",
"ENABLED_RULE": "Kuralı etkinleştir",
"OVERRIDE_INFO": "Geçersiz Kıl",
"OPERATION": "Operasyon",
diff --git a/src/portal/src/i18n/lang/zh-cn-lang.json b/src/portal/src/i18n/lang/zh-cn-lang.json
index ef941a46f34..9e42bf1b123 100644
--- a/src/portal/src/i18n/lang/zh-cn-lang.json
+++ b/src/portal/src/i18n/lang/zh-cn-lang.json
@@ -76,6 +76,7 @@
"PULL_BASED": "把资源由远端仓库拉取到本地Harbor。",
"DESTINATION_NAMESPACE": "指定目标名称空间。如果不填,资源会被放到和源相同的名称空间下。",
"OVERRIDE": "如果存在具有相同名称的资源,请指定是否覆盖目标上的资源。",
+ "SINGLE_ACTIVE_REPLICATION": "Specify whether to defer execution until the previous active execution finishes, avoiding the execution of the same replication rules multiple times in parallel.",
"EMAIL": "请使用正确的邮箱地址,比如name@example.com。",
"USER_NAME": "不能包含特殊字符且长度不能超过255。",
"FULL_NAME": "长度不能超过20。",
@@ -558,6 +559,7 @@
"ALLOWED_CHARACTERS": "允许的特殊字符",
"TOTAL": "总数",
"OVERRIDE": "覆盖",
+ "SINGLE_ACTIVE_REPLICATION": "Single active replication",
"ENABLED_RULE": "启用规则",
"OVERRIDE_INFO": "覆盖",
"CURRENT": "当前仓库",
diff --git a/src/server/v2.0/handler/replication.go b/src/server/v2.0/handler/replication.go
index c5c700f679b..1392dbd9d78 100644
--- a/src/server/v2.0/handler/replication.go
+++ b/src/server/v2.0/handler/replication.go
@@ -113,6 +113,10 @@ func (r *replicationAPI) CreateReplicationPolicy(ctx context.Context, params ope
policy.CopyByChunk = *params.Policy.CopyByChunk
}
+ if params.Policy.SingleActiveReplication != nil {
+ policy.SingleActiveReplication = *params.Policy.SingleActiveReplication
+ }
+
id, err := r.ctl.CreatePolicy(ctx, policy)
if err != nil {
return r.SendError(ctx, err)
@@ -181,6 +185,10 @@ func (r *replicationAPI) UpdateReplicationPolicy(ctx context.Context, params ope
policy.CopyByChunk = *params.Policy.CopyByChunk
}
+ if params.Policy.SingleActiveReplication != nil {
+ policy.SingleActiveReplication = *params.Policy.SingleActiveReplication
+ }
+
if err := r.ctl.UpdatePolicy(ctx, policy); err != nil {
return r.SendError(ctx, err)
}
@@ -446,6 +454,7 @@ func convertReplicationPolicy(policy *repctlmodel.Policy) *models.ReplicationPol
Speed: &policy.Speed,
UpdateTime: strfmt.DateTime(policy.UpdateTime),
CopyByChunk: &policy.CopyByChunk,
+ SingleActiveReplication: &policy.SingleActiveReplication,
}
if policy.SrcRegistry != nil {
p.SrcRegistry = convertRegistry(policy.SrcRegistry)
@@ -535,6 +544,8 @@ func convertExecution(execution *replication.Execution) *models.ReplicationExecu
exec.Status = "Stopped"
case job.ErrorStatus.String():
exec.Status = "Failed"
+ case job.SkippedStatus.String():
+ exec.Status = "Skipped"
}
return exec
diff --git a/src/testing/pkg/task/execution_manager.go b/src/testing/pkg/task/execution_manager.go
index 4c28f9eb183..8fc834f89b9 100644
--- a/src/testing/pkg/task/execution_manager.go
+++ b/src/testing/pkg/task/execution_manager.go
@@ -213,6 +213,24 @@ func (_m *ExecutionManager) MarkError(ctx context.Context, id int64, message str
return r0
}
+// MarkSkipped provides a mock function with given fields: ctx, id, message
+func (_m *ExecutionManager) MarkSkipped(ctx context.Context, id int64, message string) error {
+ ret := _m.Called(ctx, id, message)
+
+ if len(ret) == 0 {
+ panic("no return value specified for MarkSkipped")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64, string) error); ok {
+ r0 = rf(ctx, id, message)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
// Stop provides a mock function with given fields: ctx, id
func (_m *ExecutionManager) Stop(ctx context.Context, id int64) error {
ret := _m.Called(ctx, id)
diff --git a/tests/apitests/python/test_system_permission.py b/tests/apitests/python/test_system_permission.py
index 1aa3f5d9f6b..ce097d5ab14 100644
--- a/tests/apitests/python/test_system_permission.py
+++ b/tests/apitests/python/test_system_permission.py
@@ -151,7 +151,8 @@ def call(self):
"deletion": False,
"override": True,
"speed": -1,
- "copy_by_chunk": False
+ "copy_by_chunk": False,
+ "single_active_replication": False
}
create_replication_policy = Permission("{}/replication/policies".format(harbor_base_url), "POST", 201, replication_policy_payload, "id", id_from_header=True)
list_replication_policy = Permission("{}/replication/policies".format(harbor_base_url), "GET", 200, replication_policy_payload)
@@ -204,7 +205,8 @@ def call(self):
"deletion": False,
"override": True,
"speed": -1,
- "copy_by_chunk": False
+ "copy_by_chunk": False,
+ "single_active_replication": False
}
response = requests.post("{}/replication/policies".format(harbor_base_url), data=json.dumps(replication_policy_payload), verify=False, auth=(admin_user_name, admin_password), headers={"Content-Type": "application/json"})
replication_policy_id = int(response.headers["Location"].split("/")[-1])