blob: 938d08fcd25d7f3a103e72d1499d9ac818e54273 [file] [log] [blame]
giolekva8aa73e82022-07-09 11:34:39 +04001package installer
giolekva050609f2021-12-29 15:51:40 +04002
giolekva8aa73e82022-07-09 11:34:39 +04003import (
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +04004 "archive/tar"
Giorgi Lekveishvili3f697b12024-01-04 00:56:06 +04005 "bytes"
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +04006 "compress/gzip"
giolekva8aa73e82022-07-09 11:34:39 +04007 "embed"
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +04008 "encoding/json"
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +04009 "fmt"
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +040010 template "html/template"
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +040011 "io"
giolekva8aa73e82022-07-09 11:34:39 +040012 "log"
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +040013 "net/http"
Giorgi Lekveishvili4257b902023-07-07 17:08:42 +040014 "strings"
Giorgi Lekveishvili0ccd1482023-06-21 15:02:24 +040015
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +040016 "cuelang.org/go/cue"
17 "cuelang.org/go/cue/cuecontext"
18 cueyaml "cuelang.org/go/encoding/yaml"
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +040019 "github.com/go-git/go-billy/v5"
20 "sigs.k8s.io/yaml"
giolekva8aa73e82022-07-09 11:34:39 +040021)
giolekva050609f2021-12-29 15:51:40 +040022
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +040023//go:embed values-tmpl
24var valuesTmpls embed.FS
25
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +040026var storeAppConfigs = []string{
27 "values-tmpl/jellyfin.cue",
28 // "values-tmpl/maddy.cue",
29 "values-tmpl/matrix.cue",
30 "values-tmpl/penpot.cue",
31 "values-tmpl/pihole.cue",
32 "values-tmpl/qbittorrent.cue",
33 "values-tmpl/rpuppy.cue",
34 "values-tmpl/soft-serve.cue",
35 "values-tmpl/vaultwarden.cue",
DTabidze09935812024-03-13 13:47:39 +040036 "values-tmpl/url-shortener.cue",
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +040037}
38
39var infraAppConfigs = []string{
40 "values-tmpl/appmanager.cue",
41 "values-tmpl/cert-manager.cue",
42 "values-tmpl/certificate-issuer-private.cue",
43 "values-tmpl/certificate-issuer-public.cue",
44 "values-tmpl/config-repo.cue",
45 "values-tmpl/core-auth.cue",
46 "values-tmpl/csi-driver-smb.cue",
47 "values-tmpl/dns-zone-manager.cue",
48 "values-tmpl/env-manager.cue",
49 "values-tmpl/fluxcd-reconciler.cue",
50 "values-tmpl/headscale-controller.cue",
51 "values-tmpl/headscale-user.cue",
52 "values-tmpl/headscale.cue",
53 "values-tmpl/ingress-public.cue",
54 "values-tmpl/metallb-ipaddresspool.cue",
55 "values-tmpl/private-network.cue",
56 "values-tmpl/resource-renderer-controller.cue",
57 "values-tmpl/welcome.cue",
DTabidze0d802592024-03-19 17:42:45 +040058 "values-tmpl/memberships.cue",
Giorgi Lekveishvili925f0de2024-03-14 18:51:56 +040059 "values-tmpl/hydra-maester.cue",
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +040060}
61
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +040062// TODO(gio): import
63const cueBaseConfig = `
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +040064name: string | *""
65description: string | *""
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +040066readme: string | *""
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +040067icon: string | *""
68namespace: string | *""
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +040069
Giorgi Lekveishvilia09fad72024-03-21 15:24:35 +040070#Auth: {
71 enabled: bool | *false // TODO(gio): enabled by default?
72 groups: string | *"" // TODO(gio): []string
73}
74
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +040075#Network: {
76 name: string
77 ingressClass: string
78 certificateIssuer: string | *""
79 domain: string
80}
81
82#Image: {
83 registry: string | *"docker.io"
84 repository: string
85 name: string
86 tag: string
87 pullPolicy: string | *"IfNotPresent"
88 imageName: "\(repository)/\(name)"
89 fullName: "\(registry)/\(imageName)"
90 fullNameWithTag: "\(fullName):\(tag)"
91}
92
93#Chart: {
94 chart: string
95 sourceRef: #SourceRef
96}
97
98#SourceRef: {
99 kind: "GitRepository" | "HelmRepository"
100 name: string
101 namespace: string // TODO(gio): default global.id
102}
103
104#Global: {
105 id: string | *""
106 pcloudEnvName: string | *""
107 domain: string | *""
108 privateDomain: string | *""
109 namespacePrefix: string | *""
110 ...
111}
112
113#Release: {
114 namespace: string
115}
116
117global: #Global
118release: #Release
119
120_ingressPrivate: "\(global.id)-ingress-private"
121_ingressPublic: "\(global.pcloudEnvName)-ingress-public"
122_issuerPrivate: "\(global.id)-private"
123_issuerPublic: "\(global.id)-public"
124
125images: {
126 for key, value in images {
127 "\(key)": #Image & value
128 }
129}
130
131charts: {
132 for key, value in charts {
133 "\(key)": #Chart & value
134 }
135}
136
137#ResourceReference: {
138 name: string
139 namespace: string
140}
141
142#Helm: {
143 name: string
Giorgi Lekveishvilia09fad72024-03-21 15:24:35 +0400144 dependsOn: [...#ResourceReference] | *[]
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400145 ...
146}
147
Giorgi Lekveishvili0ba5e402024-03-20 15:56:30 +0400148helmValidate: {
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400149 for key, value in helm {
150 "\(key)": #Helm & value & {
151 name: key
152 }
153 }
154}
155
156#HelmRelease: {
157 _name: string
158 _chart: #Chart
159 _values: _
Giorgi Lekveishvilia09fad72024-03-21 15:24:35 +0400160 _dependencies: [...#ResourceReference] | *[]
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400161
162 apiVersion: "helm.toolkit.fluxcd.io/v2beta1"
163 kind: "HelmRelease"
164 metadata: {
165 name: _name
166 namespace: release.namespace
167 }
168 spec: {
169 interval: "1m0s"
Giorgi Lekveishvilia09fad72024-03-21 15:24:35 +0400170 dependsOn: _dependencies
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400171 chart: {
172 spec: _chart
173 }
174 values: _values
175 }
176}
177
178output: {
Giorgi Lekveishvili0ba5e402024-03-20 15:56:30 +0400179 for name, r in helmValidate {
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400180 "\(name)": #HelmRelease & {
181 _name: name
182 _chart: r.chart
183 _values: r.values
184 _dependencies: r.dependsOn
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400185 }
186 }
187}
188`
189
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400190type appConfig struct {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400191 Name string `json:"name"`
192 Version string `json:"version"`
193 Description string `json:"description"`
194 Namespaces []string `json:"namespaces"`
195 Icon template.HTML `json:"icon"`
Giorgi Lekveishvili4257b902023-07-07 17:08:42 +0400196}
197
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +0400198type Rendered struct {
199 Readme string
200 Resources map[string][]byte
201}
202
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400203type App interface {
204 Name() string
205 Description() string
206 Icon() template.HTML
207 Schema() Schema
208 Namespaces() []string
209 Render(derived Derived) (Rendered, error)
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400210}
211
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400212type cueApp struct {
213 name string
214 description string
215 icon template.HTML
216 namespace string
217 schema Schema
218 cfg *cue.Value
219}
220
221type cueAppConfig struct {
222 Name string `json:"name"`
223 Namespace string `json:"namespace"`
224 Description string `json:"description"`
225 Icon string `json:"icon"`
226}
227
228func newCueApp(config *cue.Value) (cueApp, error) {
229 if config == nil {
230 return cueApp{}, fmt.Errorf("config not provided")
231 }
232 var cfg cueAppConfig
233 if err := config.Decode(&cfg); err != nil {
234 return cueApp{}, err
235 }
236 schema, err := NewCueSchema(config.LookupPath(cue.ParsePath("input")))
237 if err != nil {
238 return cueApp{}, err
239 }
240 return cueApp{
241 name: cfg.Name,
242 description: cfg.Description,
243 icon: template.HTML(cfg.Icon),
244 namespace: cfg.Namespace,
245 schema: schema,
246 cfg: config,
247 }, nil
248}
249
250func (a cueApp) Name() string {
251 return a.name
252}
253
254func (a cueApp) Description() string {
255 return a.description
256}
257
258func (a cueApp) Icon() template.HTML {
259 return a.icon
260}
261
262func (a cueApp) Schema() Schema {
263 return a.schema
264}
265
266func (a cueApp) Namespaces() []string {
267 return []string{a.namespace}
268}
269
270func (a cueApp) Render(derived Derived) (Rendered, error) {
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +0400271 ret := Rendered{
272 Resources: make(map[string][]byte),
273 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400274 var buf bytes.Buffer
275 if err := json.NewEncoder(&buf).Encode(derived); err != nil {
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +0400276 return Rendered{}, err
277 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400278 ctx := a.cfg.Context()
279 d := ctx.CompileBytes(buf.Bytes())
280 res := a.cfg.Unify(d).Eval()
281 if err := res.Err(); err != nil {
282 return Rendered{}, err
283 }
284 if err := res.Validate(); err != nil {
285 return Rendered{}, err
286 }
287 readme, err := res.LookupPath(cue.ParsePath("readme")).String()
288 if err != nil {
289 return Rendered{}, err
290 }
291 ret.Readme = readme
292 output := res.LookupPath(cue.ParsePath("output"))
293 i, err := output.Fields()
294 if err != nil {
295 return Rendered{}, err
296 }
297 for i.Next() {
Giorgi Lekveishvilia09fad72024-03-21 15:24:35 +0400298 if contents, err := cueyaml.Encode(i.Value()); err != nil {
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +0400299 return Rendered{}, err
Giorgi Lekveishvilia09fad72024-03-21 15:24:35 +0400300 } else {
301 name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
302 ret.Resources[name] = contents
Giorgi Lekveishvili3f697b12024-01-04 00:56:06 +0400303 }
Giorgi Lekveishvili3f697b12024-01-04 00:56:06 +0400304 }
305 return ret, nil
306}
307
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400308type AppRepository interface {
309 GetAll() ([]App, error)
310 Find(name string) (App, error)
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +0400311}
312
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400313type InMemoryAppRepository struct {
314 apps []App
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +0400315}
316
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400317func NewInMemoryAppRepository(apps []App) InMemoryAppRepository {
318 return InMemoryAppRepository{apps}
Giorgi Lekveishvili27b2b572023-06-30 10:44:45 +0400319}
320
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400321func (r InMemoryAppRepository) Find(name string) (App, error) {
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +0400322 for _, a := range r.apps {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400323 if a.Name() == name {
324 return a, nil
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +0400325 }
326 }
327 return nil, fmt.Errorf("Application not found: %s", name)
328}
giolekva8aa73e82022-07-09 11:34:39 +0400329
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400330func (r InMemoryAppRepository) GetAll() ([]App, error) {
Giorgi Lekveishvili7efe22f2023-05-30 13:01:53 +0400331 return r.apps, nil
332}
333
giolekva8aa73e82022-07-09 11:34:39 +0400334func CreateAllApps() []App {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400335 return append(
336 createApps(infraAppConfigs),
337 CreateStoreApps()...,
338 )
339}
340
341func CreateStoreApps() []App {
342 return createApps(storeAppConfigs)
343}
344
345func createApps(configs []string) []App {
Giorgi Lekveishvili186eae52024-02-15 14:21:41 +0400346 ret := make([]App, 0)
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400347 for _, cfgFile := range configs {
348 cfg, err := readCueConfigFromFile(valuesTmpls, cfgFile)
349 if err != nil {
350 panic(err)
351 }
352 if app, err := newCueApp(cfg); err != nil {
353 panic(err)
354 } else {
355 ret = append(ret, app)
356 }
Giorgi Lekveishvili27b2b572023-06-30 10:44:45 +0400357 }
358 return ret
359}
360
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400361// func CreateAppMaddy(fs embed.FS, tmpls *template.Template) App {
362// schema, err := readJSONSchemaFromFile(fs, "values-tmpl/maddy.jsonschema")
363// if err != nil {
364// panic(err)
365// }
366// return StoreApp{
367// App{
368// "maddy",
369// []string{"app-maddy"},
370// []*template.Template{
371// tmpls.Lookup("maddy.yaml"),
372// },
373// schema,
374// nil,
375// nil,
376// },
377// `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 48 48"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M9.5 13c13.687 13.574 14.825 13.09 29 0"/><rect width="37" height="31" x="5.5" y="8.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" rx="2"/></svg>`,
378// "SMPT/IMAP server to communicate via email.",
379// }
380// }
Giorgi Lekveishvili2df23db2023-12-14 07:55:22 +0400381
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400382type httpAppRepository struct {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400383 apps []App
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400384}
385
386type appVersion struct {
387 Version string `json:"version"`
388 Urls []string `json:"urls"`
389}
390
391type allAppsResp struct {
392 ApiVersion string `json:"apiVersion"`
393 Entries map[string][]appVersion `json:"entries"`
394}
395
396func FetchAppsFromHTTPRepository(addr string, fs billy.Filesystem) error {
397 resp, err := http.Get(addr)
398 if err != nil {
399 return err
400 }
401 b, err := io.ReadAll(resp.Body)
402 if err != nil {
403 return err
404 }
405 var apps allAppsResp
406 if err := yaml.Unmarshal(b, &apps); err != nil {
407 return err
408 }
409 for name, conf := range apps.Entries {
410 for _, version := range conf {
411 resp, err := http.Get(version.Urls[0])
412 if err != nil {
413 return err
414 }
415 nameVersion := fmt.Sprintf("%s-%s", name, version.Version)
416 if err := fs.MkdirAll(nameVersion, 0700); err != nil {
417 return err
418 }
419 sub, err := fs.Chroot(nameVersion)
420 if err != nil {
421 return err
422 }
423 if err := extractApp(resp.Body, sub); err != nil {
424 return err
425 }
426 }
427 }
428 return nil
429}
430
431func extractApp(archive io.Reader, fs billy.Filesystem) error {
432 uncompressed, err := gzip.NewReader(archive)
433 if err != nil {
434 return err
435 }
436 tarReader := tar.NewReader(uncompressed)
437 for true {
438 header, err := tarReader.Next()
439 if err == io.EOF {
440 break
441 }
442 if err != nil {
443 return err
444 }
445 switch header.Typeflag {
446 case tar.TypeDir:
447 if err := fs.MkdirAll(header.Name, 0755); err != nil {
448 return err
449 }
450 case tar.TypeReg:
451 out, err := fs.Create(header.Name)
452 if err != nil {
453 return err
454 }
455 defer out.Close()
456 if _, err := io.Copy(out, tarReader); err != nil {
457 return err
458 }
459 default:
460 return fmt.Errorf("Uknown type: %s", header.Name)
461 }
462 }
463 return nil
464}
465
466type fsAppRepository struct {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400467 InMemoryAppRepository
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400468 fs billy.Filesystem
469}
470
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400471func NewFSAppRepository(fs billy.Filesystem) (AppRepository, error) {
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400472 all, err := fs.ReadDir(".")
473 if err != nil {
474 return nil, err
475 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400476 apps := make([]App, 0)
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400477 for _, e := range all {
478 if !e.IsDir() {
479 continue
480 }
481 appFS, err := fs.Chroot(e.Name())
482 if err != nil {
483 return nil, err
484 }
485 app, err := loadApp(appFS)
486 if err != nil {
487 log.Printf("Ignoring directory %s: %s", e.Name(), err)
488 continue
489 }
490 apps = append(apps, app)
491 }
492 return &fsAppRepository{
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400493 NewInMemoryAppRepository(apps),
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400494 fs,
495 }, nil
496}
497
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400498func loadApp(fs billy.Filesystem) (App, error) {
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400499 items, err := fs.ReadDir(".")
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400500 if err != nil {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400501 return nil, err
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400502 }
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400503 var contents bytes.Buffer
504 for _, i := range items {
505 if i.IsDir() {
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400506 continue
507 }
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400508 f, err := fs.Open(i.Name())
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400509 if err != nil {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400510 return nil, err
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400511 }
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400512 defer f.Close()
513 if _, err := io.Copy(&contents, f); err != nil {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400514 return nil, err
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400515 }
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400516 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400517 cfg, err := processCueConfig(contents.String())
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400518 if err != nil {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400519 return nil, err
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400520 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400521 return newCueApp(cfg)
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400522}
523
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400524func cleanName(s string) string {
525 return strings.ReplaceAll(strings.ReplaceAll(s, "\"", ""), "'", "")
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400526}
527
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400528func processCueConfig(contents string) (*cue.Value, error) {
529 ctx := cuecontext.New()
Giorgi Lekveishvilia09fad72024-03-21 15:24:35 +0400530 cfg := ctx.CompileString(contents + cueBaseConfig)
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400531 if err := cfg.Err(); err != nil {
532 return nil, err
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400533 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400534 if err := cfg.Validate(); err != nil {
535 return nil, err
536 }
537 return &cfg, nil
538}
539
540func readCueConfigFromFile(fs embed.FS, f string) (*cue.Value, error) {
541 contents, err := fs.ReadFile(f)
542 if err != nil {
543 return nil, err
544 }
545 return processCueConfig(string(contents))
546}
547
548func createApp(fs embed.FS, configFile string) App {
549 cfg, err := readCueConfigFromFile(fs, configFile)
550 if err != nil {
551 panic(err)
552 }
553 if app, err := newCueApp(cfg); err != nil {
554 panic(err)
555 } else {
556 return app
557 }
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400558}