| 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. |
| 9 | package console |
| 10 | |
| 11 | import ( |
| 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. |
| 26 | func (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. |
| 64 | func (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. |
| 112 | type 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. |
| 123 | func (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. |
| 155 | func (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". |
| 198 | func (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 | |