blob: 8cfb2b52514d5de10095025d6b4cd85a5c3b7435 [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 +040062const cueBaseConfigImports = `
63import (
64 "list"
65)
66`
67
68// TODO(gio): import
69const cueBaseConfig = `
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +040070name: string | *""
71description: string | *""
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +040072readme: string | *""
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +040073icon: string | *""
74namespace: string | *""
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +040075
76#Network: {
77 name: string
78 ingressClass: string
79 certificateIssuer: string | *""
80 domain: string
81}
82
83#Image: {
84 registry: string | *"docker.io"
85 repository: string
86 name: string
87 tag: string
88 pullPolicy: string | *"IfNotPresent"
89 imageName: "\(repository)/\(name)"
90 fullName: "\(registry)/\(imageName)"
91 fullNameWithTag: "\(fullName):\(tag)"
92}
93
94#Chart: {
95 chart: string
96 sourceRef: #SourceRef
97}
98
99#SourceRef: {
100 kind: "GitRepository" | "HelmRepository"
101 name: string
102 namespace: string // TODO(gio): default global.id
103}
104
105#Global: {
106 id: string | *""
107 pcloudEnvName: string | *""
108 domain: string | *""
109 privateDomain: string | *""
110 namespacePrefix: string | *""
111 ...
112}
113
114#Release: {
115 namespace: string
116}
117
118global: #Global
119release: #Release
120
121_ingressPrivate: "\(global.id)-ingress-private"
122_ingressPublic: "\(global.pcloudEnvName)-ingress-public"
123_issuerPrivate: "\(global.id)-private"
124_issuerPublic: "\(global.id)-public"
125
126images: {
127 for key, value in images {
128 "\(key)": #Image & value
129 }
130}
131
132charts: {
133 for key, value in charts {
134 "\(key)": #Chart & value
135 }
136}
137
138#ResourceReference: {
139 name: string
140 namespace: string
141}
142
143#Helm: {
144 name: string
145 dependsOn: [...#Helm] | *[]
146 dependsOnExternal: [...#ResourceReference] | *[]
147 ...
148}
149
Giorgi Lekveishvili0ba5e402024-03-20 15:56:30 +0400150helmValidate: {
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400151 for key, value in helm {
152 "\(key)": #Helm & value & {
153 name: key
154 }
155 }
156}
157
158#HelmRelease: {
159 _name: string
160 _chart: #Chart
161 _values: _
162 _dependencies: [...#Helm] | *[]
163 _externalDependencies: [...#ResourceReference] | *[]
164
165 apiVersion: "helm.toolkit.fluxcd.io/v2beta1"
166 kind: "HelmRelease"
167 metadata: {
168 name: _name
169 namespace: release.namespace
170 }
171 spec: {
172 interval: "1m0s"
173 dependsOn: list.Concat([_externalDependencies, [
174 for d in _dependencies {
175 name: d.name
176 namespace: release.namespace
177 }
178 ]])
179 chart: {
180 spec: _chart
181 }
182 values: _values
183 }
184}
185
186output: {
Giorgi Lekveishvili0ba5e402024-03-20 15:56:30 +0400187 for name, r in helmValidate {
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400188 "\(name)": #HelmRelease & {
189 _name: name
190 _chart: r.chart
191 _values: r.values
192 _dependencies: r.dependsOn
193 _externalDependencies: r.dependsOnExternal
194 }
195 }
196}
197`
198
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400199type appConfig struct {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400200 Name string `json:"name"`
201 Version string `json:"version"`
202 Description string `json:"description"`
203 Namespaces []string `json:"namespaces"`
204 Icon template.HTML `json:"icon"`
Giorgi Lekveishvili4257b902023-07-07 17:08:42 +0400205}
206
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +0400207type Rendered struct {
208 Readme string
209 Resources map[string][]byte
210}
211
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400212type App interface {
213 Name() string
214 Description() string
215 Icon() template.HTML
216 Schema() Schema
217 Namespaces() []string
218 Render(derived Derived) (Rendered, error)
Giorgi Lekveishvilie009a5d2024-01-05 14:10:11 +0400219}
220
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400221type cueApp struct {
222 name string
223 description string
224 icon template.HTML
225 namespace string
226 schema Schema
227 cfg *cue.Value
228}
229
230type cueAppConfig struct {
231 Name string `json:"name"`
232 Namespace string `json:"namespace"`
233 Description string `json:"description"`
234 Icon string `json:"icon"`
235}
236
237func newCueApp(config *cue.Value) (cueApp, error) {
238 if config == nil {
239 return cueApp{}, fmt.Errorf("config not provided")
240 }
241 var cfg cueAppConfig
242 if err := config.Decode(&cfg); err != nil {
243 return cueApp{}, err
244 }
245 schema, err := NewCueSchema(config.LookupPath(cue.ParsePath("input")))
246 if err != nil {
247 return cueApp{}, err
248 }
249 return cueApp{
250 name: cfg.Name,
251 description: cfg.Description,
252 icon: template.HTML(cfg.Icon),
253 namespace: cfg.Namespace,
254 schema: schema,
255 cfg: config,
256 }, nil
257}
258
259func (a cueApp) Name() string {
260 return a.name
261}
262
263func (a cueApp) Description() string {
264 return a.description
265}
266
267func (a cueApp) Icon() template.HTML {
268 return a.icon
269}
270
271func (a cueApp) Schema() Schema {
272 return a.schema
273}
274
275func (a cueApp) Namespaces() []string {
276 return []string{a.namespace}
277}
278
279func (a cueApp) Render(derived Derived) (Rendered, error) {
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +0400280 ret := Rendered{
281 Resources: make(map[string][]byte),
282 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400283 var buf bytes.Buffer
284 if err := json.NewEncoder(&buf).Encode(derived); err != nil {
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +0400285 return Rendered{}, err
286 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400287 ctx := a.cfg.Context()
288 d := ctx.CompileBytes(buf.Bytes())
289 res := a.cfg.Unify(d).Eval()
290 if err := res.Err(); err != nil {
291 return Rendered{}, err
292 }
293 if err := res.Validate(); err != nil {
294 return Rendered{}, err
295 }
296 readme, err := res.LookupPath(cue.ParsePath("readme")).String()
297 if err != nil {
298 return Rendered{}, err
299 }
300 ret.Readme = readme
301 output := res.LookupPath(cue.ParsePath("output"))
302 i, err := output.Fields()
303 if err != nil {
304 return Rendered{}, err
305 }
306 for i.Next() {
307 name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
308 contents, err := cueyaml.Encode(i.Value())
309 if err != nil {
Giorgi Lekveishvili9b52ab92024-01-05 13:12:48 +0400310 return Rendered{}, err
Giorgi Lekveishvili3f697b12024-01-04 00:56:06 +0400311 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400312 ret.Resources[name] = contents
Giorgi Lekveishvili3f697b12024-01-04 00:56:06 +0400313 }
314 return ret, nil
315}
316
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400317type AppRepository interface {
318 GetAll() ([]App, error)
319 Find(name string) (App, error)
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +0400320}
321
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400322type InMemoryAppRepository struct {
323 apps []App
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +0400324}
325
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400326func NewInMemoryAppRepository(apps []App) InMemoryAppRepository {
327 return InMemoryAppRepository{apps}
Giorgi Lekveishvili27b2b572023-06-30 10:44:45 +0400328}
329
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400330func (r InMemoryAppRepository) Find(name string) (App, error) {
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +0400331 for _, a := range r.apps {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400332 if a.Name() == name {
333 return a, nil
Giorgi Lekveishvilibd6be7f2023-05-26 15:51:28 +0400334 }
335 }
336 return nil, fmt.Errorf("Application not found: %s", name)
337}
giolekva8aa73e82022-07-09 11:34:39 +0400338
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400339func (r InMemoryAppRepository) GetAll() ([]App, error) {
Giorgi Lekveishvili7efe22f2023-05-30 13:01:53 +0400340 return r.apps, nil
341}
342
giolekva8aa73e82022-07-09 11:34:39 +0400343func CreateAllApps() []App {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400344 return append(
345 createApps(infraAppConfigs),
346 CreateStoreApps()...,
347 )
348}
349
350func CreateStoreApps() []App {
351 return createApps(storeAppConfigs)
352}
353
354func createApps(configs []string) []App {
Giorgi Lekveishvili186eae52024-02-15 14:21:41 +0400355 ret := make([]App, 0)
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400356 for _, cfgFile := range configs {
357 cfg, err := readCueConfigFromFile(valuesTmpls, cfgFile)
358 if err != nil {
359 panic(err)
360 }
361 if app, err := newCueApp(cfg); err != nil {
362 panic(err)
363 } else {
364 ret = append(ret, app)
365 }
Giorgi Lekveishvili27b2b572023-06-30 10:44:45 +0400366 }
367 return ret
368}
369
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400370// func CreateAppMaddy(fs embed.FS, tmpls *template.Template) App {
371// schema, err := readJSONSchemaFromFile(fs, "values-tmpl/maddy.jsonschema")
372// if err != nil {
373// panic(err)
374// }
375// return StoreApp{
376// App{
377// "maddy",
378// []string{"app-maddy"},
379// []*template.Template{
380// tmpls.Lookup("maddy.yaml"),
381// },
382// schema,
383// nil,
384// nil,
385// },
386// `<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>`,
387// "SMPT/IMAP server to communicate via email.",
388// }
389// }
Giorgi Lekveishvili2df23db2023-12-14 07:55:22 +0400390
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400391type httpAppRepository struct {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400392 apps []App
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400393}
394
395type appVersion struct {
396 Version string `json:"version"`
397 Urls []string `json:"urls"`
398}
399
400type allAppsResp struct {
401 ApiVersion string `json:"apiVersion"`
402 Entries map[string][]appVersion `json:"entries"`
403}
404
405func FetchAppsFromHTTPRepository(addr string, fs billy.Filesystem) error {
406 resp, err := http.Get(addr)
407 if err != nil {
408 return err
409 }
410 b, err := io.ReadAll(resp.Body)
411 if err != nil {
412 return err
413 }
414 var apps allAppsResp
415 if err := yaml.Unmarshal(b, &apps); err != nil {
416 return err
417 }
418 for name, conf := range apps.Entries {
419 for _, version := range conf {
420 resp, err := http.Get(version.Urls[0])
421 if err != nil {
422 return err
423 }
424 nameVersion := fmt.Sprintf("%s-%s", name, version.Version)
425 if err := fs.MkdirAll(nameVersion, 0700); err != nil {
426 return err
427 }
428 sub, err := fs.Chroot(nameVersion)
429 if err != nil {
430 return err
431 }
432 if err := extractApp(resp.Body, sub); err != nil {
433 return err
434 }
435 }
436 }
437 return nil
438}
439
440func extractApp(archive io.Reader, fs billy.Filesystem) error {
441 uncompressed, err := gzip.NewReader(archive)
442 if err != nil {
443 return err
444 }
445 tarReader := tar.NewReader(uncompressed)
446 for true {
447 header, err := tarReader.Next()
448 if err == io.EOF {
449 break
450 }
451 if err != nil {
452 return err
453 }
454 switch header.Typeflag {
455 case tar.TypeDir:
456 if err := fs.MkdirAll(header.Name, 0755); err != nil {
457 return err
458 }
459 case tar.TypeReg:
460 out, err := fs.Create(header.Name)
461 if err != nil {
462 return err
463 }
464 defer out.Close()
465 if _, err := io.Copy(out, tarReader); err != nil {
466 return err
467 }
468 default:
469 return fmt.Errorf("Uknown type: %s", header.Name)
470 }
471 }
472 return nil
473}
474
475type fsAppRepository struct {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400476 InMemoryAppRepository
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400477 fs billy.Filesystem
478}
479
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400480func NewFSAppRepository(fs billy.Filesystem) (AppRepository, error) {
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400481 all, err := fs.ReadDir(".")
482 if err != nil {
483 return nil, err
484 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400485 apps := make([]App, 0)
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400486 for _, e := range all {
487 if !e.IsDir() {
488 continue
489 }
490 appFS, err := fs.Chroot(e.Name())
491 if err != nil {
492 return nil, err
493 }
494 app, err := loadApp(appFS)
495 if err != nil {
496 log.Printf("Ignoring directory %s: %s", e.Name(), err)
497 continue
498 }
499 apps = append(apps, app)
500 }
501 return &fsAppRepository{
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400502 NewInMemoryAppRepository(apps),
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400503 fs,
504 }, nil
505}
506
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400507func loadApp(fs billy.Filesystem) (App, error) {
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400508 items, err := fs.ReadDir(".")
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 var contents bytes.Buffer
513 for _, i := range items {
514 if i.IsDir() {
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400515 continue
516 }
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400517 f, err := fs.Open(i.Name())
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400518 if err != nil {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400519 return nil, err
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400520 }
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400521 defer f.Close()
522 if _, err := io.Copy(&contents, f); err != nil {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400523 return nil, err
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400524 }
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400525 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400526 cfg, err := processCueConfig(contents.String())
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400527 if err != nil {
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400528 return nil, err
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400529 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400530 return newCueApp(cfg)
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400531}
532
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400533func cleanName(s string) string {
534 return strings.ReplaceAll(strings.ReplaceAll(s, "\"", ""), "'", "")
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400535}
536
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400537func processCueConfig(contents string) (*cue.Value, error) {
538 ctx := cuecontext.New()
539 cfg := ctx.CompileString(cueBaseConfigImports + contents + cueBaseConfig)
540 if err := cfg.Err(); err != nil {
541 return nil, err
Giorgi Lekveishvilief21c132024-01-17 18:57:58 +0400542 }
Giorgi Lekveishvili08af67a2024-01-18 08:53:05 +0400543 if err := cfg.Validate(); err != nil {
544 return nil, err
545 }
546 return &cfg, nil
547}
548
549func readCueConfigFromFile(fs embed.FS, f string) (*cue.Value, error) {
550 contents, err := fs.ReadFile(f)
551 if err != nil {
552 return nil, err
553 }
554 return processCueConfig(string(contents))
555}
556
557func createApp(fs embed.FS, configFile string) App {
558 cfg, err := readCueConfigFromFile(fs, configFile)
559 if err != nil {
560 panic(err)
561 }
562 if app, err := newCueApp(cfg); err != nil {
563 panic(err)
564 } else {
565 return app
566 }
Giorgi Lekveishvili743fb432023-11-08 17:19:40 +0400567}