Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code | Sign in
(1855)

Side by Side Diff: worker/uniter/modes.go

Issue 6588053: uniter: integrate filter type
Patch Set: uniter: integrate filter type Created 5 years, 3 months ago
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments. Please Sign in to add in-line comments.
Jump to:
View unified diff | Download patch
OLDNEW
1 package uniter 1 package uniter
2 2
3 import ( 3 import (
4 "errors" 4 "errors"
5 "fmt" 5 "fmt"
6 "launchpad.net/juju-core/environs" 6 "launchpad.net/juju-core/environs"
7 "launchpad.net/juju-core/log" 7 "launchpad.net/juju-core/log"
8 "launchpad.net/juju-core/state" 8 "launchpad.net/juju-core/state"
9 "launchpad.net/juju-core/state/watcher"
10 "launchpad.net/juju-core/worker/uniter/charm" 9 "launchpad.net/juju-core/worker/uniter/charm"
11 "launchpad.net/juju-core/worker/uniter/hook" 10 "launchpad.net/juju-core/worker/uniter/hook"
12 "launchpad.net/tomb" 11 "launchpad.net/tomb"
13 ) 12 )
14 13
15 // Mode defines the signature of the functions that implement the possible 14 // Mode defines the signature of the functions that implement the possible
16 // states of a running Uniter. 15 // states of a running Uniter.
17 type Mode func(u *Uniter) (Mode, error) 16 type Mode func(u *Uniter) (Mode, error)
18 17
19 // ModeInit is the initial Uniter mode. 18 // ModeInit is the initial Uniter mode.
20 func ModeInit(u *Uniter) (next Mode, err error) { 19 func ModeInit(u *Uniter) (next Mode, err error) {
21 » defer errorContextf(&err, "ModeInit") 20 » defer modeContext("ModeInit", &next, &err)()
22 log.Printf("updating unit addresses") 21 log.Printf("updating unit addresses")
23 cfg, err := u.st.EnvironConfig() 22 cfg, err := u.st.EnvironConfig()
24 if err != nil { 23 if err != nil {
25 return nil, err 24 return nil, err
26 } 25 }
27 provider, err := environs.Provider(cfg.Type()) 26 provider, err := environs.Provider(cfg.Type())
28 if err != nil { 27 if err != nil {
29 return nil, err 28 return nil, err
30 } 29 }
31 if private, err := provider.PrivateAddress(); err != nil { 30 if private, err := provider.PrivateAddress(); err != nil {
32 return nil, err 31 return nil, err
33 } else if err = u.unit.SetPrivateAddress(private); err != nil { 32 } else if err = u.unit.SetPrivateAddress(private); err != nil {
34 return nil, err 33 return nil, err
35 } 34 }
36 if public, err := provider.PublicAddress(); err != nil { 35 if public, err := provider.PublicAddress(); err != nil {
37 return nil, err 36 return nil, err
38 } else if err = u.unit.SetPublicAddress(public); err != nil { 37 } else if err = u.unit.SetPublicAddress(public); err != nil {
39 return nil, err 38 return nil, err
40 } 39 }
41 return ModeContinue, nil 40 return ModeContinue, nil
42 } 41 }
43 42
44 // ModeContinue determines what action to take based on persistent uniter state. 43 // ModeContinue determines what action to take based on persistent uniter state.
45 func ModeContinue(u *Uniter) (next Mode, err error) { 44 func ModeContinue(u *Uniter) (next Mode, err error) {
46 » defer errorContextf(&err, "ModeContinue") 45 » defer modeContext("ModeContinue", &next, &err)()
47 46
48 // When no charm exists, install it. 47 // When no charm exists, install it.
49 log.Printf("reading uniter state from disk...")
50 s, err := u.sf.Read() 48 s, err := u.sf.Read()
51 if err == ErrNoStateFile { 49 if err == ErrNoStateFile {
52 log.Printf("charm is not deployed") 50 log.Printf("charm is not deployed")
53 sch, _, err := u.service.Charm() 51 sch, _, err := u.service.Charm()
54 if err != nil { 52 if err != nil {
55 return nil, err 53 return nil, err
56 } 54 }
57 return ModeInstalling(sch), nil 55 return ModeInstalling(sch), nil
58 } else if err != nil { 56 } else if err != nil {
59 return nil, err 57 return nil, err
60 } 58 }
61 59
62 // Filter out states not related to charm deployment. 60 // Filter out states not related to charm deployment.
63 switch s.Op { 61 switch s.Op {
64 case Abide: 62 case Abide:
65 log.Printf("continuing after %q hook", s.Hook.Kind) 63 log.Printf("continuing after %q hook", s.Hook.Kind)
66 » » if s.Hook.Kind == hook.Install { 64 » » switch s.Hook.Kind {
65 » » case hook.Install:
67 return ModeStarting, nil 66 return ModeStarting, nil
67 case hook.Stop:
68 return ModeTerminating, nil
68 } 69 }
69 » » return ModeStarted, nil 70 » » return ModeAbide, nil
70 case RunHook: 71 case RunHook:
71 if s.OpStep == Queued { 72 if s.OpStep == Queued {
72 » » » log.Printf("running queued %q hook", s.Hook.Kind) 73 » » » log.Printf("found queued %q hook", s.Hook.Kind)
73 » » » if err := u.runHook(*s.Hook); err != nil { 74 » » » return nil, u.runHook(*s.Hook)
74 » » » » if err != errHookFailed {
75 » » » » » return nil, err
76 » » » » }
77 » » » }
78 » » » return ModeContinue, nil
79 } 75 }
80 if s.OpStep == Done { 76 if s.OpStep == Done {
81 » » » log.Printf("recovering uncommitted %q hook", s.Hook.Kind ) 77 » » » log.Printf("found uncommitted %q hook", s.Hook.Kind)
82 » » » if err = u.commitHook(*s.Hook); err != nil { 78 » » » return nil, u.commitHook(*s.Hook)
83 » » » » return nil, err
84 » » » }
85 » » » return ModeContinue, nil
86 } 79 }
87 log.Printf("awaiting error resolution for %q hook", s.Hook.Kind) 80 log.Printf("awaiting error resolution for %q hook", s.Hook.Kind)
88 return ModeHookError, nil 81 return ModeHookError, nil
89 } 82 }
90 83
91 // Resume interrupted deployment operations. 84 // Resume interrupted deployment operations.
92 sch, err := u.st.Charm(s.CharmURL) 85 sch, err := u.st.Charm(s.CharmURL)
93 if err != nil { 86 if err != nil {
94 return nil, err 87 return nil, err
95 } 88 }
96 if s.Op == Install { 89 if s.Op == Install {
97 log.Printf("resuming charm install") 90 log.Printf("resuming charm install")
98 return ModeInstalling(sch), nil 91 return ModeInstalling(sch), nil
99 } else if s.Op == Upgrade { 92 } else if s.Op == Upgrade {
100 log.Printf("resuming charm upgrade") 93 log.Printf("resuming charm upgrade")
101 return ModeUpgrading(sch), nil 94 return ModeUpgrading(sch), nil
102 } 95 }
103 panic(fmt.Errorf("unhandled uniter operation %q", s.Op)) 96 panic(fmt.Errorf("unhandled uniter operation %q", s.Op))
104 } 97 }
105 98
106 // ModeInstalling is responsible for the initial charm deployment. 99 // ModeInstalling is responsible for the initial charm deployment.
107 func ModeInstalling(sch *state.Charm) Mode { 100 func ModeInstalling(sch *state.Charm) Mode {
108 return func(u *Uniter) (next Mode, err error) { 101 return func(u *Uniter) (next Mode, err error) {
109 » » defer errorContextf(&err, "ModeInstalling") 102 » » name := fmt.Sprintf("ModeInstalling %s", sch.URL())
110 » » if err = u.deploy(sch, Install); err != nil { 103 » » defer modeContext(name, &next, &err)()
111 » » » return nil, err 104 » » return nil, u.deploy(sch, Install)
112 » » }
113 » » return ModeContinue, nil
114 } 105 }
115 } 106 }
116 107
117 // ModeStarting is responsible for running the "start" hook. 108 // ModeUpgrading is responsible for upgrading the charm.
118 func ModeStarting(u *Uniter) (next Mode, err error) { 109 func ModeUpgrading(sch *state.Charm) Mode {
119 » defer errorContextf(&err, "ModeStarting") 110 » return func(u *Uniter) (next Mode, err error) {
120 » if err := u.unit.SetStatus(state.UnitInstalled, ""); err != nil { 111 » » name := fmt.Sprintf("ModeUpgrading %s", sch.URL())
112 » » defer modeContext(name, &next, &err)()
113 » » if err = u.deploy(sch, Upgrade); err == charm.ErrConflict {
114 » » » return ModeConflicted(sch), nil
115 » » }
121 return nil, err 116 return nil, err
122 } 117 }
123 » hi := hook.Info{Kind: hook.Start} 118 }
124 » if err := u.runHook(hi); err != nil && err != errHookFailed { 119
120 // ModeStarting runs the "start" hook.
121 func ModeStarting(u *Uniter) (next Mode, err error) {
122 » defer modeContext("ModeStarting", &next, &err)()
123 » if err = u.unit.SetStatus(state.UnitInstalled, ""); err != nil {
125 return nil, err 124 return nil, err
126 } 125 }
127 » return ModeContinue, nil 126 » return nil, u.runHook(hook.Info{Kind: hook.Start})
128 } 127 }
129 128
130 // ModeStarted is the Uniter's usual steady state. It watches for and responds t o: 129 // ModeStopping runs the "stop" hook.
130 func ModeStopping(u *Uniter) (next Mode, err error) {
131 » defer modeContext("ModeStopping", &next, &err)()
132 » return nil, u.runHook(hook.Info{Kind: hook.Stop})
133 }
134
135 // ModeTerminating marks the unit dead and returns ErrDead.
136 func ModeTerminating(u *Uniter) (next Mode, err error) {
137 » defer modeContext("ModeTerminating", &next, &err)()
138 » if err = u.unit.SetStatus(state.UnitStopped, ""); err != nil {
139 » » return nil, err
140 » }
141 » if err = u.unit.EnsureDead(); err != nil {
142 » » return nil, err
143 » }
144 » return nil, ErrDead
145 }
146
147 // ModeAbide is the Uniter's usual steady state. It watches for and responds to:
131 // * service configuration changes 148 // * service configuration changes
132 // * charm upgrade requests (not implemented) 149 // * charm upgrade requests
133 // * relation changes (not implemented) 150 // * relation changes (not implemented)
134 // * unit death (not implemented) 151 // * unit death
135 func ModeStarted(u *Uniter) (next Mode, err error) { 152 func ModeAbide(u *Uniter) (next Mode, err error) {
136 » defer errorContextf(&err, "ModeStarted") 153 » defer modeContext("ModeAbide", &next, &err)()
137 s, err := u.sf.Read() 154 s, err := u.sf.Read()
138 if err != nil { 155 if err != nil {
139 return nil, err 156 return nil, err
140 } 157 }
141 if s.Op != Abide { 158 if s.Op != Abide {
142 return nil, fmt.Errorf("insane uniter state: %#v", s) 159 return nil, fmt.Errorf("insane uniter state: %#v", s)
rog 2012/10/01 11:02:58 "insane uniter state; expected Abide, got %#v", s)
143 } 160 }
144 if err = u.unit.SetStatus(state.UnitStarted, ""); err != nil { 161 if err = u.unit.SetStatus(state.UnitStarted, ""); err != nil {
145 return nil, err 162 return nil, err
146 } 163 }
147 164
148 » // To begin with, only watch for config changes, and exploit the 165 » // Execute an initial config-changed hook regardless of state.
niemeyer 2012/10/01 22:47:33 s/regardless of state//; We only got here after qu
fwereade 2012/10/02 07:01:35 SGTM
149 » // guaranteed initial send to ensure we run a config-changed hook 166 » cc := hook.Info{Kind: hook.ConfigChanged}
150 » // before starting any other watches. 167 » u.wantConfigEvent()
151 » starting := true 168 » select {
152 » configw := u.service.WatchConfig() 169 » case <-u.Dying():
153 » defer stop(configw, &next, &err) 170 » » return nil, tomb.ErrDying
154 » var servicew *state.ServiceWatcher 171 » case <-u.configEvents():
niemeyer 2012/10/01 22:47:33 It'd be good to have a comment explaining why we h
fwereade 2012/10/02 07:01:35 The rationale is that we want to run one config-ch
155 » var serviceChanges <-chan *state.Service 172 » » if err = u.runHook(cc); err != nil {
173 » » » return nil, err
174 » » }
175 » }
176
177 » // Watch for everything else (including further config changes).
niemeyer 2012/10/01 22:47:33 d It's not watching everything else. It's watchin
fwereade 2012/10/02 07:01:35 Ha, yes.
178 » u.wantCharmEvent()
156 for { 179 for {
157 select { 180 select {
158 » » case <-u.tomb.Dying(): 181 » » case <-u.Dying():
159 return nil, tomb.ErrDying 182 return nil, tomb.ErrDying
160 » » case _, ok := <-configw.Changes(): 183 » » case <-u.unitDying():
161 » » » if !ok { 184 » » » // TODO don't stop until all relations broken.
162 » » » » return nil, watcher.MustErr(configw) 185 » » » return ModeStopping, nil
163 » » » } 186 » » case <-u.configEvents():
164 » » » hi := hook.Info{Kind: hook.ConfigChanged} 187 » » » if err = u.runHook(cc); err != nil {
165 » » » if err = u.runHook(hi); err != nil {
166 » » » » if err == errHookFailed {
167 » » » » » return ModeHookError, nil
168 » » » » }
169 return nil, err 188 return nil, err
170 } 189 }
171 » » » if starting { 190 » » case ch := <-u.charmEvents():
172 » » » » // If we haven't already set up additional watch es, do so now. 191 » » » upgrade, err := u.getUpgrade(ch, false)
173 » » » » starting = false 192 » » » if err == errNoUpgrade {
174 » » » » servicew = u.service.Watch() 193 » » » » continue
175 » » » » defer stop(servicew, &next, &err) 194 » » » } else if err != nil {
176 » » » » serviceChanges = servicew.Changes()
177 » » » }
178 » » case service, ok := <-serviceChanges:
179 » » » if !ok {
180 » » » » return nil, watcher.MustErr(servicew)
181 » » » }
182 » » » ch, _, err := service.Charm()
183 » » » if err != nil {
184 return nil, err 195 return nil, err
185 } 196 }
186 » » » url, err := charm.ReadCharmURL(u.charm) 197 » » » return ModeUpgrading(upgrade), nil
187 » » » if err != nil {
188 » » » » return nil, err
189 » » » }
190 » » » if *ch.URL() != *url {
191 » » » » return ModeUpgrading(ch), nil
192 » » » }
193 } 198 }
194 // TODO: unit death; relations.
195 } 199 }
196 panic("unreachable") 200 panic("unreachable")
197 } 201 }
198 202
199 // ModeHookError is responsible for watching and responding to: 203 // ModeHookError is responsible for watching and responding to:
200 // * user resolution of hook errors 204 // * user resolution of hook errors
201 // * forced charm upgrade requests (not implemented) 205 // * charm upgrade requests
202 // * unit death (not implemented)
203 func ModeHookError(u *Uniter) (next Mode, err error) { 206 func ModeHookError(u *Uniter) (next Mode, err error) {
204 » defer errorContextf(&err, "ModeHookError") 207 » defer modeContext("ModeHookError", &next, &err)()
205 s, err := u.sf.Read() 208 s, err := u.sf.Read()
206 if err != nil { 209 if err != nil {
207 return nil, err 210 return nil, err
208 } 211 }
209 if s.Op != RunHook || s.OpStep != Pending { 212 if s.Op != RunHook || s.OpStep != Pending {
210 return nil, fmt.Errorf("insane uniter state: %#v", s) 213 return nil, fmt.Errorf("insane uniter state: %#v", s)
211 } 214 }
212 msg := fmt.Sprintf("hook failed: %q", s.Hook.Kind) 215 msg := fmt.Sprintf("hook failed: %q", s.Hook.Kind)
213 if err = u.unit.SetStatus(state.UnitError, msg); err != nil { 216 if err = u.unit.SetStatus(state.UnitError, msg); err != nil {
214 return nil, err 217 return nil, err
215 } 218 }
216 219 » resolveHook := getResolveHook(*s.Hook)
217 » // Wait for shutdown, error resolution, or forced charm upgrade. 220 » u.wantResolvedEvent()
218 » unitw := u.unit.Watch() 221 » u.wantCharmEvent()
219 » defer stop(unitw, &next, &err)
220 » servicew := u.service.Watch()
221 » defer stop(servicew, &next, &err)
222 for { 222 for {
223 select { 223 select {
224 » » case <-u.tomb.Dying(): 224 » » case <-u.Dying():
225 return nil, tomb.ErrDying 225 return nil, tomb.ErrDying
226 » » case unit, ok := <-unitw.Changes(): 226 » » case rm := <-u.resolvedEvents():
227 » » » // TODO: unit death. 227 » » » if success, err := u.resolveError(*rm, resolveHook); suc cess {
228 » » » if !ok { 228 » » » » return ModeContinue, nil
229 » » » » return nil, watcher.MustErr(unitw) 229 » » » } else if err != nil && err != errHookFailed {
230 » » » » return nil, err
230 } 231 }
231 » » » switch unit.Resolved() { 232 » » case ch := <-u.charmEvents():
232 » » » case state.ResolvedNone: 233 » » » upgrade, err := u.getUpgrade(ch, true)
233 » » » » continue 234 » » » if err == errNoUpgrade {
234 » » » case state.ResolvedRetryHooks:
235 » » » » err = u.runHook(*s.Hook)
236 » » » case state.ResolvedNoHooks:
237 » » » » err = u.commitHook(*s.Hook)
238 » » » default:
239 » » » » panic(fmt.Errorf("unhandled resolved mode %q", u nit.Resolved()))
240 » » » }
241 » » » if e := unit.ClearResolved(); e != nil {
242 » » » » err = e
243 » » » }
244 » » » if err == errHookFailed {
245 continue 235 continue
246 } else if err != nil { 236 } else if err != nil {
247 return nil, err 237 return nil, err
248 } 238 }
249 » » » return ModeContinue, nil 239 » » » return ModeUpgrading(upgrade), nil
250 » » case service, ok := <-servicew.Changes():
251 » » » if !ok {
252 » » » » return nil, watcher.MustErr(servicew)
253 » » » }
254 » » » ch, force, err := service.Charm()
255 » » » if err != nil {
256 » » » » return nil, err
257 » » » }
258 » » » url, err := charm.ReadCharmURL(u.charm)
259 » » » if err != nil {
260 » » » » return nil, err
261 » » » }
262 » » » if force && *ch.URL() != *url {
263 » » » » return ModeUpgrading(ch), nil
264 » » » }
265 } 240 }
266 } 241 }
267 panic("unreachable") 242 panic("unreachable")
268 } 243 }
269 244
270 // ModeUpgrading is responsible for upgrading the charm. 245 // ModeConflicted is responsible for watching and responding to:
271 func ModeUpgrading(sch *state.Charm) Mode { 246 // * user resolution of charm upgrade conflicts
272 » return func(u *Uniter) (Mode, error) { 247 // * forced charm upgrade requests
273 » » log.Printf("upgrading charm to %q", sch.URL())
274 » » if err := u.deploy(sch, Upgrade); err != nil {
275 » » » if err == charm.ErrConflict {
276 » » » » return ModeConflicted(sch), nil
277 » » » }
278 » » » return nil, err
279 » » }
280 » » return ModeContinue, nil
281 » }
282 }
283
284 // ModeConflicted waits for the user to resolve an error encountered when
285 // upgrading a charm. This may be done either by manually resolving errors
286 // and then setting the resolved flag, or by forcing an upgrade to a
287 // different charm.
288 func ModeConflicted(sch *state.Charm) Mode { 248 func ModeConflicted(sch *state.Charm) Mode {
289 return func(u *Uniter) (next Mode, err error) { 249 return func(u *Uniter) (next Mode, err error) {
250 defer modeContext("ModeConflicted", &next, &err)()
290 if err = u.unit.SetStatus(state.UnitError, "upgrade failed"); er r != nil { 251 if err = u.unit.SetStatus(state.UnitError, "upgrade failed"); er r != nil {
291 return nil, err 252 return nil, err
292 } 253 }
293 » » unitw := u.unit.Watch() 254 » » u.wantResolvedEvent()
294 » » defer stop(unitw, &next, &err) 255 » » u.wantCharmEvent()
295 » » servicew := u.service.Watch()
296 » » defer stop(servicew, &next, &err)
297 for { 256 for {
298 select { 257 select {
299 » » » case <-u.tomb.Dying(): 258 » » » case <-u.Dying():
300 return nil, tomb.ErrDying 259 return nil, tomb.ErrDying
301 » » » case service, ok := <-servicew.Changes(): 260 » » » case rm := <-u.resolvedEvents():
302 » » » » if !ok { 261 » » » » if success, err := u.resolveError(*rm, resolveCo nflict); success {
303 » » » » » return nil, watcher.MustErr(servicew) 262 » » » » » return ModeUpgrading(sch), nil
304 » » » » } 263 » » » » } else if err != nil {
305 » » » » ch, force, err := service.Charm()
306 » » » » if err != nil {
307 return nil, err 264 return nil, err
308 } 265 }
309 » » » » if force && *ch.URL() != *sch.URL() { 266 » » » case ch := <-u.charmEvents():
310 » » » » » if err := u.charm.Revert(); err != nil { 267 » » » » upgrade, err := u.getUpgrade(ch, true)
311 » » » » » » return nil, err 268 » » » » if err != nil {
269 » » » » » if err == errNoUpgrade {
270 » » » » » » continue
312 } 271 }
313 return ModeUpgrading(ch), nil
314 }
315 case unit, ok := <-unitw.Changes():
316 if !ok {
317 return nil, watcher.MustErr(unitw)
318 }
319 if unit.Resolved() == state.ResolvedNone {
320 continue
321 }
322 err := u.charm.Snapshotf("Upgrade conflict resol ved.")
323 if e := u.unit.ClearResolved(); e != nil && err == nil {
324 err = e
325 }
326 if err != nil {
327 return nil, err 272 return nil, err
328 } 273 }
329 » » » » return ModeUpgrading(sch), nil 274 » » » » if err := u.charm.Revert(); err != nil {
275 » » » » » return nil, err
276 » » » » }
277 » » » » return ModeUpgrading(upgrade), nil
330 } 278 }
331 // TODO: unit death.
332 } 279 }
333 panic("unreachable") 280 panic("unreachable")
334 } 281 }
335 } 282 }
336 283
337 // stop is used by Mode funcs to shut down watchers on return. 284 // modeContext returns a function that implements logging and common error
338 func stop(s stopper, next *Mode, err *error) { 285 // manipulation for Mode funcs.
339 » if e := s.Stop(); e != nil && *err == nil { 286 func modeContext(name string, next *Mode, err *error) func() {
rog 2012/10/01 11:02:58 as discussed online, most/all of this could be mov
niemeyer 2012/10/01 22:47:33 +1 on turning this into straightforward code. Some
340 » » *next = nil 287 » log.Printf(name + " starting")
341 » » *err = e 288 » return func() {
289 » » log.Debugf(name + " exiting")
290 » » switch *err {
291 » » case nil:
292 » » » if *next == nil {
niemeyer 2012/10/01 22:47:33 This seems to obscure the logic without benefit. W
293 » » » » *next = ModeContinue
294 » » » }
295 » » case errHookFailed:
296 » » » *next, *err = ModeHookError, nil
niemeyer 2012/10/01 22:47:33 Same idea. The state machine is being hidden behin
fwereade 2012/10/02 07:01:35 Good points all. Will drop the ModeContinue bits e
niemeyer 2012/10/02 14:12:59 I'd much prefer having it close to the logic that
297 » » case tomb.ErrDying, ErrDead:
298 » » » log.Printf(name + " shutting down")
299 » » default:
300 » » » *err = errors.New(name + ": " + (*err).Error())
301 » » }
342 } 302 }
343 } 303 }
344
345 type stopper interface {
346 Stop() error
347 }
348
349 // errorContextf prefixes the error stored in err with text formatted
350 // according to the format specifier. If err does not contain an error,
351 // or if err is tomb.ErrDying, errorContextf does nothing.
352 func errorContextf(err *error, format string, args ...interface{}) {
353 if *err != nil && *err != tomb.ErrDying {
354 *err = errors.New(fmt.Sprintf(format, args...) + ": " + (*err).E rror())
355 }
356 }
OLDNEW
« no previous file with comments | « state/unit_test.go ('k') | worker/uniter/uniter.go » ('j') | worker/uniter/uniter.go » ('J')

Powered by Google App Engine
RSS Feeds Recent Issues | This issue
This is Rietveld 204d58d