-
Notifications
You must be signed in to change notification settings - Fork 7
/
gc_policy.go
264 lines (241 loc) · 9.27 KB
/
gc_policy.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
/*******************************************************************************
*
* Copyright 2021 SAP SE
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You should have received a copy of the License along with this
* program. If not, you may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*******************************************************************************/
package keppel
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/sapcc/go-bits/regexpext"
)
// GCPolicy is a policy enabling optional garbage collection runs in an account.
// It is stored in serialized form in the GCPoliciesJSON field of type Account.
type GCPolicy struct {
RepositoryRx regexpext.BoundedRegexp `json:"match_repository" yaml:"match_repository"`
NegativeRepositoryRx regexpext.BoundedRegexp `json:"except_repository,omitempty" yaml:"except_repository,omitempty"`
TagRx regexpext.BoundedRegexp `json:"match_tag,omitempty" yaml:"match_tag,omitempty"`
NegativeTagRx regexpext.BoundedRegexp `json:"except_tag,omitempty" yaml:"except_tag,omitempty"`
OnlyUntagged bool `json:"only_untagged,omitempty" yaml:"only_untagged,omitempty"`
TimeConstraint *GCTimeConstraint `json:"time_constraint,omitempty" yaml:"time_constraint,omitempty"`
Action string `json:"action" yaml:"action"`
}
// GCTimeConstraint appears in type GCPolicy.
type GCTimeConstraint struct {
FieldName string `json:"on" yaml:"on"`
OldestCount uint64 `json:"oldest,omitempty" yaml:"oldest,omitempty"`
NewestCount uint64 `json:"newest,omitempty" yaml:"newest,omitempty"`
MinAge Duration `json:"older_than,omitempty" yaml:"older_than,omitempty"`
MaxAge Duration `json:"newer_than,omitempty" yaml:"newer_than,omitempty"`
}
// MatchesRepository evaluates the repository regexes in this policy.
func (g GCPolicy) MatchesRepository(repoName string) bool {
//NOTE: NegativeRepositoryRx takes precedence and is thus evaluated first.
if g.NegativeRepositoryRx != "" && g.NegativeRepositoryRx.MatchString(repoName) {
return false
}
return g.RepositoryRx.MatchString(repoName)
}
// MatchesTags evaluates the tag regexes in this policy for a complete set of
// tag names belonging to a single manifest.
func (g GCPolicy) MatchesTags(tagNames []string) bool {
if g.OnlyUntagged && len(tagNames) > 0 {
return false
}
//NOTE: NegativeTagRx takes precedence over TagRx and is thus evaluated first.
if g.NegativeTagRx != "" {
for _, tagName := range tagNames {
if g.NegativeTagRx.MatchString(tagName) {
return false
}
}
}
if g.TagRx != "" {
for _, tagName := range tagNames {
if g.TagRx.MatchString(tagName) {
return true
}
}
}
// if we did not have any matching tags, the match is successful unless we
// required a positive tag match
return g.TagRx == ""
}
// MatchesTimeConstraint evaluates the time constraint in this policy for the
// given manifest. A full list of all manifests in this repo must be supplied in
// order to evaluate "newest" and "oldest" time constraints. The final argument
// must be equivalent to time.Now(); it is given explicitly to allow for
// simulated clocks during unit tests.
func (g GCPolicy) MatchesTimeConstraint(manifest Manifest, allManifestsInRepo []Manifest, now time.Time) bool {
// do we have a time constraint at all?
if g.TimeConstraint == nil {
return true
}
tc := *g.TimeConstraint
if tc.FieldName == "" {
return true
}
// select the right time field
var getTime func(Manifest) time.Time
switch tc.FieldName {
case "pushed_at":
getTime = func(m Manifest) time.Time { return m.PushedAt }
case "last_pulled_at":
getTime = func(m Manifest) time.Time {
if m.LastPulledAt == nil {
return time.Unix(0, 0)
}
return *m.LastPulledAt
}
default:
panic(fmt.Sprintf("unexpected GC policy time constraint target: %q (why was this not caught by Validate!?)", tc.FieldName))
}
getAge := func(m Manifest) Duration {
return Duration(now.Sub(getTime(m)))
}
// option 1: simple threshold-based time constraint
if tc.MinAge != 0 {
return getAge(manifest) >= tc.MinAge
}
if tc.MaxAge != 0 {
return getAge(manifest) <= tc.MaxAge
}
// option 2: order-based time constraint (we can skip all the sorting logic if we have less manifests than we want to match)
if tc.OldestCount != 0 && uint64(len(allManifestsInRepo)) < tc.OldestCount {
return true
}
if tc.NewestCount != 0 && uint64(len(allManifestsInRepo)) < tc.NewestCount {
return true
}
// sort manifests by the right time field
sort.Slice(allManifestsInRepo, func(i, j int) bool {
lhs := allManifestsInRepo[i]
rhs := allManifestsInRepo[j]
return getAge(lhs) > getAge(rhs)
})
// which manifests match? (note that we already know that
// len(allManifestsInRepo) is larger than the amount we want to match, so we
// don't have to check bounds any further)
var matchingManifests []Manifest
switch {
case tc.OldestCount != 0:
matchingManifests = allManifestsInRepo[:tc.OldestCount]
case tc.NewestCount != 0:
matchingManifests = allManifestsInRepo[uint64(len(allManifestsInRepo))-tc.NewestCount:]
default:
panic("unexpected GC policy time constraint: no threshold configured (why was this not caught by Validate!?)")
}
for _, m := range matchingManifests {
if m.Digest == manifest.Digest {
return true
}
}
return false
}
// Validate returns an error if this policy is invalid.
func (g GCPolicy) Validate() error {
if g.RepositoryRx == "" {
return errors.New(`GC policy must have the "match_repository" attribute`)
}
if g.OnlyUntagged {
if g.TagRx != "" {
return errors.New(`GC policy cannot have the "match_tag" attribute when "only_untagged" is set`)
}
if g.NegativeTagRx != "" {
return errors.New(`GC policy cannot have the "except_tag" attribute when "only_untagged" is set`)
}
}
if g.TimeConstraint != nil {
tc := *g.TimeConstraint
var tcFilledFields []string
if tc.OldestCount != 0 {
tcFilledFields = append(tcFilledFields, `"oldest"`)
if g.Action == "delete" {
return fmt.Errorf(`GC policy with action %q cannot set the "time_constraint.oldest" attribute`, g.Action)
}
}
if tc.NewestCount != 0 {
tcFilledFields = append(tcFilledFields, `"newest"`)
if g.Action == "delete" {
return fmt.Errorf(`GC policy with action %q cannot set the "time_constraint.newest" attribute`, g.Action)
}
}
if tc.MinAge != 0 {
tcFilledFields = append(tcFilledFields, `"older_than"`)
}
if tc.MaxAge != 0 {
tcFilledFields = append(tcFilledFields, `"newer_than"`)
}
switch tc.FieldName {
case "":
return errors.New(`GC policy time constraint must have the "on" attribute`)
case "last_pulled_at", "pushed_at":
if len(tcFilledFields) == 0 {
return errors.New(`GC policy time constraint needs to set at least one attribute other than "on"`)
}
if len(tcFilledFields) > 1 {
return fmt.Errorf(`GC policy time constraint cannot set all these attributes at once: %s`, strings.Join(tcFilledFields, ", "))
}
default:
return fmt.Errorf(`%q is not a valid target for a GC policy time constraint`, tc.FieldName)
}
}
switch g.Action {
case "delete", "protect":
// valid
return nil
case "":
return errors.New(`GC policy must have the "action" attribute`)
default:
return fmt.Errorf("%q is not a valid action for a GC policy", g.Action)
}
}
// ParseGCPolicies parses the GC policies for the given account.
func (a Account) ParseGCPolicies() ([]GCPolicy, error) {
if a.GCPoliciesJSON == "" || a.GCPoliciesJSON == "[]" {
return nil, nil
}
var policies []GCPolicy
err := json.Unmarshal([]byte(a.GCPoliciesJSON), &policies)
return policies, err
}
// GCStatus documents the current status of a manifest with regard to image GC.
// It is stored in serialized form in the GCStatusJSON field of type Manifest.
//
// Since GCStatus objects describe images that currently exist in the DB, they
// only describe policy decisions that led to no cleanup.
type GCStatus struct {
// True if the manifest was uploaded less than 10 minutes ago and is therefore
// protected from GC.
ProtectedByRecentUpload bool `json:"protected_by_recent_upload,omitempty"`
// If a parent manifest references this manifest and thus protects it from GC,
// contains the parent manifest's digest.
ProtectedByParentManifest string `json:"protected_by_parent,omitempty"`
// If a policy with action "protect" applies to this image, contains the
// definition of the policy.
ProtectedByPolicy *GCPolicy `json:"protected_by_policy,omitempty"`
// If the image is not protected, contains all policies with action "delete"
// that could delete this image in the future.
RelevantPolicies []GCPolicy `json:"relevant_policies,omitempty"`
}
// IsProtected returns whether any of the ProtectedBy... fields is filled.
func (s GCStatus) IsProtected() bool {
return s.ProtectedByRecentUpload || s.ProtectedByParentManifest != "" || s.ProtectedByPolicy != nil
}