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

Side by Side Diff: cmd/juju/status.go

Issue 8842043: cmd/juju: simplify status
Patch Set: Created 10 years, 11 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 main 1 package main
2 2
3 import ( 3 import (
4 "encoding/json"
4 "fmt" 5 "fmt"
5
6 "launchpad.net/gnuflag" 6 "launchpad.net/gnuflag"
7 "launchpad.net/juju-core/charm"
7 "launchpad.net/juju-core/cmd" 8 "launchpad.net/juju-core/cmd"
8 "launchpad.net/juju-core/environs" 9 "launchpad.net/juju-core/environs"
9 "launchpad.net/juju-core/juju" 10 "launchpad.net/juju-core/juju"
10 "launchpad.net/juju-core/state" 11 "launchpad.net/juju-core/state"
11 "launchpad.net/juju-core/state/api/params" 12 "launchpad.net/juju-core/state/api/params"
12 "launchpad.net/juju-core/utils/set" 13 "launchpad.net/juju-core/utils/set"
14 "strings"
13 ) 15 )
14 16
15 type statusMap map[string]interface{}
16
17 type StatusCommand struct { 17 type StatusCommand struct {
18 EnvCommandBase 18 EnvCommandBase
19 out cmd.Output 19 out cmd.Output
20 } 20 }
21 21
22 var statusDoc = "This command will report on the runtime state of various system entities." 22 var statusDoc = "This command will report on the runtime state of various system entities."
23 23
24 func (c *StatusCommand) Info() *cmd.Info { 24 func (c *StatusCommand) Info() *cmd.Info {
25 return &cmd.Info{ 25 return &cmd.Info{
26 Name: "status", 26 Name: "status",
27 Purpose: "output status information about an environment", 27 Purpose: "output status information about an environment",
28 Doc: statusDoc, 28 Doc: statusDoc,
29 Aliases: []string{"stat"}, 29 Aliases: []string{"stat"},
30 } 30 }
31 } 31 }
32 32
33 func (c *StatusCommand) SetFlags(f *gnuflag.FlagSet) { 33 func (c *StatusCommand) SetFlags(f *gnuflag.FlagSet) {
34 c.EnvCommandBase.SetFlags(f) 34 c.EnvCommandBase.SetFlags(f)
35 c.out.AddFlags(f, "yaml", map[string]cmd.Formatter{ 35 c.out.AddFlags(f, "yaml", map[string]cmd.Formatter{
36 "yaml": cmd.FormatYaml, 36 "yaml": cmd.FormatYaml,
37 "json": cmd.FormatJson, 37 "json": cmd.FormatJson,
38 }) 38 })
39 } 39 }
40 40
41 type statusContext struct {
42 instances map[state.InstanceId]environs.Instance
43 machines map[string]*state.Machine
44 services map[string]*state.Service
45 units map[string]map[string]*state.Unit
46 }
47
41 func (c *StatusCommand) Run(ctx *cmd.Context) error { 48 func (c *StatusCommand) Run(ctx *cmd.Context) error {
42 conn, err := juju.NewConnFromName(c.EnvName) 49 conn, err := juju.NewConnFromName(c.EnvName)
43 if err != nil { 50 if err != nil {
44 return err 51 return err
45 } 52 }
46 defer conn.Close() 53 defer conn.Close()
47 54
48 » instances, err := fetchAllInstances(conn.Environ) 55 » var ctxt statusContext
49 » if err != nil { 56 » if ctxt.instances, err = fetchAllInstances(conn.Environ); err != nil {
50 return err 57 return err
51 } 58 }
52 59 » if ctxt.machines, err = fetchAllMachines(conn.State); err != nil {
53 » machines, err := fetchAllMachines(conn.State)
54 » if err != nil {
55 return err 60 return err
56 } 61 }
57 62 » if ctxt.services, ctxt.units, err = fetchAllServicesAndUnits(conn.State) ; err != nil {
58 » services, err := fetchAllServices(conn.State)
59 » if err != nil {
60 return err 63 return err
61 } 64 }
62 65 » result := struct {
63 » result := map[string]interface{}{ 66 » » Machines map[string]machineStatus `json:"machines"`
64 » » "machines": checkError(processMachines(machines, instances)), 67 » » Services map[string]serviceStatus `json:"services"`
65 » » "services": checkError(processServices(services)), 68 » }{
69 » » Machines: ctxt.processMachines(),
70 » » Services: ctxt.processServices(),
66 } 71 }
67
68 return c.out.Write(ctx, result) 72 return c.out.Write(ctx, result)
69 } 73 }
70 74
71 // fetchAllInstances returns a map[string]environs.Instance representing 75 // fetchAllInstances returns a map from instance id to instance.
72 // a mapping of instance ids to their respective instance.
73 func fetchAllInstances(env environs.Environ) (map[state.InstanceId]environs.Inst ance, error) { 76 func fetchAllInstances(env environs.Environ) (map[state.InstanceId]environs.Inst ance, error) {
74 m := make(map[state.InstanceId]environs.Instance) 77 m := make(map[state.InstanceId]environs.Instance)
75 insts, err := env.AllInstances() 78 insts, err := env.AllInstances()
fwereade 2013/04/17 22:04:08 Zero instances is "sane" given eventual consistenc
rog 2013/04/17 22:31:45 Done.
76 if err != nil { 79 if err != nil {
77 return nil, err 80 return nil, err
78 } 81 }
79 for _, i := range insts { 82 for _, i := range insts {
80 m[i.Id()] = i 83 m[i.Id()] = i
81 } 84 }
82 return m, nil 85 return m, nil
83 } 86 }
84 87
85 // fetchAllMachines returns a map[string]*state.Machine representing 88 // fetchAllMachines returns a map from machine id to machine.
86 // a mapping of machine ids to machines.
87 func fetchAllMachines(st *state.State) (map[string]*state.Machine, error) { 89 func fetchAllMachines(st *state.State) (map[string]*state.Machine, error) {
88 v := make(map[string]*state.Machine) 90 v := make(map[string]*state.Machine)
89 machines, err := st.AllMachines() 91 machines, err := st.AllMachines()
90 if err != nil { 92 if err != nil {
91 return nil, err 93 return nil, err
92 } 94 }
93 for _, m := range machines { 95 for _, m := range machines {
94 v[m.Id()] = m 96 v[m.Id()] = m
95 } 97 }
96 return v, nil 98 return v, nil
97 } 99 }
98 100
99 // fetchAllServices returns a map representing a mapping of service 101 // fetchAllServicesAndUnits returns a map from service name to service
100 // names to services. 102 // and a map from service name to unit name to unit.
101 func fetchAllServices(st *state.State) (map[string]*state.Service, error) { 103 func fetchAllServicesAndUnits(st *state.State) (map[string]*state.Service, map[s tring]map[string]*state.Unit, error) {
102 » v := make(map[string]*state.Service) 104 » svcMap := make(map[string]*state.Service)
105 » unitMap := make(map[string]map[string]*state.Unit)
103 services, err := st.AllServices() 106 services, err := st.AllServices()
104 if err != nil { 107 if err != nil {
105 » » return nil, err 108 » » return nil, nil, err
106 } 109 }
107 for _, s := range services { 110 for _, s := range services {
108 » » v[s.Name()] = s 111 » » svcMap[s.Name()] = s
109 » } 112 » » units, err := s.AllUnits()
110 » return v, nil 113 » » if err != nil {
111 } 114 » » » return nil, nil, err
112 115 » » }
113 // processMachines gathers information about machines. 116 » » svcUnitMap := make(map[string]*state.Unit)
114 func processMachines(machines map[string]*state.Machine, instances map[state.Ins tanceId]environs.Instance) (statusMap, error) { 117 » » for _, u := range units {
115 » machinesMap := make(statusMap) 118 » » » svcUnitMap[u.Name()] = u
116 » for _, m := range machines { 119 » » }
117 » » instid, ok := m.InstanceId() 120 » » unitMap[s.Name()] = svcUnitMap
118 » » if !ok { 121 » }
119 » » » machinesMap[m.Id()] = statusMap{ 122 » return svcMap, unitMap, nil
120 » » » » "instance-id": "pending", 123 }
121 » » » } 124
122 » » } else { 125 func (ctxt *statusContext) processMachines() map[string]machineStatus {
123 » » » instance, ok := instances[instid] 126 » machinesMap := make(map[string]machineStatus)
124 » » » if !ok { 127 » for _, m := range ctxt.machines {
125 » » » » // Double plus ungood. There is an instance id r ecorded for this machine in the state, 128 » » machinesMap[m.Id()] = ctxt.processMachine(m)
126 » » » » // yet the environ cannot find that id. 129 » }
127 » » » » return nil, fmt.Errorf("instance %s for machine %s not found", instid, m.Id()) 130 » return machinesMap
128 » » » } 131 }
129 » » » machinesMap[m.Id()] = checkError(processMachine(m, insta nce)) 132
130 » » } 133 func (ctxt *statusContext) processMachine(machine *state.Machine) (status machin eStatus) {
131 » } 134 » instid, ok := machine.InstanceId()
132 » return machinesMap, nil 135 » if !ok {
133 } 136 » » status.InstanceId = "pending"
134 137 » » return
135 func processStatus(sm statusMap, status params.Status, info string, agentAlive, entityDead bool) { 138 » }
136 » if status != params.StatusPending { 139 » instance, ok := ctxt.instances[instid]
137 » » if !agentAlive && !entityDead { 140 » if !ok {
138 » » » // Add the original status to the info, so it's not lost . 141 » » // Double plus ungood. There is an
139 » » » if info != "" { 142 » » // instance id recorded for this machine
140 » » » » info = fmt.Sprintf("(%s: %s)", status, info) 143 » » // in the state, yet the environ cannot
141 » » » } else { 144 » » // find that id.
fwereade 2013/04/17 22:04:08 Not really an error; definitely not one that justi
rog 2013/04/17 22:31:45 Done.
142 » » » » info = fmt.Sprintf("(%s)", status) 145 » » status.Err = fmt.Errorf("instance %s not found", instid)
143 » » » } 146 » » return
144 » » » // Agent should be running but it's not. 147 » }
145 » » » status = params.StatusDown 148 » status.InstanceId = instance.Id()
146 » » } 149 » status.DNSName, _ = instance.DNSName()
147 » } 150 » processVersion(&status.AgentVersion, machine)
148 » sm["agent-state"] = status 151 » status.Err = processStatus(&status.AgentState, &status.AgentStateInfo, m achine)
149 » if info != "" { 152 » return
150 » » sm["agent-state-info"] = info 153 }
151 » } 154
152 } 155 func (ctxt *statusContext) processServices() map[string]serviceStatus {
153 156 » servicesMap := make(map[string]serviceStatus)
154 func processMachine(machine *state.Machine, instance environs.Instance) (statusM ap, error) { 157 » for _, s := range ctxt.services {
155 » machineMap := make(statusMap) 158 » » servicesMap[s.Name()] = ctxt.processService(s)
156 » machineMap["instance-id"] = instance.Id() 159 » }
157 160 » return servicesMap
158 » if dnsname, err := instance.DNSName(); err == nil { 161 }
159 » » machineMap["dns-name"] = dnsname 162
160 » } 163 func (ctxt *statusContext) processService(service *state.Service) (status servic eStatus) {
161
162 » processVersion(machineMap, machine)
163
164 » agentAlive, err := machine.AgentAlive()
165 » if err != nil {
166 » » return nil, err
167 » }
168 » machineDead := machine.Life() == state.Dead
169 » status, info, err := machine.Status()
170 » if err != nil {
171 » » return nil, err
172 » }
173 » processStatus(machineMap, status, info, agentAlive, machineDead)
174
175 » return machineMap, nil
176 }
177
178 // processServices gathers information about services.
179 func processServices(services map[string]*state.Service) (statusMap, error) {
180 » servicesMap := make(statusMap)
181 » for _, s := range services {
182 » » servicesMap[s.Name()] = checkError(processService(s))
183 » }
184 » return servicesMap, nil
185 }
186
187 func processService(service *state.Service) (statusMap, error) {
188 » serviceMap := make(statusMap)
189 ch, _, err := service.Charm() 164 ch, _, err := service.Charm()
fwereade 2013/04/17 22:04:08 I think you can just get CharmURL here
rog 2013/04/17 22:31:45 much nicer, yes. done (less round-trips, yay!)
190 if err != nil { 165 if err != nil {
191 » » return nil, err 166 » » status.Err = err
192 » } 167 » » return
193 » serviceMap["charm"] = ch.String() 168 » }
194 » serviceMap["exposed"] = service.IsExposed() 169 » status.Charm = ch.String()
195 170 » status.Exposed = service.IsExposed()
196 » // TODO(dfc) service.IsSubordinate() ? 171 » status.Relations, status.SubordinateTo, err = ctxt.processRelations(serv ice)
197 172 » if err != nil {
198 » units, err := service.AllUnits() 173 » » status.Err = err
199 » if err != nil { 174 » » return
200 » » return nil, err 175 » }
201 » } 176 » if !service.IsSubordinate() {
202 177 » » status.Units = ctxt.processUnits(ctxt.units[service.Name()])
203 » if u := checkError(processUnits(units)); len(u) > 0 { 178 » }
204 » » serviceMap["units"] = u 179 » return status
205 » } 180 }
206 181
207 » if r := checkError(processRelations(service)); len(r) > 0 { 182 func (ctxt *statusContext) processUnits(units map[string]*state.Unit) map[string ]unitStatus {
208 » » serviceMap["relations"] = r 183 » unitsMap := make(map[string]unitStatus)
209 » }
210
211 » return serviceMap, nil
212 }
213
214 func processUnits(units []*state.Unit) (statusMap, error) {
215 » unitsMap := make(statusMap)
216 for _, unit := range units { 184 for _, unit := range units {
217 » » unitsMap[unit.Name()] = checkError(processUnit(unit)) 185 » » unitsMap[unit.Name()] = ctxt.processUnit(unit)
218 » } 186 » }
219 » return unitsMap, nil 187 » return unitsMap
220 } 188 }
221 189
222 func processUnit(unit *state.Unit) (statusMap, error) { 190 func (ctxt *statusContext) processUnit(unit *state.Unit) (status unitStatus) {
223 » unitMap := make(statusMap) 191 » status.PublicAddress, _ = unit.PublicAddress()
224 192 » if unit.IsPrincipal() {
225 » if addr, ok := unit.PublicAddress(); ok { 193 » » status.Machine, _ = unit.AssignedMachineId()
226 » » unitMap["public-address"] = addr 194 » }
227 » } 195 » processVersion(&status.AgentVersion, unit)
228 196 » if err := processStatus(&status.AgentState, &status.AgentStateInfo, unit ); err != nil {
229 » if id, err := unit.AssignedMachineId(); err == nil { 197 » » status.Err = err
230 » » // TODO(dfc) we could make this nicer, ie machine/0 198 » » return
231 » » unitMap["machine"] = id 199 » }
232 » } 200 » if subUnits := unit.SubordinateNames(); len(subUnits) > 0 {
233 201 » » status.Subordinates = make(map[string]unitStatus)
234 » processVersion(unitMap, unit) 202 » » for _, name := range subUnits {
235 203 » » » subUnit := ctxt.unitByName(name)
236 » agentAlive, err := unit.AgentAlive() 204 » » » status.Subordinates[name] = ctxt.processUnit(subUnit)
237 » if err != nil { 205 » » }
238 » » return nil, err 206 » }
239 » } 207 » return
240 » unitDead := unit.Life() == state.Dead 208 }
241 » status, info, err := unit.Status() 209
242 » if err != nil { 210 func (ctxt *statusContext) unitByName(name string) *state.Unit {
243 » » return nil, err 211 » serviceName := strings.Split(name, "/")[0]
244 » } 212 » return ctxt.units[serviceName][name]
245 » processStatus(unitMap, status, info, agentAlive, unitDead) 213 }
246 214
247 » return unitMap, nil 215 func (*statusContext) processRelations(service *state.Service) (related map[stri ng][]string, subord []string, err error) {
248 }
249
250 func processRelations(service *state.Service) (statusMap, error) {
251 // TODO(mue) This way the same relation is read twice (for each service) . 216 // TODO(mue) This way the same relation is read twice (for each service) .
252 // Maybe add Relations() to state, read them only once and pass them to each 217 // Maybe add Relations() to state, read them only once and pass them to each
253 » // call of this function. 218 » // call of this function.
254 relations, err := service.Relations() 219 relations, err := service.Relations()
255 if err != nil { 220 if err != nil {
256 » » return nil, err 221 » » return nil, nil, err
257 » } 222 » }
258 » relationMap := make(statusMap) 223 » var subordSet set.Strings
224 » related = make(map[string][]string)
259 for _, relation := range relations { 225 for _, relation := range relations {
260 ep, err := relation.Endpoint(service.Name()) 226 ep, err := relation.Endpoint(service.Name())
261 if err != nil { 227 if err != nil {
262 » » » return nil, err 228 » » » return nil, nil, err
263 } 229 }
264 relationName := ep.Relation.Name 230 relationName := ep.Relation.Name
265 eps, err := relation.RelatedEndpoints(service.Name()) 231 eps, err := relation.RelatedEndpoints(service.Name())
266 if err != nil { 232 if err != nil {
267 » » » return nil, err 233 » » » return nil, nil, err
268 » » }
269 » » serviceNames := []string{}
270 » » if relationMap[relationName] != nil {
271 » » » serviceNames = relationMap[relationName].([]string)
272 } 234 }
273 for _, ep := range eps { 235 for _, ep := range eps {
274 » » » serviceNames = append(serviceNames, ep.ServiceName) 236 » » » if ep.Scope == charm.ScopeContainer && service.IsSubordi nate() {
275 » » } 237 » » » » subordSet.Add(ep.ServiceName)
276 » » relationMap[relationName] = serviceNames 238 » » » }
277 » } 239 » » » related[relationName] = append(related[relationName], ep .ServiceName)
278 » // Normalize service names by removing duplicates and sorting them. 240 » » }
279 » // TODO(mue) Check if and why duplicates can happen and what this means. 241 » }
280 » for relationName, serviceNames := range relationMap { 242 » for relationName, serviceNames := range related {
281 » » sn := set.NewStrings(serviceNames.([]string)...) 243 » » sn := set.NewStrings(serviceNames...)
282 » » relationMap[relationName] = sn.SortedValues() 244 » » related[relationName] = sn.SortedValues()
283 » } 245 » }
284 » return relationMap, nil 246 » return related, subordSet.SortedValues(), nil
285 } 247 }
286 248
287 type versioned interface { 249 type versioned interface {
288 AgentTools() (*state.Tools, error) 250 AgentTools() (*state.Tools, error)
289 } 251 }
290 252
291 func processVersion(sm statusMap, v versioned) { 253 // processVersion stores the agent version in status.
254 func processVersion(version *string, v versioned) {
292 if t, err := v.AgentTools(); err == nil { 255 if t, err := v.AgentTools(); err == nil {
293 » » sm["agent-version"] = t.Binary.Number.String() 256 » » *version = t.Binary.Number.String()
294 » } 257 » }
295 } 258 }
296 259
297 func checkError(sm statusMap, err error) statusMap { 260 type statuser interface {
298 » if err != nil { 261 » Life() state.Life
299 » » return map[string]interface{}{"status-error": err.Error()} 262 » AgentAlive() (bool, error)
300 » } 263 » Status() (params.Status, string, error)
301 » return sm 264 }
302 } 265
266 // processStatus retrieves the status from the given entity
267 // and sets the destination status and info values accordingly.
268 func processStatus(dstStatus *params.Status, dstInfo *string, entity statuser) e rror {
269 » agentAlive, err := entity.AgentAlive()
270 » if err != nil {
271 » » return err
272 » }
273 » entityDead := entity.Life() == state.Dead
274 » status, info, err := entity.Status()
275 » if err != nil {
276 » » return err
277 » }
278 » if status != params.StatusPending && !agentAlive && !entityDead {
279 » » // Add the original status to the info, so it's not lost.
280 » » if info != "" {
281 » » » info = fmt.Sprintf("(%s: %s)", status, info)
282 » » } else {
283 » » » info = fmt.Sprintf("(%s)", status)
284 » » }
285 » » // Agent should be running but it's not.
286 » » status = params.StatusDown
287 » }
288 » *dstStatus = status
289 » *dstInfo = info
290 » return nil
291 }
292
293 type machineStatus struct {
294 » Err error `json:"-" yaml:",omitempty"`
295 » InstanceId state.InstanceId `json:"instance-id" yaml:"instance-id"`
296 » DNSName string `json:"dns-name,omitempty" yaml:"dns-nam e,omitempty"`
297 » AgentVersion string `json:"agent-version,omitempty" yaml:"ag ent-version,omitempty"`
298 » AgentState params.Status `json:"agent-state,omitempty" yaml:"agen t-state,omitempty"`
299 » AgentStateInfo string `json:"agent-state-info,omitempty" yaml: "agent-state-info,omitempty"`
300 }
301
302 // A goyaml bug means we can't declare these types
303 // locally to the GetYAML methods.
304 type machineStatusNoMarshal machineStatus
fwereade 2013/04/17 22:04:08 I have a great love for bug tests, which fail when
rog 2013/04/17 22:31:45 upstream bugs *are* in fact fixed - but i don't wa
305
306 type errorStatus struct {
307 » StatusError string `json:"status-error" yaml:"status-error"`
308 }
309
310 func (s machineStatus) MarshalJSON() ([]byte, error) {
311 » if s.Err != nil {
312 » » return json.Marshal(errorStatus{s.Err.Error()})
313 » }
314 » return json.Marshal(machineStatusNoMarshal(s))
315 }
316
317 func (s machineStatus) GetYAML() (tag string, value interface{}) {
318 » if s.Err != nil {
319 » » return "", errorStatus{s.Err.Error()}
320 » }
321 » // TODO(rog) rename mNoMethods to noMethods (and also in
322 » // the other GetYAML methods) when people are using the non-buggy
323 » // goyaml version.
324 » type mNoMethods machineStatus
325 » return "", mNoMethods(s)
326 }
327
328 type serviceStatus struct {
329 » Err error `json:"-" yaml:",omitempty"`
330 » Charm string `json:"charm" yaml:"charm"`
331 » Exposed bool `json:"exposed" yaml:"exposed"`
332 » Units map[string]unitStatus `json:"units,omitempty" yaml:"units, omitempty"`
333 » Relations map[string][]string `json:"relations,omitempty" yaml:"re lations,omitempty"`
334 » SubordinateTo []string `json:"subordinate-to,omitempty" yam l:"subordinate-to,omitempty"`
335 }
336 type serviceStatusNoMarshal serviceStatus
337
338 func (s serviceStatus) MarshalJSON() ([]byte, error) {
339 » if s.Err != nil {
340 » » return json.Marshal(errorStatus{s.Err.Error()})
341 » }
342 » type sNoMethods serviceStatus
343 » return json.Marshal(sNoMethods(s))
344 }
345
346 func (s serviceStatus) GetYAML() (tag string, value interface{}) {
347 » if s.Err != nil {
348 » » return "", errorStatus{s.Err.Error()}
349 » }
350 » type sNoMethods serviceStatus
351 » return "", sNoMethods(s)
352 }
353
354 type unitStatus struct {
355 » Err error `json:"-" yaml:",omitempty"`
356 » PublicAddress string `json:"public-address,omitempty" ya ml:"public-address,omitempty"`
357 » Machine string `json:"machine,omitempty" yaml:"mac hine,omitempty"`
358 » AgentVersion string `json:"agent-version,omitempty" yam l:"agent-version,omitempty"`
359 » AgentState params.Status `json:"agent-state,omitempty" yaml: "agent-state,omitempty"`
360 » AgentStateInfo string `json:"agent-state-info,omitempty" yaml:"agent-state-info,omitempty"`
361 » Subordinates map[string]unitStatus `json:"subordinates,omitempty" yaml :"subordinates,omitempty"`
362 }
363
364 type unitStatusNoMarshal unitStatus
365
366 func (s unitStatus) MarshalJSON() ([]byte, error) {
367 » if s.Err != nil {
368 » » return json.Marshal(errorStatus{s.Err.Error()})
369 » }
370 » return json.Marshal(unitStatusNoMarshal(s))
371 }
372
373 func (s unitStatus) GetYAML() (tag string, value interface{}) {
374 » if s.Err != nil {
375 » » return "", errorStatus{s.Err.Error()}
376 » }
377 » type uNoMethods unitStatus
378 » return "", unitStatusNoMarshal(s)
379 }
OLDNEW
« no previous file with comments | « [revision details] ('k') | cmd/juju/status_test.go » ('j') | cmd/juju/status_test.go » ('J')

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