feature(tag_retention) add checkbox for user to control whether remove untagged image

Signed-off-by: Ziming Zhang <zziming@vmware.com>
This commit is contained in:
Ziming Zhang 2020-03-03 11:11:30 +08:00 committed by Ziming
parent 1d8389ab41
commit 8ffa79801b
23 changed files with 205 additions and 57 deletions

View File

@ -27,4 +27,4 @@ type Selector interface {
}
// Factory is factory method to return a selector implementation
type Factory func(decoration string, pattern string) Selector
type Factory func(decoration string, pattern string, extras string) Selector

View File

@ -15,6 +15,7 @@
package doublestar
import (
"encoding/json"
"github.com/bmatcuk/doublestar"
iselector "github.com/goharbor/harbor/src/internal/selector"
)
@ -43,6 +44,8 @@ type selector struct {
decoration string
// The pattern expression
pattern string
// whether match untagged
untagged bool
}
// Select candidates by regular expressions
@ -97,36 +100,55 @@ func (s *selector) Select(artifacts []*iselector.Candidate) (selected []*iselect
}
func (s *selector) tagSelectMatch(artifact *iselector.Candidate) (selected bool, err error) {
for _, t := range artifact.Tags {
matched, err := match(s.pattern, t)
if err != nil {
return false, err
}
if matched {
return true, nil
if len(artifact.Tags) > 0 {
for _, t := range artifact.Tags {
matched, err := match(s.pattern, t)
if err != nil {
return false, err
}
if matched {
return true, nil
}
}
return false, nil
}
return false, nil
return s.untagged, nil
}
func (s *selector) tagSelectExclude(artifact *iselector.Candidate) (selected bool, err error) {
for _, t := range artifact.Tags {
matched, err := match(s.pattern, t)
if err != nil {
return false, err
}
if !matched {
return true, nil
if len(artifact.Tags) > 0 {
for _, t := range artifact.Tags {
matched, err := match(s.pattern, t)
if err != nil {
return false, err
}
if !matched {
return true, nil
}
}
return false, nil
}
return false, nil
return !s.untagged, nil
}
// New is factory method for doublestar selector
func New(decoration string, pattern string) iselector.Selector {
func New(decoration string, pattern string, extras string) iselector.Selector {
untagged := true // default behavior for upgrade, active keep the untagged images
if decoration == Excludes {
untagged = false
}
if extras != "" {
var extraObj struct {
Untagged bool `json:"untagged"`
}
if err := json.Unmarshal([]byte(extras), &extraObj); err == nil {
untagged = extraObj.Untagged
}
}
return &selector{
decoration: decoration,
pattern: pattern,
untagged: untagged,
}
}

View File

@ -72,6 +72,16 @@ func (suite *RegExpSelectorTestSuite) SetupSuite() {
CreationTime: time.Now().Unix() - 7200,
Labels: []string{"label1", "label4", "label5"},
},
{
NamespaceID: 3,
Namespace: "library",
Repository: "special",
Tags: nil, // untagged
Kind: iselector.Image,
PushedTime: time.Now().Unix() - 3600,
PulledTime: time.Now().Unix(),
CreationTime: time.Now().Unix() - 7200,
},
}
}
@ -100,6 +110,15 @@ func (suite *RegExpSelectorTestSuite) TestTagMatches() {
assert.Condition(suite.T(), func() bool {
return expect([]string{"redis:4.0", "redis:4.1"}, selected)
})
tagMatches3 := New(Matches, "4.*", "")
selected, err = tagMatches3.Select(suite.artifacts)
require.NoError(suite.T(), err)
assert.Equal(suite.T(), 3, len(selected))
assert.Condition(suite.T(), func() bool {
return expect([]string{"redis:4.0", "redis:4.1"}, selected)
})
}
// TestTagExcludes tests the tag `excludes` case
@ -107,15 +126,23 @@ func (suite *RegExpSelectorTestSuite) TestTagExcludes() {
tagExcludes := &selector{
decoration: Excludes,
pattern: "{latest,4.*}",
untagged: true,
}
selected, err := tagExcludes.Select(suite.artifacts)
require.NoError(suite.T(), err)
assert.Equal(suite.T(), 0, len(selected))
tagExcludes1 := New(Excludes, "{latest,4.*}", "")
selected, err = tagExcludes1.Select(suite.artifacts)
require.NoError(suite.T(), err)
assert.Equal(suite.T(), 1, len(selected))
tagExcludes2 := &selector{
decoration: Excludes,
pattern: "4.*",
untagged: true,
}
selected, err = tagExcludes2.Select(suite.artifacts)
@ -162,7 +189,7 @@ func (suite *RegExpSelectorTestSuite) TestRepoExcludes() {
selected, err := repoExcludes.Select(suite.artifacts)
require.NoError(suite.T(), err)
assert.Equal(suite.T(), 1, len(selected))
assert.Equal(suite.T(), 2, len(selected))
assert.Condition(suite.T(), func() bool {
return expect([]string{"harbor:latest"}, selected)
})
@ -174,7 +201,7 @@ func (suite *RegExpSelectorTestSuite) TestRepoExcludes() {
selected, err = repoExcludes2.Select(suite.artifacts)
require.NoError(suite.T(), err)
assert.Equal(suite.T(), 2, len(selected))
assert.Equal(suite.T(), 3, len(selected))
assert.Condition(suite.T(), func() bool {
return expect([]string{"redis:4.0", "redis:4.1"}, selected)
})
@ -189,7 +216,7 @@ func (suite *RegExpSelectorTestSuite) TestNSMatches() {
selected, err := repoMatches.Select(suite.artifacts)
require.NoError(suite.T(), err)
assert.Equal(suite.T(), 1, len(selected))
assert.Equal(suite.T(), 2, len(selected))
assert.Condition(suite.T(), func() bool {
return expect([]string{"harbor:latest"}, selected)
})
@ -228,7 +255,7 @@ func (suite *RegExpSelectorTestSuite) TestNSExcludes() {
selected, err = repoExcludes2.Select(suite.artifacts)
require.NoError(suite.T(), err)
assert.Equal(suite.T(), 1, len(selected))
assert.Equal(suite.T(), 2, len(selected))
assert.Condition(suite.T(), func() bool {
return expect([]string{"harbor:latest"}, selected)
})

View File

@ -69,7 +69,7 @@ func Register(kind string, decorations []string, factory selector.Factory) {
}
// Get selector with the provided kind and decoration
func Get(kind, decoration, pattern string) (selector.Selector, error) {
func Get(kind, decoration, pattern, extras string) (selector.Selector, error) {
if len(kind) == 0 || len(decoration) == 0 {
return nil, errors.New("empty selector kind or decoration")
}
@ -83,7 +83,7 @@ func Get(kind, decoration, pattern string) (selector.Selector, error) {
for _, dec := range item.Meta.Decorations {
if dec == decoration {
factory := item.Factory
return factory(decoration, pattern), nil
return factory(decoration, pattern, extras), nil
}
}

View File

@ -49,7 +49,7 @@ func (s *selector) Select(artifacts []*iselector.Candidate) (selected []*iselect
}
// New is factory method for list selector
func New(decoration string, pattern string) iselector.Selector {
func New(decoration string, pattern string, extras string) iselector.Selector {
labels := make([]string, 0)
if len(pattern) > 0 {
labels = append(labels, strings.Split(pattern, ",")...)

View File

@ -33,7 +33,7 @@ func (rm *Matcher) Match(pid int64, c iselector.Candidate) (bool, error) {
}
repositorySelector := repositorySelectors[0]
selector, err := index.Get(repositorySelector.Kind, repositorySelector.Decoration,
repositorySelector.Pattern)
repositorySelector.Pattern, "")
if err != nil {
return false, err
}
@ -53,7 +53,7 @@ func (rm *Matcher) Match(pid int64, c iselector.Candidate) (bool, error) {
}
tagSelector := r.TagSelectors[0]
selector, err = index.Get(tagSelector.Kind, tagSelector.Decoration,
tagSelector.Pattern)
tagSelector.Pattern, "")
if err != nil {
return false, err
}

View File

@ -137,7 +137,7 @@ func (l *launcher) Launch(ply *policy.Metadata, executionID int64, isDryRun bool
// filter projects according to the project selectors
for _, projectSelector := range rule.ScopeSelectors["project"] {
selector, err := index.Get(projectSelector.Kind, projectSelector.Decoration,
projectSelector.Pattern)
projectSelector.Pattern, "")
if err != nil {
return 0, launcherError(err)
}
@ -166,7 +166,7 @@ func (l *launcher) Launch(ply *policy.Metadata, executionID int64, isDryRun bool
// filter repositories according to the repository selectors
for _, repositorySelector := range rule.ScopeSelectors["repository"] {
selector, err := index.Get(repositorySelector.Kind, repositorySelector.Decoration,
repositorySelector.Pattern)
repositorySelector.Pattern, repositorySelector.Extras)
if err != nil {
return 0, launcherError(err)
}

View File

@ -93,8 +93,8 @@ func (suite *ProcessorTestSuite) TestProcess() {
params = append(params, &alg.Parameter{
Evaluator: lastx.New(lastxParams),
Selectors: []selector.Selector{
doublestar.New(doublestar.Matches, "*dev*"),
label.New(label.With, "L1,L2"),
doublestar.New(doublestar.Matches, "*dev*", ""),
label.New(label.With, "L1,L2", ""),
},
Performer: perf,
})
@ -104,7 +104,7 @@ func (suite *ProcessorTestSuite) TestProcess() {
params = append(params, &alg.Parameter{
Evaluator: latestps.New(latestKParams),
Selectors: []selector.Selector{
label.New(label.With, "L3"),
label.New(label.With, "L3", ""),
},
Performer: perf,
})
@ -134,8 +134,8 @@ func (suite *ProcessorTestSuite) TestProcess2() {
params = append(params, &alg.Parameter{
Evaluator: always.New(alwaysParams),
Selectors: []selector.Selector{
doublestar.New(doublestar.Matches, "latest"),
label.New(label.With, ""),
doublestar.New(doublestar.Matches, "latest", ""),
label.New(label.With, "", ""),
},
Performer: perf,
})

View File

@ -78,7 +78,7 @@ func (bb *basicBuilder) Build(policy *lwp.Metadata, isDryRun bool) (alg.Processo
sl := make([]selector.Selector, 0)
for _, s := range r.TagSelectors {
sel, err := index2.Get(s.Kind, s.Decoration, s.Pattern)
sel, err := index2.Get(s.Kind, s.Decoration, s.Pattern, s.Extras)
if err != nil {
return nil, errors.Wrap(err, "get selector by metadata")
}

View File

@ -75,6 +75,9 @@ type Selector struct {
// Param for the selector
Pattern string `json:"pattern" valid:"Required"`
// Extras for other settings
Extras string `json:"extras"`
}
// Parameters of rule, indexed by the key

View File

@ -87,7 +87,7 @@ export class ProjectDetailComponent implements OnInit, AfterViewInit, OnDestroy
{
linkName: "tag-strategy",
tabLinkInOverflow: false,
showTabName: "PROJECT_DETAIL.TAG_STRATEGY",
showTabName: "PROJECT_DETAIL.POLICY",
permissions: () => this.hasTagRetentionPermission
},
{

View File

@ -10,7 +10,7 @@
<div class="clr-col-4">
<span>{{'TAG_RETENTION.IN_REPOSITORIES' | translate}}</span>
</div>
<div class="clr-col-3">
<div class="clr-col-2">
<div class="clr-select-wrapper w-100">
<select [(ngModel)]="repoSelect" class="clr-select w-100">
<option *ngFor="let d of metadata?.scope_selectors[0]?.decorations"
@ -18,7 +18,7 @@
</select>
</div>
</div>
<div class="clr-col-5">
<div class="clr-col-6">
<div class="w-100">
<input id="repos" required [(ngModel)]="repositories" class="clr-input w-100">
</div>
@ -57,12 +57,12 @@
</div>
</div>
</div>
<div class="height-72">
<div class="height-85">
<div class="clr-row">
<div class="clr-col-4">
<label>{{'TAG_RETENTION.TAGS' | translate}}</label>
</div>
<div class="clr-col-3">
<div class="clr-col-2">
<div class="clr-select-wrapper w-100">
<select [(ngModel)]="tagsSelect" class="clr-select w-100">
<option *ngFor="let d of metadata?.tag_selectors[0]?.decorations"
@ -70,16 +70,22 @@
</select>
</div>
</div>
<div class="clr-col-5">
<div class="clr-col-3">
<div class="w-100">
<input id="tags" required [(ngModel)]="tagsInput" class="clr-input w-100">
</div>
</div>
<div class="clr-col-3 p-0 font-size-13">
<div class="w-100 untagged">
<label for="untagged">{{'TAG_RETENTION.INCLUDE_UNTAGGED' | translate}}</label>
<input type="checkbox" [(ngModel)]="untagged" name="untagged" id="untagged" class="clr-input w-100" clrCheckbox />
</div>
</div>
</div>
<div class="clr-row">
<div class="clr-col-4"></div>
<div class="clr-col-8">
<span>{{'TAG_RETENTION.TAG_SEPARATOR' | translate}}</span>
<span class="tootip">{{'TAG_RETENTION.TAG_SEPARATOR' | translate}}</span>
</div>
</div>
</div>

View File

@ -11,7 +11,25 @@
.height-72 {
height: 72px;
}
.height-85 {
height: 85px;
}
.display-none {
display: none
}
display: none;
}
.untagged {
display: flex;
justify-content: space-around;
}
.modal-body {
overflow-y: hidden;
}
.tootip {
display: block;
line-height: .9rem;
}
.font-size-13 {
font-size: 13px;
}

View File

@ -123,6 +123,24 @@ export class AddRuleComponent implements OnInit, OnDestroy {
get tagsInput() {
return this.rule.tag_selectors[0].pattern.replace(/[{}]/g, "");
}
set untagged(untagged) {
let extras = JSON.parse(this.rule.tag_selectors[0].extras);
extras.untagged = untagged;
this.rule.tag_selectors[0].extras = JSON.stringify(extras);
}
get untagged() {
if (this.rule.tag_selectors[0] && this.rule.tag_selectors[0].extras) {
let extras = JSON.parse(this.rule.tag_selectors[0].extras);
if (extras.untagged !== undefined) {
return extras.untagged;
}
return false;
} else {
return false;
}
}
get labelsSelect() {
return this.rule.tag_selectors[1].decoration;
@ -227,6 +245,9 @@ export class AddRuleComponent implements OnInit, OnDestroy {
if (this.rule.tag_selectors[0].decoration !== rule.tag_selectors[0].decoration) {
return false;
}
if (this.rule.tag_selectors[0].extras !== rule.tag_selectors[0].extras) {
return false;
}
return this.rule.tag_selectors[0].pattern === rule.tag_selectors[0].pattern;
}

View File

@ -96,6 +96,7 @@ export class Rule extends BaseRule {
constructor() {
super();
this.params = {};
this.tag_selectors[0].extras = JSON.stringify({untagged: true});
}
}
@ -103,6 +104,7 @@ export class Selector {
kind: string;
decoration: string;
pattern: string;
extras?: string;
}
export class Param {

View File

@ -44,6 +44,9 @@
<span>{{'TAG_RETENTION.LOWER_TAGS' | translate}}</span>
<span>{{getI18nKey(rule?.tag_selectors[0]?.decoration)|translate}}</span>
<span>{{formatPattern(rule?.tag_selectors[0]?.pattern)}}</span>
<span class="color-97">{{ showUntagged(rule?.tag_selectors[0]?.extras) ? ('TAG_RETENTION.WITH_CONDITION' | translate) :''}}</span>
<span>{{ showUntagged(rule?.tag_selectors[0]?.extras) ? ( 'TAG_RETENTION.UNTAGGED' | translate ) : ''}}</span>
<ng-container *ngIf="rule?.tag_selectors[1]?.pattern && rule?.tag_selectors[1]?.pattern">
<span class="color-97">{{'TAG_RETENTION.AND' | translate}}</span>
<span>{{'TAG_RETENTION.LOWER_LABELS' | translate}}</span>

View File

@ -37,6 +37,10 @@ const SCHEDULE_TYPE = {
HOURLY: "Hourly",
CUSTOM: "Custom"
};
const DECORATION = {
MATCHES: "matches",
EXCLUDES: "excludes",
};
const RUNNING: string = "Running";
const PENDING: string = "pending";
const TIMEOUT: number = 5000;
@ -176,6 +180,8 @@ export class TagRetentionComponent implements OnInit {
if (!item.params) {
item.params = {};
}
this.setRuleUntagged(item);
});
}
this.retention = response;
@ -225,7 +231,28 @@ export class TagRetentionComponent implements OnInit {
this.errorHandler.error(error);
});
}
setRuleUntagged(rule) {
if (!rule.tag_selectors[0].extras) {
if (rule.tag_selectors[0].decoration === DECORATION.MATCHES) {
rule.tag_selectors[0].extras = JSON.stringify({untagged: true});
}
if (rule.tag_selectors[0].decoration === DECORATION.EXCLUDES) {
rule.tag_selectors[0].extras = JSON.stringify({untagged: false});
}
} else {
let extras = JSON.parse(rule.tag_selectors[0].extras);
if (extras.untagged === undefined) {
if (rule.tag_selectors[0].decoration === DECORATION.MATCHES) {
extras.untagged = true;
}
if (rule.tag_selectors[0].decoration === DECORATION.EXCLUDES) {
extras.untagged = false;
}
rule.tag_selectors[0].extras = JSON.stringify(extras);
}
}
}
openAddRule() {
this.addRuleComponent.open();
this.addRuleComponent.isAdd = true;
@ -450,4 +477,11 @@ export class TagRetentionComponent implements OnInit {
this.refreshList();
}
/**
*
* @param extras Json string
*/
showUntagged(extras) {
return JSON.parse(extras).untagged;
}
}

View File

@ -251,7 +251,7 @@
"ROBOT_ACCOUNTS": "Robot Accounts",
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "Tag Immutability",
"TAG_STRATEGY": "Tag Strategy"
"POLICY": "Policy"
},
"PROJECT_CONFIG": {
"REGISTRY": "Project registry",
@ -1250,9 +1250,11 @@
"IN_REPOSITORIES": "For the repositories",
"REP_SEPARATOR": "Enter multiple comma separated repos,repo*,or **",
"TAGS": "Tags",
"UNTAGGED": " untagged",
"INCLUDE_UNTAGGED": " untagged artifacts",
"MATCHES_TAGS": "Matches tags",
"MATCHES_EXCEPT_TAGS": "Matches except tags",
"TAG_SEPARATOR": "Enter multiple comma separated tags,tag*,or **",
"TAG_SEPARATOR": "Enter multiple comma separated tags, tag*, or **. Optionally include all untagged artifacts when applying the including or excluding selector by checking the box above.",
"LABELS": "Labels",
"MATCHES_LABELS": "Matches Labels",
"MATCHES_EXCEPT_LABELS": "Matches except Labels",
@ -1309,7 +1311,7 @@
"IN_REPOSITORIES": "For the repositories",
"REP_SEPARATOR": "Enter multiple comma separated repos,repo*,or **",
"TAGS": "Tags",
"TAG_SEPARATOR": "Enter multiple comma separated tags,tag*,or **",
"TAG_SEPARATOR": "Enter multiple comma separated tags,tag*,or **.",
"EDIT_TITLE": "Edit Tag Immutability Rule",
"EXC": " excluding ",
"MAT": " matching ",

View File

@ -252,7 +252,7 @@
"ROBOT_ACCOUNTS": "Robot Accounts",
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "Tag Immutability",
"TAG_STRATEGY": "Tag Strategy"
"POLICY": "Policy"
},
"PROJECT_CONFIG": {
"REGISTRY": "Registro de proyectos",
@ -1247,9 +1247,11 @@
"IN_REPOSITORIES": "For the repositories",
"REP_SEPARATOR": "Enter multiple comma separated repos,repo*,or **",
"TAGS": "Tags",
"INCLUDE_UNTAGGED": " untagged artifacts",
"UNTAGGED": " untagged",
"MATCHES_TAGS": "Matches tags",
"MATCHES_EXCEPT_TAGS": "Matches except tags",
"TAG_SEPARATOR": "Enter multiple comma separated tags,tag*,or **",
"TAG_SEPARATOR": "Enter multiple comma separated tags, tag*, or **. Optionally include all untagged artifacts when applying the including or excluding selector by checking the box above.",
"LABELS": "Labels",
"MATCHES_LABELS": "Matches Labels",
"MATCHES_EXCEPT_LABELS": "Matches except Labels",

View File

@ -245,7 +245,7 @@
"ROBOT_ACCOUNTS": "Robot Accounts",
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "Tag Immutability",
"TAG_STRATEGY": "Tag Strategy"
"POLICY": "Policy"
},
"PROJECT_CONFIG": {
"REGISTRY": "Dépôt du Projet",
@ -1217,9 +1217,11 @@
"IN_REPOSITORIES": "For the repositories",
"REP_SEPARATOR": "Enter multiple comma separated repos,repo*,or **",
"TAGS": "Tags",
"INCLUDE_UNTAGGED": " untagged artifacts",
"UNTAGGED": " untagged",
"MATCHES_TAGS": "Matches tags",
"MATCHES_EXCEPT_TAGS": "Matches except tags",
"TAG_SEPARATOR": "Enter multiple comma separated tags,tag*,or **",
"TAG_SEPARATOR": "Enter multiple comma separated tags, tag*, or **. Optionally include all untagged artifacts when applying the including or excluding selector by checking the box above.",
"LABELS": "Labels",
"MATCHES_LABELS": "Matches Labels",
"MATCHES_EXCEPT_LABELS": "Matches except Labels",

View File

@ -249,7 +249,7 @@
"ROBOT_ACCOUNTS": "Robot Accounts",
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "Tag Immutability",
"TAG_STRATEGY": "Tag Strategy"
"POLICY": "Policy"
},
"PROJECT_CONFIG": {
"REGISTRY": "Registro do Projeto",
@ -1245,9 +1245,11 @@
"IN_REPOSITORIES": "For the repositories",
"REP_SEPARATOR": "Enter multiple comma separated repos,repo*,or **",
"TAGS": "Tags",
"INCLUDE_UNTAGGED": " untagged artifacts",
"UNTAGGED": " untagged",
"MATCHES_TAGS": "Matches tags",
"MATCHES_EXCEPT_TAGS": "Matches except tags",
"TAG_SEPARATOR": "Enter multiple comma separated tags,tag*,or **",
"TAG_SEPARATOR": "Enter multiple comma separated tags, tag*, or **. Optionally include all untagged artifacts when applying the including or excluding selector by checking the box above.",
"LABELS": "Labels",
"MATCHES_LABELS": "Matches Labels",
"MATCHES_EXCEPT_LABELS": "Matches except Labels",

View File

@ -251,7 +251,7 @@
"ROBOT_ACCOUNTS": "Robot Hesapları",
"WEBHOOKS": "Ağ Kancaları",
"IMMUTABLE_TAG": "Tag Immutability",
"TAG_STRATEGY": "Tag Strategy"
"POLICY": "Policy"
},
"PROJECT_CONFIG": {
"REGISTRY": "Proje kaydı",
@ -1249,9 +1249,11 @@
"IN_REPOSITORIES": "Depolar için",
"REP_SEPARATOR": "Birden çok virgülle ayrılmış depolar, depo* veya ** girin",
"TAGS": "Etiketler",
"INCLUDE_UNTAGGED": " untagged artifacts",
"UNTAGGED": " untagged",
"MATCHES_TAGS": "Etiketleri eşleşir",
"MATCHES_EXCEPT_TAGS": "Etiketler hariç eşleşir",
"TAG_SEPARATOR": "Birden çok virgülle ayrılmış etiket, etiket * veya ** girin",
"TAG_SEPARATOR": "Enter multiple comma separated tags, tag*, or **. Optionally include all untagged artifacts when applying the including or excluding selector by checking the box above.",
"LABELS": "Etiketler",
"MATCHES_LABELS": "Eşleşen Etiketler",
"MATCHES_EXCEPT_LABELS": "Etiketler hariç eşleşmeler",

View File

@ -250,7 +250,7 @@
"ROBOT_ACCOUNTS": "机器人账户",
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "不可变的Tag",
"TAG_STRATEGY": "Tag 策略"
"POLICY": "策略"
},
"PROJECT_CONFIG": {
"REGISTRY": "项目仓库",
@ -1246,9 +1246,11 @@
"IN_REPOSITORIES": "应用到仓库",
"REP_SEPARATOR": "使用逗号分隔repos,repo*和**",
"TAGS": "Tags",
"INCLUDE_UNTAGGED": " 不含tag 的 artifacts",
"UNTAGGED": " 不含tag",
"MATCHES_TAGS": "匹配tags",
"MATCHES_EXCEPT_TAGS": "排除tags",
"TAG_SEPARATOR": "使用逗号分割tags,tag*和**",
"TAG_SEPARATOR": "输入多个逗号分隔的Tags, Tag*或**。可通过勾选将未加 Tag 的镜像作为此策略的一部分。",
"LABELS": "标签",
"MATCHES_LABELS": "匹配标签",
"MATCHES_EXCEPT_LABELS": "排除标签",