Skip to content

Commit 0217a3f

Browse files
authored
v2025-09.2 (#263)
1 parent 9dda4d1 commit 0217a3f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+2640
-689
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,43 @@ Reach out to laszlo at gimlet.io. We are looking for beta testers.
4949
![Star History Chart](https://api.star-history.com/svg?repos=gimlet-io/capacitor&type=Date)
5050

5151
Please push ✨
52+
53+
## Versioning
54+
55+
Capacitor Next follows a calendar based versioning with a bi-weekly feature release cadence.
56+
57+
- `v2025-09.1` is the first feature release in September 2025. Landing mid month.
58+
- `v2025-09.2` is the second feature release in September 2025. Landing at the end of the month, or perhaps the beginning of the next one.
59+
- `v2025-09.1-patch1` is the first patch release of the first feature release in September 2025.
60+
- `v2025-09.2-rc2` is the second release candidate of the second feature release in September 2025.
61+
- `v2025-09.2-debug1` is a debug release of the second feature release in September 2025. Meant to be installed only by request of the maintainers.
62+
63+
The update notifications in the UI trigger only on feature releases: `v2025-09.1`, `v2025-09.2`.
64+
65+
You may install `patch` and `rc` releases if you please, `debug` releases if requested.
66+
67+
You can install a specific version by supplying the version tag:
68+
69+
```
70+
wget -qO- https://gimlet.io/install-capacitor | bash -s -- v2025-09.2
71+
```
72+
73+
### Branching
74+
75+
`main` is always the latest feature release. Late September 2025 that is `v2025-09.1` as `v2025-09.2` is not released yet. `main` carries patch and debug releases of the latest feature release.
76+
77+
The next feature release is built on a branch named after it eg.: `v2025-09.2`. Rc and debug releases are built from this branch.
78+
79+
### Self-hosted versioning
80+
81+
Versioning for the self-hosted release follows the versioning of the local-first app.
82+
83+
The deployment yamls always point to the latest feture release or its latest patch release.
84+
85+
- Self-hosted fixes are released in a patch version `v2025-09.1-patch1` even if the local-first app does not have any changes. The auto-update message ignores patch versions, therefor the self-hosted version does not interfere with the local-first experience.
86+
87+
- The self-hosted version does not publish an image with rc tag of the next feature release. New features are meant to be tested in the local-first app.
88+
89+
- The self-hosted version do publish images with the debug tag. It is crucial to grow robust support for all environments. These are meant to be installed on request and the contents are not publicly documented.
90+
91+
- The self-hosted version may introduce new features in a patch tag. Only for new features that are strictly related to the self-hosted experience.

cli/pkg/server/k8s_proxy.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package server
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"fmt"
57
"io"
68
"log"
@@ -58,6 +60,40 @@ func NewKubernetesProxy(k8sClient *kubernetes.Client) (*KubernetesProxy, error)
5860
// Set the custom transport with TLS config
5961
proxy.Transport = transport
6062

63+
// Strip metadata.managedFields from JSON responses
64+
proxy.ModifyResponse = func(resp *http.Response) error {
65+
// Skip if this is a watch stream; we don't buffer streaming responses here
66+
if resp != nil && resp.Request != nil {
67+
q := resp.Request.URL.Query().Get("watch")
68+
if strings.EqualFold(q, "true") || q == "1" {
69+
return nil
70+
}
71+
}
72+
73+
// Skip compressed or non-JSON responses
74+
if enc := resp.Header.Get("Content-Encoding"); enc != "" && enc != "identity" {
75+
return nil
76+
}
77+
ct := resp.Header.Get("Content-Type")
78+
if !strings.Contains(ct, "json") {
79+
return nil
80+
}
81+
82+
// Read, filter, and replace body
83+
body, err := io.ReadAll(resp.Body)
84+
if err != nil {
85+
return err
86+
}
87+
_ = resp.Body.Close()
88+
89+
filtered := stripManagedFieldsFromBytes(body)
90+
91+
resp.Body = io.NopCloser(bytes.NewReader(filtered))
92+
resp.ContentLength = int64(len(filtered))
93+
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(filtered)))
94+
return nil
95+
}
96+
6197
// Customize error handling
6298
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
6399
log.Printf("Error proxying request to Kubernetes API: %v", err)
@@ -100,3 +136,45 @@ func (p *KubernetesProxy) HandleAPIRequest(c echo.Context) error {
100136

101137
return nil
102138
}
139+
140+
// stripManagedFieldsFromBytes removes metadata.managedFields fields from any JSON structure.
141+
// On error, it returns the original input.
142+
func stripManagedFieldsFromBytes(in []byte) []byte {
143+
if len(in) == 0 {
144+
return in
145+
}
146+
var v interface{}
147+
if err := json.Unmarshal(in, &v); err != nil {
148+
return in
149+
}
150+
removeManagedFieldsFromAny(&v)
151+
b, err := json.Marshal(v)
152+
if err != nil {
153+
return in
154+
}
155+
return b
156+
}
157+
158+
// removeManagedFieldsFromAny walks arbitrarily nested maps/slices and deletes metadata.managedFields.
159+
func removeManagedFieldsFromAny(v *interface{}) {
160+
switch t := (*v).(type) {
161+
case map[string]interface{}:
162+
if meta, ok := t["metadata"].(map[string]interface{}); ok {
163+
delete(meta, "managedFields")
164+
}
165+
for k, child := range t {
166+
// Recurse into children
167+
c := interface{}(child)
168+
removeManagedFieldsFromAny(&c)
169+
t[k] = c
170+
}
171+
case []interface{}:
172+
for i, child := range t {
173+
c := interface{}(child)
174+
removeManagedFieldsFromAny(&c)
175+
t[i] = c
176+
}
177+
default:
178+
// primitives: nothing to do
179+
}
180+
}

cli/pkg/server/server.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ func (s *Server) Setup() {
110110
return func(c echo.Context) error {
111111
ctxName := c.Param("context")
112112
if strings.TrimSpace(ctxName) != "" {
113+
// URL decode the context name to handle special characters like @
114+
ctxName, err := url.PathUnescape(ctxName)
115+
if err != nil {
116+
return c.JSON(http.StatusBadRequest, map[string]string{
117+
"error": fmt.Sprintf("failed to decode context name: %v", err),
118+
})
119+
}
113120
proxy, err := s.getOrCreateK8sProxyForContext(ctxName)
114121
if err != nil {
115122
status := http.StatusInternalServerError
@@ -154,6 +161,14 @@ func (s *Server) Setup() {
154161
})
155162
}
156163

164+
// URL decode the context name to handle special characters like @
165+
ctxName, err := url.PathUnescape(ctxName)
166+
if err != nil {
167+
return c.JSON(http.StatusBadRequest, map[string]string{
168+
"error": fmt.Sprintf("failed to decode context name: %v", err),
169+
})
170+
}
171+
157172
proxy, err := s.getOrCreateK8sProxyForContext(ctxName)
158173
if err != nil {
159174
status := http.StatusInternalServerError
@@ -1062,6 +1077,29 @@ func (s *Server) Setup() {
10621077
})
10631078
})
10641079

1080+
// Add endpoints for listing Helm releases (context-aware) with optional Table response
1081+
// Support trailing resource segment to match client list path construction
1082+
s.echo.GET("/api/:context/helm/releases/releases", func(c echo.Context) error {
1083+
proxy, ok := getProxyFromContext(c)
1084+
if !ok {
1085+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "missing proxy in context"})
1086+
}
1087+
return s.handleHelmReleasesList(c, proxy, "")
1088+
})
1089+
1090+
// Support trailing resource segment for namespaced path as well
1091+
s.echo.GET("/api/:context/helm/releases/namespaces/:namespace/releases", func(c echo.Context) error {
1092+
proxy, ok := getProxyFromContext(c)
1093+
if !ok {
1094+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "missing proxy in context"})
1095+
}
1096+
ns := c.Param("namespace")
1097+
if strings.EqualFold(ns, "all-namespaces") {
1098+
ns = ""
1099+
}
1100+
return s.handleHelmReleasesList(c, proxy, ns)
1101+
})
1102+
10651103
// Add endpoint for Helm release rollback (context-aware)
10661104
s.echo.POST("/api/:context/helm/rollback/:namespace/:name/:revision", func(c echo.Context) error {
10671105
proxy, ok := getProxyFromContext(c)
@@ -1169,6 +1207,124 @@ func (s *Server) getOrCreateK8sProxyForContext(contextName string) (*KubernetesP
11691207
return proxy, nil
11701208
}
11711209

1210+
// handleHelmReleasesList lists Helm releases and returns either a Kubernetes Table or a plain List
1211+
func (s *Server) handleHelmReleasesList(c echo.Context, proxy *KubernetesProxy, namespace string) error {
1212+
hc, err := helm.NewClient(proxy.k8sClient.Config, "")
1213+
if err != nil {
1214+
return c.JSON(http.StatusInternalServerError, map[string]string{
1215+
"error": fmt.Sprintf("failed to create helm client: %v", err),
1216+
})
1217+
}
1218+
1219+
releases, err := hc.ListReleases(c.Request().Context(), namespace)
1220+
if err != nil {
1221+
return c.JSON(http.StatusInternalServerError, map[string]string{
1222+
"error": fmt.Sprintf("Failed to list Helm releases: %v", err),
1223+
})
1224+
}
1225+
1226+
accept := c.Request().Header.Get("Accept")
1227+
if strings.Contains(accept, "as=Table;g=meta.k8s.io;v=v1") {
1228+
table := buildHelmReleasesTable(releases)
1229+
return c.JSON(http.StatusOK, table)
1230+
}
1231+
1232+
// Default JSON list fallback
1233+
items := make([]map[string]interface{}, 0, len(releases))
1234+
for _, rel := range releases {
1235+
items = append(items, buildHelmReleaseObject(rel))
1236+
}
1237+
return c.JSON(http.StatusOK, map[string]interface{}{
1238+
"kind": "List",
1239+
"apiVersion": "v1",
1240+
"items": items,
1241+
})
1242+
}
1243+
1244+
// buildHelmReleaseObject converts a Helm release to a Kubernetes-like object
1245+
func buildHelmReleaseObject(release *helm.Release) map[string]interface{} {
1246+
return map[string]interface{}{
1247+
"apiVersion": "helm.sh/v3",
1248+
"kind": "Release",
1249+
"metadata": map[string]interface{}{
1250+
"name": release.Name,
1251+
"namespace": release.Namespace,
1252+
"creationTimestamp": release.Updated.Format(time.RFC3339),
1253+
},
1254+
"spec": map[string]interface{}{
1255+
"chart": release.Chart,
1256+
"chartVersion": release.ChartVersion,
1257+
"values": release.Values,
1258+
},
1259+
"status": map[string]interface{}{
1260+
"status": release.Status,
1261+
"revision": release.Revision,
1262+
"appVersion": release.AppVersion,
1263+
"notes": release.Notes,
1264+
},
1265+
}
1266+
}
1267+
1268+
// buildHelmReleasesTable constructs a meta.k8s.io/v1 Table response for Helm releases
1269+
func buildHelmReleasesTable(releases []*helm.Release) map[string]interface{} {
1270+
columnDefinitions := []map[string]interface{}{
1271+
{"name": "Name", "type": "string", "format": "name"},
1272+
{"name": "Chart", "type": "string"},
1273+
{"name": "App Version", "type": "string"},
1274+
{"name": "Status", "type": "string"},
1275+
{"name": "Revision", "type": "string"},
1276+
{"name": "Age", "type": "string"},
1277+
}
1278+
1279+
rows := make([]map[string]interface{}, 0, len(releases))
1280+
now := time.Now()
1281+
for _, rel := range releases {
1282+
age := humanizeDuration(now.Sub(rel.Updated))
1283+
chart := rel.Chart
1284+
if rel.ChartVersion != "" {
1285+
chart = fmt.Sprintf("%s (%s)", rel.Chart, rel.ChartVersion)
1286+
}
1287+
cells := []interface{}{
1288+
rel.Name,
1289+
chart,
1290+
rel.AppVersion,
1291+
rel.Status,
1292+
fmt.Sprintf("%d", rel.Revision),
1293+
age,
1294+
}
1295+
rows = append(rows, map[string]interface{}{
1296+
"cells": cells,
1297+
"object": buildHelmReleaseObject(rel),
1298+
})
1299+
}
1300+
1301+
return map[string]interface{}{
1302+
"kind": "Table",
1303+
"apiVersion": "meta.k8s.io/v1",
1304+
"columnDefinitions": columnDefinitions,
1305+
"rows": rows,
1306+
}
1307+
}
1308+
1309+
// humanizeDuration returns a short human-readable duration like 5m, 2h, 3d
1310+
func humanizeDuration(d time.Duration) string {
1311+
if d < time.Minute {
1312+
s := int(d.Seconds())
1313+
if s <= 0 {
1314+
return "0s"
1315+
}
1316+
return fmt.Sprintf("%ds", s)
1317+
}
1318+
if d < time.Hour {
1319+
return fmt.Sprintf("%dm", int(d.Minutes()))
1320+
}
1321+
if d < 24*time.Hour {
1322+
return fmt.Sprintf("%dh", int(d.Hours()))
1323+
}
1324+
days := int(d.Hours()) / 24
1325+
return fmt.Sprintf("%dd", days)
1326+
}
1327+
11721328
// downloadAndExtractArtifact downloads and extracts a Flux source artifact
11731329
func (s *Server) downloadAndExtractArtifact(ctx context.Context, client *kubernetes.Client, artifactURL string) (string, error) {
11741330
// Create a temporary directory

0 commit comments

Comments
 (0)