carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The CLI's version-target, tags and releases API (release versioning spec,
2// 2026-08-08): `carlos version target` and the releases list behind `carlos
3// releases`. The tags routes have no CLI verb of their own yet — their rows
4// feed `carlos channels`' tag annotations and the console's releases page,
5// and a future `carlos tags` verb may read them directly. Every handler
6// opens with s.apiAccountApp — the SAME account/app 404 apicli.go's other
7// doors give, so a non-member cannot tell an app they don't belong to apart
8// from one that does not exist by probing any of these five routes either.
9package console
10
11import (
12 "context"
13 "errors"
14 "log"
15 "net/http"
16 "os"
17 "strings"
18 "time"
19
20 "github.com/carlosframework/platform/internal/release"
21)
22
23// handleAPIVersionTargetGet answers `carlos version target` with no argument:
24// the app's current target, or an empty one when none has ever been set —
25// unset is a state, not an error, matching release.GetTarget's own contract.
26func (s *Server) handleAPIVersionTargetGet(w http.ResponseWriter, r *http.Request) {
27 _, acct, app, ok := s.apiAccountApp(w, r)
28 if !ok {
29 return
30 }
31 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
32 defer cancel()
33 vt, _, err := release.GetTarget(ctx, s.cfg.Releases, acct.Sqid, app)
34 if err != nil {
35 log.Printf("console: api version-target get %s/%s: %v", acct.Sqid, app, err)
36 jsonError(w, http.StatusBadGateway, "could not read the version target")
37 return
38 }
39 if vt == nil {
40 writeJSON(w, http.StatusOK, map[string]string{"target": ""})
41 return
42 }
43 writeJSON(w, http.StatusOK, map[string]any{"target": vt.Target, "set_by": vt.SetBy, "set_at": vt.SetAt})
44}
45
46// handleAPIVersionTargetSet answers `carlos version target <version>`: the
47// one write door in this file, gated the same way handleAPIPromote's write
48// is — apiAccountApp's 404 first, then the shared write budget, keyed on the
49// caller's address like every other mutation here.
50//
51// release.SetTarget carries its own two refusals — not a bare semver, and
52// not above every existing tag — and this handler surfaces both rather than
53// re-deriving them: a 400 for the shape (this caller's mistake, fixable by
54// retyping, checked here before SetTarget is even called) and a 409 for the
55// ordering (a fact about the app's history that SetTarget's own message
56// names, recognised below via errors.Is(release.ErrTargetNotAboveTags)
57// rather than a string match). Anything else SetTarget can return — a
58// store outage, a lost CAS race against a concurrent writer — is the
59// STORE's fault, not the caller's: surfacing it as a 409 with the raw
60// error text would tell a caller retrying a good request that their INPUT
61// was the problem, and would put storage-layer wording (key names, request
62// ids) in front of a terminal. Those get logged and answered with the same
63// fixed 502 every other bucket failure in this file gets.
64func (s *Server) handleAPIVersionTargetSet(w http.ResponseWriter, r *http.Request) {
65 addr, acct, app, ok := s.apiAccountApp(w, r)
66 if !ok {
67 return
68 }
69 p, _ := principalFrom(r.Context())
70 // allowWrite picks the person or the service write budget from the
71 // principal (svcprincipal.go) — the same key handleAPIPromote's budget
72 // uses.
73 if !s.allowWrite(p, time.Now()) {
74 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
75 return
76 }
77 var req struct {
78 Target string `json:"target"`
79 }
80 if !readJSON(w, r, &req) {
81 return
82 }
83 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
84 defer cancel()
85 target := strings.TrimSpace(req.Target)
86 if _, ok := release.ParseSemVer(target); !ok {
87 jsonError(w, http.StatusBadRequest, "version target: want MAJOR.MINOR.PATCH, like 0.5.0")
88 return
89 }
90 if err := release.SetTarget(ctx, s.cfg.Releases, acct.Sqid, app, target, addr, time.Now().UTC()); err != nil {
91 if errors.Is(err, release.ErrTargetNotAboveTags) || errors.Is(err, release.ErrCASConflict) {
92 // SetTarget's own message names the offending tag when the
93 // target is not above every release already cut — as useful
94 // over the API as it is on the command line, so it passes
95 // through verbatim. A lost CAS race gets the same status: a
96 // concurrent write beat this one, which is the caller's cue to
97 // re-read and retry, exactly like the 409 above.
98 jsonError(w, http.StatusConflict, err.Error())
99 return
100 }
101 log.Printf("console: api version-target set %s/%s: %v", acct.Sqid, app, err)
102 jsonError(w, http.StatusBadGateway, "could not write the version target")
103 return
104 }
105 s.audit(acct, app, addr, "version-target", "version target set to "+target+" (cli)")
106 writeJSON(w, http.StatusOK, map[string]string{"target": target})
107}
108
109// tagListRow is one row of the tags list — deliberately WITHOUT Changelog,
110// which can run to several KB per tag and is not what a list view needs;
111// GetTag (below) is the door onto a single tag's full changelog.
112type tagListRow struct {
113 Version string `json:"version"`
114 Release string `json:"release"`
115 TaggedAt time.Time `json:"tagged_at"`
116 Actor string `json:"actor,omitempty"`
117}
118
119// handleAPITagsList answers the tags list: every tag this app has cut,
120// semver-descending — release.ListTags' own order. `carlos channels` reads
121// this to annotate its TAG column and the console's releases page reads it
122// for the same join; there is no standalone `carlos tags` verb yet.
123func (s *Server) handleAPITagsList(w http.ResponseWriter, r *http.Request) {
124 _, acct, app, ok := s.apiAccountApp(w, r)
125 if !ok {
126 return
127 }
128 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
129 defer cancel()
130 tags, err := release.ListTags(ctx, s.cfg.Releases, acct.Sqid, app)
131 if err != nil {
132 // ListTags propagates anything beyond a list/delete race (a
133 // corrupted tag object, an unreadable store) rather than swallowing
134 // it into a shorter list — the same "an unreadable row is worth
135 // refusing over, not hiding" call handleAPIChannels' per-row error
136 // makes differently, because there a partial answer is still useful
137 // and here list order already depends on parsing every row.
138 log.Printf("console: api tags list %s/%s: %v", acct.Sqid, app, err)
139 jsonError(w, http.StatusBadGateway, "could not read the tags")
140 return
141 }
142 out := make([]tagListRow, 0, len(tags))
143 for _, t := range tags {
144 out = append(out, tagListRow{Version: t.Version, Release: t.Release, TaggedAt: t.TaggedAt, Actor: t.Actor})
145 }
146 writeJSON(w, http.StatusOK, map[string]any{"tags": out})
147}
148
149// handleAPITagGet answers a single tag, in full — including the changelog
150// the list route deliberately omits; the console's releases page renders it.
151// A version this app never tagged is a 404 naming the version, distinct from
152// apiAccountApp's byte-identical membership 404 above it: a member is told
153// exactly what is missing, because by the time this line runs membership is
154// already proved and there is nothing left to protect by staying vague.
155func (s *Server) handleAPITagGet(w http.ResponseWriter, r *http.Request) {
156 _, acct, app, ok := s.apiAccountApp(w, r)
157 if !ok {
158 return
159 }
160 version := r.PathValue("version")
161 // Shape-checked before the key builder, not after — the same discipline
162 // apiinstances.go's handleAPIInstanceGet applies to a {host} segment.
163 // A {version} segment arrives percent-decoded, so "..%2Fversion-target"
164 // reaches this handler as a path with a separator in it, and TagKey
165 // would happily join it into a key naming a DIFFERENT object under this
166 // app's prefix. ListTags already only ever produces semver-parsable tag
167 // names (tag.go), so a version that fails ParseSemVer can never name a
168 // real tag regardless of what the store holds — this 404 is correct on
169 // every backend, not just the ones where the traversal itself would fail.
170 if _, ok := release.ParseSemVer(version); !ok {
171 jsonError(w, http.StatusNotFound, "no such tag "+version)
172 return
173 }
174 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
175 defer cancel()
176 tag, err := release.GetTag(ctx, s.cfg.Releases, acct.Sqid, app, version)
177 if err != nil {
178 if errors.Is(err, os.ErrNotExist) {
179 jsonError(w, http.StatusNotFound, "no such tag "+version)
180 return
181 }
182 log.Printf("console: api tag get %s/%s %s: %v", acct.Sqid, app, version, err)
183 jsonError(w, http.StatusBadGateway, "could not read tag "+version)
184 return
185 }
186 writeJSON(w, http.StatusOK, map[string]any{
187 "version": tag.Version, "release": tag.Release, "commit": tag.Commit,
188 "tagged_at": tag.TaggedAt, "actor": tag.Actor, "changelog": tag.Changelog,
189 })
190}
191
192// handleAPIReleasesList answers `carlos releases`: every shipped release
193// (Versions, view.go — the same read the console's own app page uses),
194// joined against ListTags so a row that a tag points at carries the tag's
195// name. The join key is Release, not Version-as-tag: a tag names the
196// ITERATION it was cut from (Tag.Release), so this is the inverse lookup —
197// "which tag, if any, points at this release".
198func (s *Server) handleAPIReleasesList(w http.ResponseWriter, r *http.Request) {
199 _, acct, app, ok := s.apiAccountApp(w, r)
200 if !ok {
201 return
202 }
203 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
204 defer cancel()
205 versions, err := Versions(s.cfg.Releases, acct.Sqid, app)
206 if err != nil {
207 log.Printf("console: api releases list %s/%s: %v", acct.Sqid, app, err)
208 jsonError(w, http.StatusBadGateway, "could not read the releases")
209 return
210 }
211 // Unlike the console's own releases page (handleAppReleases, server.go),
212 // which degrades a broken tags read to a tag-less page, an API caller
213 // that asked for tags and got a wrong 200 back with every tag silently
214 // missing would have no way to tell "no tags exist" from "the read
215 // failed" — so this propagates the error to the same 502 every other
216 // store failure in this file gets.
217 byRelease, err := tagByRelease(ctx, s.cfg.Releases, acct.Sqid, app)
218 if err != nil {
219 log.Printf("console: api releases list %s/%s: tags: %v", acct.Sqid, app, err)
220 jsonError(w, http.StatusBadGateway, "could not read the tags")
221 return
222 }
223 out := make([]map[string]any, 0, len(versions))
224 for _, v := range versions {
225 out = append(out, map[string]any{
226 "version": v.Version, "kind": v.Kind, "built_at": v.BuiltAt,
227 "label": v.Label, "tag": byRelease[v.Version],
228 })
229 }
230 writeJSON(w, http.StatusOK, map[string]any{"releases": out})
231}
232