Dodo APP: infrastructure to deploy app by pusing to Git repo
Change-Id: I4034c6893255581b014ddb207c844261cb34202b
diff --git a/core/installer/Makefile b/core/installer/Makefile
index 800a8eb..ce9c184 100644
--- a/core/installer/Makefile
+++ b/core/installer/Makefile
@@ -37,6 +37,9 @@
appmanager:
./pcloud --kubeconfig=../../priv/kubeconfig-hetzner appmanager --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://localhost:2222/config --port=9090 # --app-repo-addr=http://localhost:8080
+dodo-app:
+ ./pcloud --kubeconfig=../../priv/kubeconfig-hetzner dodo-app --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://localhost:2222/test
+
welc:
./pcloud --kubeconfig=../../priv/kubeconfig welcome --ssh-key=/Users/lekva/.ssh/id_rsa --repo-addr=ssh://192.168.0.210/config --port=9090
diff --git a/core/installer/app.go b/core/installer/app.go
index 1626a1a..2a16c1e 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -2,6 +2,7 @@
import (
"bytes"
+ _ "embed"
"encoding/json"
"fmt"
template "html/template"
@@ -16,8 +17,15 @@
cueyaml "cuelang.org/go/encoding/yaml"
)
+//go:embed pcloud_app.cue
+var DodoAppCue []byte
+
// TODO(gio): import
const cueEnvAppGlobal = `
+import (
+ "net"
+)
+
#Global: {
id: string | *""
pcloudEnvName: string | *""
@@ -31,20 +39,13 @@
network: #EnvNetwork
}
-networks: {
- public: #Network & {
- name: "Public"
- ingressClass: "\(global.pcloudEnvName)-ingress-public"
- certificateIssuer: "\(global.id)-public"
- domain: global.domain
- allocatePortAddr: "http://port-allocator.\(global.pcloudEnvName)-ingress-public.svc.cluster.local/api/allocate"
- }
- private: #Network & {
- name: "Private"
- ingressClass: "\(global.id)-ingress-private"
- domain: global.privateDomain
- allocatePortAddr: "http://port-allocator.\(global.id)-ingress-private.svc.cluster.local/api/allocate"
- }
+#EnvNetwork: {
+ dns: net.IPv4
+ dnsInClusterIP: net.IPv4
+ ingress: net.IPv4
+ headscale: net.IPv4
+ servicesFrom: net.IPv4
+ servicesTo: net.IPv4
}
// TODO(gio): remove
@@ -164,10 +165,6 @@
`
const cueBaseConfig = `
-import (
- "net"
-)
-
name: string | *""
description: string | *""
readme: string | *""
@@ -187,9 +184,11 @@
#AppType: "infra" | "env"
appType: #AppType | *"env"
-#Auth: {
- enabled: bool | *false // TODO(gio): enabled by default?
- groups: string | *"" // TODO(gio): []string
+#Release: {
+ appInstanceId: string
+ namespace: string
+ repoAddr: string
+ appDir: string
}
#Network: {
@@ -200,6 +199,11 @@
allocatePortAddr: string
}
+#Auth: {
+ enabled: bool | *false // TODO(gio): enabled by default?
+ groups: string | *"" // TODO(gio): []string
+}
+
#Image: {
registry: string | *"docker.io"
repository: string
@@ -222,22 +226,6 @@
namespace: string // TODO(gio): default global.id
}
-#EnvNetwork: {
- dns: net.IPv4
- dnsInClusterIP: net.IPv4
- ingress: net.IPv4
- headscale: net.IPv4
- servicesFrom: net.IPv4
- servicesTo: net.IPv4
-}
-
-#Release: {
- appInstanceId: string
- namespace: string
- repoAddr: string
- appDir: string
-}
-
#PortForward: {
allocator: string
protocol: "TCP" | "UDP" | *"TCP"
@@ -302,6 +290,8 @@
}
}
+resources: {}
+
#HelmRelease: {
_name: string
_chart: #Chart
@@ -349,6 +339,8 @@
help: [...#HelpDocument] | *[]
url: string | *""
+
+networks: {}
`
type rendered struct {
@@ -620,17 +612,34 @@
if err := res.LookupPath(cue.ParsePath("portForward")).Decode(&ret.Ports); err != nil {
return rendered{}, err
}
- output := res.LookupPath(cue.ParsePath("output"))
- i, err := output.Fields()
- if err != nil {
- return rendered{}, err
- }
- for i.Next() {
- if contents, err := cueyaml.Encode(i.Value()); err != nil {
+ {
+ output := res.LookupPath(cue.ParsePath("output"))
+ i, err := output.Fields()
+ if err != nil {
return rendered{}, err
- } else {
- name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
- ret.Resources[name] = contents
+ }
+ for i.Next() {
+ if contents, err := cueyaml.Encode(i.Value()); err != nil {
+ return rendered{}, err
+ } else {
+ name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
+ ret.Resources[name] = contents
+ }
+ }
+ }
+ {
+ resources := res.LookupPath(cue.ParsePath("resources"))
+ i, err := resources.Fields()
+ if err != nil {
+ return rendered{}, err
+ }
+ for i.Next() {
+ if contents, err := cueyaml.Encode(i.Value()); err != nil {
+ return rendered{}, err
+ } else {
+ name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
+ ret.Resources[name] = contents
+ }
}
}
helpValue := res.LookupPath(cue.ParsePath("help"))
@@ -664,6 +673,15 @@
return cueEnvApp{app}, nil
}
+func NewDodoApp(appCfg []byte) (EnvApp, error) {
+ return NewCueEnvApp(CueAppData{
+ "app.cue": appCfg,
+ "base.cue": []byte(cueBaseConfig),
+ "pcloud_app.cue": DodoAppCue,
+ "env_app.cue": []byte(cueEnvAppGlobal),
+ })
+}
+
func (a cueEnvApp) Type() AppType {
return AppTypeEnv
}
@@ -675,9 +693,10 @@
return EnvAppRendered{}, nil
}
ret, err := a.cueApp.render(map[string]any{
- "global": env,
- "release": release,
- "input": derived,
+ "global": env,
+ "release": release,
+ "input": derived,
+ "networks": networkMap(networks),
})
if err != nil {
return EnvAppRendered{}, err
@@ -747,3 +766,11 @@
}
return strings.Join(tmp, ",")
}
+
+func networkMap(networks []Network) map[string]Network {
+ ret := make(map[string]Network)
+ for _, n := range networks {
+ ret[strings.ToLower(n.Name)] = n
+ }
+ return ret
+}
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 44e39e6..ae18ff8 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -178,7 +178,7 @@
}
// TODO(gio): rename to CommitApp
-func InstallApp(
+func installApp(
repo soft.RepoIO,
appDir string,
name string,
@@ -242,7 +242,11 @@
}
// TODO(gio): commit instanceId -> appDir mapping as well
-func (m *AppManager) Install(app EnvApp, instanceId string, appDir string, namespace string, values map[string]any) (ReleaseResources, error) {
+func (m *AppManager) Install(app EnvApp, instanceId string, appDir string, namespace string, values map[string]any, opts ...InstallOption) (ReleaseResources, error) {
+ o := &installOptions{}
+ for _, i := range opts {
+ i(o)
+ }
appDir = filepath.Clean(appDir)
if err := m.repoIO.Pull(); err != nil {
return ReleaseResources{}, err
@@ -250,9 +254,15 @@
if err := m.nsCreator.Create(namespace); err != nil {
return ReleaseResources{}, err
}
- env, err := m.Config()
- if err != nil {
- return ReleaseResources{}, err
+ var env EnvConfig
+ if o.Env != nil {
+ env = *o.Env
+ } else {
+ var err error
+ env, err = m.Config()
+ if err != nil {
+ return ReleaseResources{}, err
+ }
}
release := Release{
AppInstanceId: instanceId,
@@ -264,7 +274,12 @@
if err != nil {
return ReleaseResources{}, err
}
- if _, err := InstallApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data); err != nil {
+ dopts := []soft.DoOption{}
+ if o.Branch != "" {
+ dopts = append(dopts, soft.WithForce())
+ dopts = append(dopts, soft.WithCommitToBranch(o.Branch))
+ }
+ if _, err := installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, dopts...); err != nil {
return ReleaseResources{}, err
}
// TODO(gio): add ingress-nginx to release resources
@@ -278,6 +293,7 @@
type helmRelease struct {
Metadata Resource `json:"metadata"`
+ Kind string `json:"kind"`
Status struct {
Conditions []struct {
Type string `json:"type"`
@@ -293,7 +309,9 @@
if err := yaml.Unmarshal(contents, &h); err != nil {
panic(err) // TODO(gio): handle
}
- ret = append(ret, h.Metadata)
+ if h.Kind == "HelmRelease" {
+ ret = append(ret, h.Metadata)
+ }
}
return ret
}
@@ -322,7 +340,7 @@
if err != nil {
return ReleaseResources{}, err
}
- return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
+ return installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
}
func (m *AppManager) Remove(instanceId string) error {
@@ -368,6 +386,25 @@
nsCreator NamespaceCreator
}
+type installOptions struct {
+ Env *EnvConfig
+ Branch string
+}
+
+type InstallOption func(*installOptions)
+
+func WithConfig(env *EnvConfig) InstallOption {
+ return func(o *installOptions) {
+ o.Env = env
+ }
+}
+
+func WithBranch(branch string) InstallOption {
+ return func(o *installOptions) {
+ o.Branch = branch
+ }
+}
+
func NewInfraAppManager(repoIO soft.RepoIO, nsCreator NamespaceCreator) (*InfraAppManager, error) {
return &InfraAppManager{
repoIO,
@@ -432,7 +469,7 @@
if err != nil {
return ReleaseResources{}, err
}
- return InstallApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data)
+ return installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data)
}
func (m *InfraAppManager) Update(app InfraApp, instanceId string, values map[string]any, opts ...soft.DoOption) (ReleaseResources, error) {
@@ -459,5 +496,5 @@
if err != nil {
return ReleaseResources{}, err
}
- return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
+ return installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
}
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index b51f766..f35eb97 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -18,6 +18,7 @@
var valuesTmpls embed.FS
var storeEnvAppConfigs = []string{
+ "values-tmpl/dodo-app.cue",
"values-tmpl/url-shortener.cue",
"values-tmpl/matrix.cue",
"values-tmpl/vaultwarden.cue",
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 9b59d1c..a646425 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -1,6 +1,7 @@
package installer
import (
+ _ "embed"
"net"
"testing"
)
@@ -298,3 +299,19 @@
t.Log(string(r))
}
}
+
+//go:embed testapp.cue
+var testAppCue []byte
+
+type appInput struct {
+ RepoAddr string `json:"repoAddr"`
+ SSHKey string `json:"sshKey"`
+ Network Network `json:"network"`
+}
+
+func TestPCloudApp(t *testing.T) {
+ _, err := NewDodoApp(testAppCue)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
new file mode 100644
index 0000000..d9f9a69
--- /dev/null
+++ b/core/installer/cmd/dodo_app.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/giolekva/pcloud/core/installer"
+ "github.com/giolekva/pcloud/core/installer/soft"
+ "github.com/giolekva/pcloud/core/installer/welcome"
+
+ "github.com/spf13/cobra"
+)
+
+var dodoAppFlags struct {
+ port int
+ sshKey string
+ repoAddr string
+ self string
+ namespace string
+ envConfig string
+}
+
+func dodoAppCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "dodo-app",
+ RunE: dodoAppCmdRun,
+ }
+ cmd.Flags().IntVar(
+ &dodoAppFlags.port,
+ "port",
+ 8080,
+ "",
+ )
+ cmd.Flags().StringVar(
+ &dodoAppFlags.repoAddr,
+ "repo-addr",
+ "",
+ "",
+ )
+ cmd.Flags().StringVar(
+ &dodoAppFlags.sshKey,
+ "ssh-key",
+ "",
+ "",
+ )
+ cmd.Flags().StringVar(
+ &dodoAppFlags.self,
+ "self",
+ "",
+ "",
+ )
+ cmd.Flags().StringVar(
+ &dodoAppFlags.namespace,
+ "namespace",
+ "",
+ "",
+ )
+ cmd.Flags().StringVar(
+ &dodoAppFlags.envConfig,
+ "env-config",
+ "",
+ "",
+ )
+ return cmd
+}
+
+func dodoAppCmdRun(cmd *cobra.Command, args []string) error {
+ envConfig, err := os.Open(dodoAppFlags.envConfig)
+ if err != nil {
+ return err
+ }
+ defer envConfig.Close()
+ var env installer.EnvConfig
+ if err := json.NewDecoder(envConfig).Decode(&env); err != nil {
+ return err
+ }
+ sshKey, err := os.ReadFile(dodoAppFlags.sshKey)
+ if err != nil {
+ return err
+ }
+ softClient, err := soft.NewClient(dodoAppFlags.repoAddr, sshKey, log.Default())
+ if err != nil {
+ return err
+ }
+ if err := softClient.AddRepository("app"); err == nil {
+ repo, err := softClient.GetRepo("app")
+ if err != nil {
+ return err
+ }
+ if err := initRepo(repo); err != nil {
+ return err
+ }
+ if err := welcome.UpdateDodoApp(softClient, dodoAppFlags.namespace, string(sshKey), &env); err != nil {
+ return err
+ }
+ if err := softClient.AddWebhook("app", fmt.Sprintf("http://%s/update", dodoAppFlags.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
+ return err
+ }
+ } else if !errors.Is(err, soft.ErrorAlreadyExists) {
+ return err
+ }
+ s := welcome.NewDodoAppServer(dodoAppFlags.port, string(sshKey), softClient, dodoAppFlags.namespace, env)
+ return s.Start()
+}
+
+const goMod = `module dodo.app
+
+go 1.18
+`
+
+const mainGo = `package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+)
+
+var port = flag.Int("port", 8080, "Port to listen on")
+
+func handler(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintln(w, "Hello from Dodo App!")
+}
+
+func main() {
+ flag.Parse()
+ http.HandleFunc("/", handler)
+ log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+}
+`
+
+const appCue = `app: {
+ type: "golang:1.22.0"
+ run: "main.go"
+ ingress: {
+ network: "Private" // or Public
+ subdomain: "testapp"
+ auth: enabled: false
+ }
+}
+`
+
+func initRepo(repo soft.RepoIO) error {
+ return repo.Do(func(fs soft.RepoFS) (string, error) {
+ {
+ w, err := fs.Writer("go.mod")
+ if err != nil {
+ return "", err
+ }
+ defer w.Close()
+ fmt.Fprint(w, goMod)
+ }
+ {
+ w, err := fs.Writer("main.go")
+ if err != nil {
+ return "", err
+ }
+ defer w.Close()
+ fmt.Fprintf(w, "%s", mainGo)
+ }
+ {
+ w, err := fs.Writer("app.cue")
+ if err != nil {
+ return "", err
+ }
+ defer w.Close()
+ fmt.Fprint(w, appCue)
+ }
+ return "go web app template", nil
+ })
+}
diff --git a/core/installer/cmd/main.go b/core/installer/cmd/main.go
index 5b05381..568efae 100644
--- a/core/installer/cmd/main.go
+++ b/core/installer/cmd/main.go
@@ -28,6 +28,7 @@
rootCmd.AddCommand(welcomeCmd())
rootCmd.AddCommand(rewriteCmd())
rootCmd.AddCommand(launcherCmd())
+ rootCmd.AddCommand(dodoAppCmd())
}
func main() {
diff --git a/core/installer/kube.go b/core/installer/kube.go
index a8ed275..c8251ff 100644
--- a/core/installer/kube.go
+++ b/core/installer/kube.go
@@ -31,6 +31,16 @@
Fetch(addr string) (string, error)
}
+type noOpNamespaceCreator struct{}
+
+func (n *noOpNamespaceCreator) Create(name string) error {
+ return nil
+}
+
+func NewNoOpNamespaceCreator() NamespaceCreator {
+ return &noOpNamespaceCreator{}
+}
+
type realNamespaceCreator struct {
clientset *kubernetes.Clientset
}
diff --git a/core/installer/pcloud_app.cue b/core/installer/pcloud_app.cue
new file mode 100644
index 0000000..d453747
--- /dev/null
+++ b/core/installer/pcloud_app.cue
@@ -0,0 +1,102 @@
+import (
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+)
+
+input: {
+ repoAddr: string
+ sshPrivateKey: string
+}
+
+#AppIngress: {
+ network: string
+ subdomain: string
+ auth: #Auth
+}
+
+_goVer1220: "golang:1.22.0"
+_goVer1200: "golang:1.20.0"
+
+#GoAppTmpl: {
+ type: _goVer1220 | _goVer1200
+ run: string
+ ingress: #AppIngress
+
+ runConfiguration: [{
+ bin: "/usr/local/go/bin/go",
+ args: ["mod", "tidy"]
+ }, {
+ bin: "/usr/local/go/bin/go",
+ args: ["build", "-o", ".app", run]
+ }, {
+ bin: ".app",
+ args: []
+ }]
+}
+
+#GoApp1200: #GoAppTmpl & {
+ type: _goVer1200
+}
+
+#GoApp1220: #GoAppTmpl & {
+ type: _goVer1220
+}
+
+#GoApp: #GoApp1200 | #GoApp1220
+
+app: #GoApp
+
+// output
+
+_app: app
+ingress: {
+ app: {
+ network: networks[strings.ToLower(_app.ingress.network)]
+ subdomain: _app.ingress.subdomain
+ auth: _app.ingress.auth
+ service: {
+ name: "app-app"
+ port: name: "app"
+ }
+ }
+}
+
+images: {
+ app: {
+ repository: "giolekva"
+ name: "app-runner"
+ tag: strings.Replace(_app.type, ":", "-", -1)
+ pullPolicy: "Always"
+ }
+}
+
+charts: {
+ app: {
+ chart: "charts/app-runner"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "pcloud"
+ namespace: global.id
+ }
+ }
+}
+
+helm: {
+ app: {
+ chart: charts.app
+ values: {
+ image: {
+ repository: images.app.fullName
+ tag: images.app.tag
+ pullPolicy: images.app.pullPolicy
+ }
+ appPort: 8080
+ appDir: "/dodo-app"
+ repoAddr: input.repoAddr
+ sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
+ runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
+ manager: "http://dodo-app.\(release.namespace).svc.cluster.local/register-worker"
+ }
+ }
+}
diff --git a/core/installer/soft/client.go b/core/installer/soft/client.go
index 269f3d3..08103be 100644
--- a/core/installer/soft/client.go
+++ b/core/installer/soft/client.go
@@ -19,6 +19,8 @@
"github.com/go-git/go-git/v5/storage/memory"
)
+var ErrorAlreadyExists = errors.New("already exists")
+
type Client interface {
Address() string
Signer() ssh.Signer
@@ -32,6 +34,7 @@
MakeUserAdmin(name string) error
AddReadWriteCollaborator(repo, user string) error
AddReadOnlyCollaborator(repo, user string) error
+ AddWebhook(repo, url string, opts ...string) error
}
type realClient struct {
@@ -131,6 +134,9 @@
func (ss *realClient) AddRepository(name string) error {
log.Printf("Adding repository %s", name)
+ if err := ss.RunCommand("repo", "info", name); err == nil {
+ return ErrorAlreadyExists
+ }
return ss.RunCommand("repo", "create", name)
}
@@ -144,6 +150,14 @@
return ss.RunCommand("repo", "collab", "add", repo, user, "read-only")
}
+func (ss *realClient) AddWebhook(repo, url string, opts ...string) error {
+ log.Printf("Adding webhook %s %s", repo, url)
+ return ss.RunCommand(append(
+ []string{"repo", "webhook", "create", repo, url},
+ opts...,
+ )...)
+}
+
type Repository struct {
*git.Repository
Addr RepositoryAddress
diff --git a/core/installer/soft/repoio.go b/core/installer/soft/repoio.go
index 6a5097a..b916d24 100644
--- a/core/installer/soft/repoio.go
+++ b/core/installer/soft/repoio.go
@@ -3,10 +3,12 @@
import (
"encoding/json"
"errors"
+ "fmt"
"io"
"io/fs"
"io/ioutil"
"net"
+ "os"
"path/filepath"
"sync"
"time"
@@ -16,6 +18,7 @@
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/util"
"github.com/go-git/go-git/v5"
+ "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/object"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"golang.org/x/crypto/ssh"
@@ -33,6 +36,8 @@
type doOptions struct {
NoCommit bool
+ Force bool
+ ToBranch string
}
type DoOption func(*doOptions)
@@ -43,11 +48,42 @@
}
}
+func WithForce() DoOption {
+ return func(o *doOptions) {
+ o.Force = true
+ }
+}
+
+func WithCommitToBranch(branch string) DoOption {
+ return func(o *doOptions) {
+ o.ToBranch = branch
+ }
+}
+
+type pushOptions struct {
+ ToBranch string
+ Force bool
+}
+
+type PushOption func(*pushOptions)
+
+func WithToBranch(branch string) PushOption {
+ return func(o *pushOptions) {
+ o.ToBranch = branch
+ }
+}
+
+func PushWithForce() PushOption {
+ return func(o *pushOptions) {
+ o.Force = true
+ }
+}
+
type RepoIO interface {
RepoFS
FullAddress() string
Pull() error
- CommitAndPush(message string) error
+ CommitAndPush(message string, opts ...PushOption) error
Do(op DoFn, opts ...DoOption) error
}
@@ -120,8 +156,9 @@
return nil
}
err = wt.Pull(&git.PullOptions{
- Auth: auth(r.signer),
- Force: true,
+ Auth: auth(r.signer),
+ Force: true,
+ Progress: os.Stdout,
})
if err == nil {
return nil
@@ -130,10 +167,15 @@
return nil
}
// TODO(gio): check `remote repository is empty`
+ fmt.Println(err)
return nil
}
-func (r *repoIO) CommitAndPush(message string) error {
+func (r *repoIO) CommitAndPush(message string, opts ...PushOption) error {
+ var o pushOptions
+ for _, i := range opts {
+ i(&o)
+ }
wt, err := r.repo.Worktree()
if err != nil {
return err
@@ -149,10 +191,17 @@
}); err != nil {
return err
}
- return r.repo.Push(&git.PushOptions{
+ gopts := &git.PushOptions{
RemoteName: "origin",
Auth: auth(r.signer),
- })
+ }
+ if o.ToBranch != "" {
+ gopts.RefSpecs = []config.RefSpec{config.RefSpec(fmt.Sprintf("refs/heads/master:refs/heads/%s", o.ToBranch))}
+ }
+ if o.Force {
+ gopts.Force = true
+ }
+ return r.repo.Push(gopts)
}
func (r *repoIO) Do(op DoFn, opts ...DoOption) error {
@@ -169,7 +218,14 @@
return err
} else {
if !o.NoCommit {
- return r.CommitAndPush(msg)
+ popts := []PushOption{}
+ if o.Force {
+ popts = append(popts, PushWithForce())
+ }
+ if o.ToBranch != "" {
+ popts = append(popts, WithToBranch(o.ToBranch))
+ }
+ return r.CommitAndPush(msg, popts...)
}
}
return nil
@@ -248,3 +304,12 @@
}
return ret, nil
}
+
+func ReadFile(repo RepoFS, path string) ([]byte, error) {
+ r, err := repo.Reader(path)
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+ return io.ReadAll(r)
+}
diff --git a/core/installer/testapp.cue b/core/installer/testapp.cue
new file mode 100644
index 0000000..2573c5c
--- /dev/null
+++ b/core/installer/testapp.cue
@@ -0,0 +1,12 @@
+app: {
+ type: "golang:1.22.0"
+ run: "main.go"
+ ingress: {
+ network: "private"
+ subdomain: "testapp"
+ auth: enabled: false
+ }
+}
+
+// do create app --type=go[1.22.0] [--run-cmd=(*default main.go)]
+// do create ingress --subdomain=testapp [--network=public (*default private)] [--auth] [--auth-groups="admin" (*default empty)] TODO(gio): port
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
new file mode 100644
index 0000000..5acc6db
--- /dev/null
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -0,0 +1,161 @@
+import (
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+)
+
+input: {
+ network: #Network @name(Network)
+ subdomain: string @name(Subdomain)
+ sshPort: int @name(SSH Port)
+ adminKey: string @name(Admin SSH Public Key)
+
+ // TODO(gio): auto generate
+ ssKeys: #SSHKey
+ fluxKeys: #SSHKey
+ dAppKeys: #SSHKey
+}
+
+name: "Dodo App"
+namespace: "dodo-app"
+readme: "Deploy app by pushing to Git repository"
+description: "Deploy app by pushing to Git repository"
+icon: "<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='M2.837 27.257c3.363 2.45 11.566 3.523 12.546 1.4s.424-10.94.424-10.94s-1.763 1.192-2.302.147s.44-2.433 2.319-2.858c-1.96.05-2.221-.571-2.205-.93s.67-1.878 3.527-1.241c-1.6-.751-1.943-2.956 2.352-1.568c-1.421-.735-.36-2.825 1.649-.62c-.261-1.323 1.584-1.46 2.694.907M10.648 34.633a19 19 0 0 0-4.246.719'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M15.144 43.402c3.625-2.482 7.685-6.32 7.293-13.406s-1.6-6.368-.523-7.577s6.924-.99 10.712 3.353c.032-2.874-2.504-5.508-2.504-5.508a33 33 0 0 1 5.53.163c2.852.49 2.394 2.514 3.58 2.035s.971-3.472-.39-5.377c-1.666-2.33-3.223-2.83-6.358-2.188s-4.474.458-5.54-.587s-2.026-3.538-4.605-2.515c-2.935 1.164-4.398 2.438-3.767 5.04s2.34 4.558 2.972 6.844'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M22.001 16.552c-.925-.043-1.894.055-1.709 1.328'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M20.662 16.763c1.72 2.695 3.405 3.643 9.46 3.501'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M32.14 14.966c-1.223.879-2.18 3.781-2.496 5.307M23.1 14.908c.48 1.209 1.23.728 1.315.283a1.552 1.552 0 0 0-1.543-1.883m-.408 17.472c5.328 2.71 11.631.229 16.269-2.123c-1.176 4.572-5.911 5.585-8.916 6.107'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M29.099 37.115c4.376-.294 8.024-1.578 7.833-5.296'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M20.27 38.702c6.771 3.834 12.505.798 13.786-2.615'/><circle cx='24' cy='24' r='21.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/></svg>"
+_domain: "\(input.subdomain).\(input.network.domain)"
+
+images: {
+ softserve: {
+ repository: "charmcli"
+ name: "soft-serve"
+ tag: "v0.7.1"
+ pullPolicy: "IfNotPresent"
+ }
+ dodoApp: {
+ repository: "giolekva"
+ name: "pcloud-installer"
+ tag: "latest"
+ pullPolicy: "Always"
+ }
+}
+
+charts: {
+ softserve: {
+ chart: "charts/soft-serve"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "pcloud"
+ namespace: global.pcloudEnvName
+ }
+ }
+ dodoApp: {
+ chart: "charts/dodo-app"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "pcloud"
+ namespace: global.pcloudEnvName
+ }
+ }
+}
+
+portForward: [#PortForward & {
+ allocator: input.network.allocatePortAddr
+ sourcePort: input.sshPort
+ // TODO(gio): namespace part must be populated by app manager. Otherwise
+ // third-party app developer might point to a service from different namespace.
+ targetService: "\(release.namespace)/soft-serve"
+ targetPort: 22
+}]
+
+helm: {
+ softserve: {
+ chart: charts.softserve
+ values: {
+ serviceType: "ClusterIP"
+ addressPool: ""
+ reservedIP: ""
+ adminKey: strings.Join([input.adminKey, input.fluxKeys.public, input.dAppKeys.public], "\n")
+ privateKey: input.ssKeys.private
+ publicKey: input.ssKeys.public
+ ingress: {
+ enabled: false
+ }
+ image: {
+ repository: images.softserve.fullName
+ tag: images.softserve.tag
+ pullPolicy: images.softserve.pullPolicy
+ }
+ }
+ }
+ "dodo-app": {
+ chart: charts.dodoApp
+ values: {
+ image: {
+ repository: images.dodoApp.fullName
+ tag: images.dodoApp.tag
+ pullPolicy: images.dodoApp.pullPolicy
+ }
+ repoAddr: "soft-serve.\(release.namespace).svc.cluster.local:22"
+ sshPrivateKey: base64.Encode(null, input.dAppKeys.private)
+ self: "dodo-app.\(release.namespace).svc.cluster.local"
+ namespace: release.namespace
+ envConfig: base64.Encode(null, json.Marshal(global))
+ }
+ }
+}
+
+resources: {
+ "config-kustomization": {
+ apiVersion: "kustomize.toolkit.fluxcd.io/v1"
+ kind: "Kustomization"
+ metadata: {
+ name: "app"
+ namespace: release.namespace
+ }
+ spec: {
+ interval: "1m"
+ path: "./.dodo"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "app"
+ namespace: release.namespace
+ }
+ prune: true
+ }
+ }
+ "config-secret": {
+ apiVersion: "v1"
+ kind: "Secret"
+ type: "Opaque"
+ metadata: {
+ name: "app"
+ namespace: release.namespace
+ }
+ data: {
+ identity: base64.Encode(null, input.fluxKeys.private)
+ "identity.pub": base64.Encode(null, input.fluxKeys.public)
+ known_hosts: base64.Encode(null, "soft-serve.\(release.namespace).svc.cluster.local \(input.ssKeys.public)")
+ }
+ }
+ "config-source": {
+ apiVersion: "source.toolkit.fluxcd.io/v1"
+ kind: "GitRepository"
+ metadata: {
+ name: "app"
+ namespace: release.namespace
+ }
+ spec: {
+ interval: "1m0s"
+ ref: branch: "dodo"
+ secretRef: name: "app"
+ timeout: "60s"
+ url: "ssh://soft-serve.\(release.namespace).svc.cluster.local:22/app"
+ }
+ }
+}
+
+help: [{
+ title: "How to use"
+ contents: """
+ Clone: git clone ssh://\(_domain):\(input.sshPort)/app
+ """
+}]
diff --git a/core/installer/values-tmpl/soft-serve.cue b/core/installer/values-tmpl/soft-serve.cue
index b99430a..1bae24f 100644
--- a/core/installer/values-tmpl/soft-serve.cue
+++ b/core/installer/values-tmpl/soft-serve.cue
@@ -5,7 +5,7 @@
adminKey: string @name(Admin SSH Public Key)
}
-_domain: "\(input.subdomain).\(global.privateDomain)"
+_domain: "\(input.subdomain).\(input.network.domain)"
name: "Soft-Serve"
namespace: "app-soft-serve"
@@ -35,7 +35,7 @@
}
ingress: {
- gerrit: {
+ gerrit: { // TODO(gio): rename to soft-serve
auth: enabled: false
network: input.network
subdomain: input.subdomain
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
new file mode 100644
index 0000000..5eb2f58
--- /dev/null
+++ b/core/installer/welcome/dodo_app.go
@@ -0,0 +1,122 @@
+package welcome
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/giolekva/pcloud/core/installer"
+ "github.com/giolekva/pcloud/core/installer/soft"
+)
+
+type DodoAppServer struct {
+ port int
+ sshKey string
+ client soft.Client
+ namespace string
+ env installer.EnvConfig
+ workers map[string]struct{}
+}
+
+func NewDodoAppServer(
+ port int,
+ sshKey string,
+ client soft.Client,
+ namespace string,
+ env installer.EnvConfig,
+) *DodoAppServer {
+ return &DodoAppServer{
+ port,
+ sshKey,
+ client,
+ namespace,
+ env,
+ map[string]struct{}{},
+ }
+}
+
+func (s *DodoAppServer) Start() error {
+ http.HandleFunc("/update", s.handleUpdate)
+ http.HandleFunc("/register-worker", s.handleRegisterWorker)
+ return http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil)
+}
+
+type updateReq struct {
+ Ref string `json:"ref"`
+}
+
+func (s *DodoAppServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
+ fmt.Println("update")
+ var req updateReq
+ var contents strings.Builder
+ io.Copy(&contents, r.Body)
+ c := contents.String()
+ fmt.Println(c)
+ if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
+ fmt.Println(err)
+ return
+ }
+ if req.Ref != "refs/heads/master" {
+ return
+ }
+ go func() {
+ time.Sleep(20 * time.Second)
+ if err := UpdateDodoApp(s.client, s.namespace, s.sshKey, &s.env); err != nil {
+ fmt.Println(err)
+ }
+ }()
+ for addr, _ := range s.workers {
+ go func() {
+ // TODO(gio): make port configurable
+ http.Get(fmt.Sprintf("http://%s:3000/update", addr))
+ }()
+ }
+}
+
+type registerWorkerReq struct {
+ Address string `json:"address"`
+}
+
+func (s *DodoAppServer) handleRegisterWorker(w http.ResponseWriter, r *http.Request) {
+ var req registerWorkerReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ s.workers[req.Address] = struct{}{}
+ fmt.Printf("registered worker: %s\n", req.Address)
+}
+
+func UpdateDodoApp(client soft.Client, namespace string, sshKey string, env *installer.EnvConfig) error {
+ repo, err := client.GetRepo("app")
+ if err != nil {
+ return err
+ }
+ nsCreator := installer.NewNoOpNamespaceCreator()
+ if err != nil {
+ return err
+ }
+ m, err := installer.NewAppManager(repo, nsCreator, "/.dodo")
+ if err != nil {
+ return err
+ }
+ appCfg, err := soft.ReadFile(repo, "app.cue")
+ fmt.Println(string(appCfg))
+ if err != nil {
+ return err
+ }
+ app, err := installer.NewDodoApp(appCfg)
+ if err != nil {
+ return err
+ }
+ if _, err := m.Install(app, "app", "/.dodo/app", namespace, map[string]any{
+ "repoAddr": repo.FullAddress(),
+ "sshPrivateKey": sshKey,
+ }, installer.WithConfig(env), installer.WithBranch("dodo")); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
index e4cee83..0803e64 100644
--- a/core/installer/welcome/env_test.go
+++ b/core/installer/welcome/env_test.go
@@ -59,7 +59,7 @@
return nil
}
-func (r mockRepoIO) CommitAndPush(message string) error {
+func (r mockRepoIO) CommitAndPush(message string, opts ...soft.PushOption) error {
r.t.Logf("Commit and push: %s", message)
return nil
}
@@ -128,6 +128,10 @@
return nil
}
+func (f fakeSoftServeClient) AddWebhook(repo, url string, opts ...string) error {
+ return nil
+}
+
type fakeClientGetter struct {
t *testing.T
envFS billy.Filesystem