package main import ( "bytes" "embed" "errors" "fmt" "html/template" "os" "path/filepath" "reflect" "strings" "codeberg.org/marcig/riploy/slag" "github.com/goccy/go-yaml" ) var cmdArgs = os.Args[1:] func main() { var r Riploy baseDefaults := Riploy{ Ctx: "production", DockerFile: "Dockerfile.prod", } config := readConfigFileOrDie() // We load the config file defaults err := mergo(&config.Defaults, baseDefaults) if err != nil { exitln(err) } err = slag.Parse(&r, config.Defaults, cmdArgs) if r.StupidSong { fmt.Printf("%v\n", stupid_song) return } if r.StackName == "" { r.StackName = generateNameFromDir("_") } if r.IdentityName == "" { r.IdentityName = getIdentityNameFromDir() } if r.Upgrade { upgradeRiploy(true, false) return } if r.Help || len(os.Args[1:]) == 0 { printHelpMessage() return } if err != nil { switch { case errors.Is(err, slag.ErrNoFlagsProvided): printHelpMessage() okExit("") default: exitln(err) } } shouldForce := false shouldRerun := true if upgradeRiploy(shouldForce, shouldRerun) { return } r.ProductionHome = filepath.Clean(r.ProductionHome) if r.DBPort == 0 { r.DBPort = r.ServerPort + 1 } if r.DebuggerPort == 0 { r.DebuggerPort = r.DBPort + 1 } data := DockerTemplate{ User: r.DockerUser, Name: r.StackName, ProductionHome: r.ProductionHome, Domain: r.Domain, IdentityName: r.IdentityName, VCS: r.Vcs, Ports: DockerPorts{ Frontend: r.SveltePort, Server: r.ServerPort, DB: r.DBPort, Debugger: r.DebuggerPort, }, JWT: JWT{ Secret: r.JWTSecret, Audience: r.JWTAudience, Issuer: r.JWTIssuer, }, } tmpl, err := template.New(""). ParseFS(templateFS, "templates/*") if err != nil { exitln(err) } if r.Init { // @Todo: put the secrets dir into .gitignore and all the .riploy.* files too switch { case r.Svelte: errs := initSvelteDockerFiles(tmpl, data, r) exitIfAnyError(errs) case r.Golang: if r.MailUser == "" { r.MailUser = r.MailSender } if r.MailSender == "" { warnln("the mail-sender arg was not set, will be fallbacking into empty string") } if r.MailPassword == "" { warnln("the mail-password arg was not set, will be fallbacking into empty string") } err = generateSecrets( r.StackName, fmt.Sprintf("db_%v", r.StackName), r.MailSender, r.MailUser, r.MailPassword, r.JWTSecret, r.JWTAudience, r.JWTIssuer, r.OverwriteSecrets, ) if err != nil { exitln(err) } errs := initGolangDockerFiles(tmpl, data) exitIfAnyError(errs) default: errorln("provide a -svelte or -golang flag so i can know what kind of project deploy scaffold you need") } } else { riploy(r) } } func build(imageName, dockerFile string) { runOrDie("docker", "build", "--tag", imageName, "-f", dockerFile, ".") fmt.Printf("image built\n") } func push(imageName string) { runOrDie("docker", "push", imageName) fmt.Printf("pushed\n") } func deploy(dockerStackYaml, stackName, ctx string) { runOrDie("docker", "--debug", "--context", ctx, "stack", "deploy", "-c", dockerStackYaml, stackName) fmt.Printf("deployed\n") } //go:embed "templates" var templateFS embed.FS const ( _GENERAL_DIR_PERM os.FileMode = 0755 ) type Config struct { Hash string `yaml:"hash"` DeploymentHash string `yaml:"last_deployment_hash"` Defaults Riploy `yaml:"defaults"` } func readConfigFileOrDie() Config { home := getHome() configPath := fmt.Sprintf("%v/riploy.yaml", home) _, err := os.ReadFile(configPath) var config Config if err != nil { switch { case errors.Is(err, os.ErrNotExist): return config default: exitln(err) } } b := readFileOrDie(configPath) err = yaml.Unmarshal(b, &config) if err != nil { exitln(err) } return config } func writeConfigFile(config Config) { home := getHome() configPath := fmt.Sprintf("%v/riploy.yaml", home) b, err := yaml.Marshal(config) if err != nil { exitln(err) } writeFileOrDie(configPath, b) } type DockerPorts struct { Server int DB int Frontend int Debugger int } type JWT struct { Secret string Audience string Issuer string } type DockerTemplate struct { User string Name string IdentityName string ProductionHome string Ports DockerPorts Domain string VCS string JWT JWT } func getGitHash() string { h := runGetOrDie("git", "rev-parse", "HEAD") hash := bytes.TrimSpace(h) return string(hash) } type Riploy struct { Ctx string `yaml:"ctx"` DockerFile string `yaml:"docker_file"` DockerUser string `yaml:"docker_user"` MailSender string `yaml:"mail_sender"` JWTSecret string `yaml:"jwt_secret"` JWTIssuer string `yaml:"jwt_issuer"` JWTAudience string `yaml:"jwt_audience"` MailUser string `yaml:"mail_user"` MailPassword string `yaml:"mail_password"` ProductionHome string `yaml:"production_home"` StackName string `yaml:"-"` IdentityName string `json:"-"` Vcs string `yaml:"-"` Domain string `yaml:"domain"` OverwriteSecrets bool `yaml:"overwrite_secrets"` Build bool `yaml:"-"` Push bool `yaml:"-"` Deploy bool `yaml:"-"` All bool `yaml:"-"` Force bool `yaml:"-"` Init bool `yaml:"-"` Svelte bool `yaml:"-"` Golang bool `yaml:"-"` Static bool `yaml:"-"` SSR bool `yaml:"-"` Node bool `yaml:"-"` Deno bool `yaml:"-"` Bun bool `yaml:"-"` Help bool `yaml:"-"` Upgrade bool `yaml:"-"` SveltePort int `yaml:"-"` ServerPort int `yaml:"-"` DBPort int `yaml:"-"` DebuggerPort int `yaml:"-"` StupidSong bool `yaml:"-"` } func printHelpMessage() { msg := ` ctx: string context name to use docker-file: string docker file name to use for building the final service docker-user: string name for the dockerhub account stack-name: string name of the stack to deploy mail-sender: string email address from which we are sending emails mail-user: string email use (will fallback into mail-sender if not present) mail-password: string email password production-home string production home path vcs: string vcs repository url domain: string name of the domain overwrite-secrets: bool should overwrite all the secrets files (defaults to false) build: bool should build push: bool should push deploy: bool should deploy all: bool do everything force: bool ignore if there are not new changes (just use in case the build was interrupted) init: bool init an empty docker-stack.yaml svelte: bool (depends on init) -> tells riploy to generate the svelte docker files golang: bool (depends on init) -> tells riploy to generate the golang/backend docker files static: bool init an svelte-static Dockerfile ssr: bool init an svelte-ssr Dockerfile node: bool init an svelte-node Dockerfile bun: bool init an svelte-bun Dockerfile deno: bool init an svelte-deno Dockerfile svelte-port: int port to use for the svelte app server-port: int port to use for the server debugger-port: int port to use for the debugger jwt-secret: string containing the token secret (jwt) jwt-issuer: string issuer for the token (jwt) jwt-audience: string audience for the token (jwt) stupid-song: bool just for testing btw listen to olivia she is goated <3 help: bool prints this help message ` fmt.Printf("%v\n", msg) } func assignIfEmpty[T any](ptr *T, value T) { rv := reflect.ValueOf(ptr).Elem() fmt.Printf("%v:%v", ptr, rv.IsZero()) if rv.IsZero() { rv.Set(reflect.ValueOf(value)) } } func generateSecrets(user, dbName, mailSender, mailUser, mailPass, tokenSecret, tokenAudience, tokenIssuer string, shouldOverwrite bool) error { err := os.MkdirAll("secrets", _GENERAL_DIR_PERM) if err != nil { return err } generatedRootPass, err := RandomHash(16) if err != nil { return err } genreatedUserPass, err := RandomHash(16) if err != nil { return err } type File struct { content string shouldOverwrite bool } ts, err := RandomHash(16) if err != nil { return err } assignIfEmpty(&tokenSecret, ts) assignIfEmpty(&tokenAudience, user) assignIfEmpty(&tokenIssuer, user) secrets := map[string]File{ "./secrets/db-root-password.secret": {content: generatedRootPass, shouldOverwrite: false}, "./secrets/db-root-user.secret": {content: fmt.Sprintf("root-%v", user), shouldOverwrite: true}, "./secrets/db-password.secret": {content: genreatedUserPass, shouldOverwrite: false}, "./secrets/db-user.secret": {content: user, shouldOverwrite: false}, "./secrets/db-name.secret": {content: dbName, shouldOverwrite: false}, "./secrets/mail-password.secret": {content: mailPass, shouldOverwrite: false}, "./secrets/mail-sender.secret": {content: mailSender, shouldOverwrite: false}, "./secrets/mail-user.secret": {content: mailUser, shouldOverwrite: false}, "./secrets/jwt-secret.secret": {content: tokenSecret, shouldOverwrite: false}, "./secrets/jwt-audience.secret": {content: tokenAudience, shouldOverwrite: false}, "./secrets/jwt-issuer.secret": {content: tokenIssuer, shouldOverwrite: false}, } for name, secret := range secrets { overwrite := secret.shouldOverwrite if shouldOverwrite { overwrite = true } if secret.content != "" { writeFileString(name, secret.content, overwrite) } } return nil } func canInitializeSvelteDockerFiles(data DockerTemplate) []error { var errors []error if data.Ports.Frontend == 0 { errors = append(errors, ErrNoFrontendPort) } if data.User == "" { errors = append(errors, ErrNoDockerhubUserName) } if data.Name == "" { errors = append(errors, ErrNoProjectName) } if data.Domain == "" { errors = append(errors, ErrNoDomain) } return errors } func initSvelteDockerFiles(tmpl *template.Template, data DockerTemplate, r Riploy) []error { errors := canInitializeSvelteDockerFiles(data) if errors != nil { return errors } var err error prod := createFileOrDie("docker-stack.yaml") err = tmpl.ExecuteTemplate(prod, "prod-frontend-svelte.yaml", data) if err != nil { errors = append(errors, err) return errors } // XD var sb strings.Builder sb.WriteString("prod") switch { case r.Node: sb.WriteString("-node") case r.Deno: sb.WriteString("-deno") case r.Bun: sb.WriteString("-bun") default: if r.SSR { errorln("please tell me which js runtime to use for building and running your project") } else { errorln("please tell me which js runtime to use for building your project (-deno, -node, -bun)") } exit() } sb.WriteString("-svelte") if r.SSR { sb.WriteString("-ssr.Dockerfile") } else { sb.WriteString("-static.Dockerfile") } dockerTemplateName := sb.String() err = createFileFromTemplate(tmpl, dockerTemplateName, "Dockerfile.prod", data) if err != nil { errors = append(errors, err) return errors } err = createFileFromTemplate(tmpl, "nginx.conf", "nginx.conf", data) if err != nil { errors = append(errors, err) return errors } return nil } func upgradeRiploy(force, rerun bool) bool { source := sourceFile() config := readConfigFileOrDie() // @Todo: might need to check also the utils.go file or all the go files inside the project but blehgh checksum := getFileChecksumOrDie(fmt.Sprintf("%v/main.go", source)) msg := "[^v^]:changes to riploy were detected, rebuilt itself" if force { msg = "[óvó]:force upgrade, rebuilt itself\n" } if force || config.Hash != checksum { prev := pwd() cd(source) runOrDie("go", "build", ".") config.Hash = checksum writeConfigFile(config) fmt.Println(msg) if rerun { cd(prev) runOrDie("riploy", cmdArgs...) } return true } return false } func canInitializeGolangDockerFiles(data DockerTemplate) []error { var errors []error if data.Ports.Server == 0 { errors = append(errors, ErrNoServerPort) } if data.User == "" { errors = append(errors, ErrNoDockerhubUserName) } if data.VCS == "" { errors = append(errors, ErrNoVCS) } if data.Domain == "" { errors = append(errors, ErrNoDomain) } return errors } func initGolangDockerFiles(tmpl *template.Template, data DockerTemplate) []error { // execute template prod-backend-postgres var errors []error errors = canInitializeGolangDockerFiles(data) if errors != nil { return errors } files := map[string]string{ "prod-backend-postgres.Dockerfile": "Dockerfile.prod", "dev-backend-postgres.Dockerfile": "Dockerfile.dev", "prod-backend-postgres.yaml": "docker-stack.yaml", "dev-backend-postgres.yaml": "compose.yaml", "backend.env": ".env", "backend.Makefile": "Makefile", "backend.gitignore": ".gitignore", } for templateName, fileName := range files { err := createFileFromTemplate(tmpl, templateName, fileName, data) if err != nil { errors = append(errors, err) } } return errors } const ( RiployStackFileName = ".riploy.docker-stack.yaml" DockerStackFileName = "docker-stack.yaml" ) func generateDockerRiployFileOrDie(hash string) { os.Setenv("GIT_HASH", hash) // Generate the config file with the enviroment variables written into it filecontent := runGetOrDie("docker", "compose", "-f", DockerStackFileName, "config") newLine := []byte{'\n'} lines := bytes.Split(filecontent, newLine) // @Note: `docker compose config` creates a field that docker-stack doesn't support for deploying content := bytes.Join(lines[1:], newLine) writeFileOrDie(RiployStackFileName, content) } func riploy(r Riploy) { gitHash := getGitHash() config := readConfigFileOrDie() // @Verify this would never happen because riploy would exit if getting the git hash fails so it doesn't matter to do this // if len(gitHash) == 0 { // exitln("please run this command inside a git repository") // } imageName := fmt.Sprintf("%v/%v:%s", r.DockerUser, r.StackName, gitHash) generateDockerRiployFileOrDie(gitHash) if !r.Force && config.DeploymentHash == gitHash { exitln("no changes") } else if r.Force { warnln("forcing riploy (this is safe to do when the image was not pushed or deployed correctly the last time!) is not safe the other way because the hashes are the same and docker stack won't update the service cause it just sees the tag which has not changed") } if r.All || r.Build { build(imageName, r.DockerFile) } if r.All || r.Push { push(imageName) } if r.All || r.Deploy { deploy(RiployStackFileName, r.StackName, r.Ctx) } config.DeploymentHash = gitHash writeConfigFile(config) }