From b84e1e7d2c3ada4a34aaf3e37e8051d7d6110d1a Mon Sep 17 00:00:00 2001 From: Thanapon Johdee <66236295+yokeTH@users.noreply.github.com> Date: Sun, 21 Sep 2025 12:01:29 +0700 Subject: [PATCH 1/4] feat: nix dev env --- .envrc | 1 + .gitignore | 1 + flake.lock | 61 ++++++++++++++++++++++++++++++++++++++ flake.nix | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index f32e31a..55bdd6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ .DS_Store +.direnv diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..aae0419 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1758277210, + "narHash": "sha256-iCGWf/LTy+aY0zFu8q12lK8KuZp7yvdhStehhyX1v8w=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "8eaee110344796db060382e15d3af0a9fc396e0e", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1e8fd2a --- /dev/null +++ b/flake.nix @@ -0,0 +1,87 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: let + pkgs = import nixpkgs {inherit system;}; + + # go-migrate-pg = pkgs.go-migrate.overrideAttrs (oldAttrs: { + # tags = ["postgres"]; + # }); + + # swag = pkgs.buildGoModule rec { + # pname = "swag"; + # version = "v2.0.0-rc4"; + + # src = pkgs.fetchFromGitHub { + # owner = "swaggo"; + # repo = "swag"; + # rev = version; + # sha256 = "sha256-3dX01PAVEkn8ciVNbIn1IwiSkwogPJLYNo6xTg9jhDA="; + # }; + + # vendorHash = "sha256-bUMW9wjPIT3JLMw9F/NvWqZv1M62o/Y4gIpp6XyHbek="; + # subPackages = ["cmd/swag"]; + # }; + + app = pkgs.buildGoModule { + pname = "go-app"; + version = "0.1.0"; + + src = ./.; + + vendorHash = "sha256-jx70HhXLbBt63Vt0iZK8aUwoQnpe57MIjUMzXsnaRgA="; + + nativeBuildInputs = [ + # swag + ]; + + preBuild = '' + # Generate Swagger docs + # swag init -v3.1 -o docs -g main.go --parseDependency --parseInternal + ''; + + meta = with pkgs.lib; { + description = "Go application with Swagger documentation"; + license = licenses.mit; + }; + }; + in { + packages = { + default = app; + app = app; + }; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go_1_25 + gopls + golangci-lint + + air + # swag + + # sqlc + # go-migrate-pg + # sql-formatter + + pre-commit + ]; + + shellHook = '' + echo "Go development environment ready" + echo "Go version: $(go version)" + # echo "Swag version: $(swag --version)" + ''; + }; + } + ); +} From 5633bf93b704c71b09b3e8f31e4f539cf7413694 Mon Sep 17 00:00:00 2001 From: Thanapon Johdee <66236295+yokeTH@users.noreply.github.com> Date: Sun, 21 Sep 2025 12:02:52 +0700 Subject: [PATCH 2/4] feat: support full deployment feature deploy method --- go.mod | 1 + go.sum | 2 + internal/runner/helper.go | 74 +++++++++++++ internal/runner/runner.go | 219 +++++++++++++++++++++++++++++++++----- 4 files changed, 269 insertions(+), 27 deletions(-) create mode 100644 internal/runner/helper.go diff --git a/go.mod b/go.mod index c6d021a..f25c49e 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/moonrhythm/validator v1.3.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect golang.org/x/net v0.18.0 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/go.sum b/go.sum index 6b2a91c..12fd876 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/moonrhythm/validator v1.3.0/go.mod h1:gbDyBOwWGzphP8K2Sdrd9+MveRVxzxm github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= diff --git a/internal/runner/helper.go b/internal/runner/helper.go new file mode 100644 index 0000000..5825899 --- /dev/null +++ b/internal/runner/helper.go @@ -0,0 +1,74 @@ +package runner + +import ( + "fmt" + "os" + "strings" +) + +func splitCommaList(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// format: "K=V,A=B" (values may be empty). No escaping; keep it simple like existing flags. +func parseKVList(s string) map[string]string { + m := map[string]string{} + for _, pair := range splitCommaList(s) { + if eq := strings.IndexByte(pair, '='); eq >= 0 { + k := strings.TrimSpace(pair[:eq]) + v := strings.TrimSpace(pair[eq+1:]) + if k != "" { + m[k] = v + } + } else if pair != "" { + // allow "KEY" -> empty value + m[pair] = "" + } + } + return m +} + +// supports "@path" to read file content; otherwise returns value as-is +func readMaybeFile(value string) (string, error) { + if strings.HasPrefix(value, "@") && len(value) > 1 { + b, err := os.ReadFile(value[1:]) + if err != nil { + return "", err + } + return string(b), nil + } + return value, nil +} + +// format: "/path=VALUE,/path2=@file.txt" +func parseMountData(s string) (map[string]string, error) { + m := map[string]string{} + for _, pair := range splitCommaList(s) { + if eq := strings.IndexByte(pair, '='); eq >= 0 { + k := strings.TrimSpace(pair[:eq]) + v := strings.TrimSpace(pair[eq+1:]) + if k == "" || !strings.HasPrefix(k, "/") { + return nil, fmt.Errorf("mountData key must be absolute path: %q", k) + } + val, err := readMaybeFile(v) + if err != nil { + return nil, err + } + m[k] = val + } else if pair != "" { + return nil, fmt.Errorf("mountData must be key=value: %q", pair) + } + } + return m, nil +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index f531296..3708354 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -11,6 +11,8 @@ import ( "github.com/deploys-app/api" "gopkg.in/yaml.v2" + + "github.com/robfig/cron/v3" ) type tablePrinter interface { @@ -380,33 +382,7 @@ func (rn Runner) deployment(args ...string) error { f.Parse(args[1:]) resp, err = s.Delete(context.Background(), &req) case "deploy": - var ( - req api.DeploymentDeploy - typ string - port int - minReplicas int - maxReplicas int - ) - f.StringVar(&req.Location, "location", "", "location") - f.StringVar(&req.Project, "project", "", "project id") - f.StringVar(&req.Name, "name", "", "deployment name") - f.StringVar(&req.Image, "image", "", "docker image") - f.StringVar(&typ, "type", "", "deployment type") - f.IntVar(&port, "port", 0, "port") - f.IntVar(&minReplicas, "minReplicas", 0, "autoscale min replicas") - f.IntVar(&maxReplicas, "maxReplicas", 0, "autoscale max replicas") - f.Parse(args[1:]) - req.Type = api.ParseDeploymentTypeString(typ) - if port > 0 { - req.Port = &port - } - if minReplicas > 0 { - req.MinReplicas = &minReplicas - } - if maxReplicas > 0 { - req.MaxReplicas = &maxReplicas - } - resp, err = s.Deploy(context.Background(), &req) + return rn.deploymentDeploy(args[1:]...) case "set": return rn.deploymentSet(args[1:]...) } @@ -479,6 +455,195 @@ func (rn Runner) route(args ...string) error { return rn.print(resp) } +func (rn Runner) deploymentDeploy(args ...string) error { + if len(args) == 0 { + return fmt.Errorf("invalid command") + } + + s := rn.API.Deployment() + + var ( + resp any + err error + ) + + f := flag.NewFlagSet("", flag.ExitOnError) + rn.registerFlags(f) + + var ( + req api.DeploymentDeploy + typ string + port int + minReplicas int + maxReplicas int + protoStr string + internal bool + envStr string + addEnvStr string + rmEnvStr string + cmdStr string + argsStr string + wi string + pull string + diskName string + diskMount string + diskSub string + schedule string + reqCPU string + reqMem string + limCPU string + limMem string + mountDataStr string + sidecarsFile string + ) + f.StringVar(&req.Location, "location", "", "location") + f.StringVar(&req.Project, "project", "", "project id") + f.StringVar(&req.Name, "name", "", "deployment name") + f.StringVar(&req.Image, "image", "", "docker image") + f.StringVar(&typ, "type", "", "deployment type (WebService,Worker,CronJob,TCPService,InternalTCPService)") + f.IntVar(&port, "port", 0, "port") + f.StringVar(&protoStr, "protocol", "", "protocol (http,https,h2c) [WebService]") + f.BoolVar(&internal, "internal", false, "run as internal service [WebService]") + f.IntVar(&minReplicas, "minReplicas", 0, "autoscale min replicas") + f.IntVar(&maxReplicas, "maxReplicas", 0, "autoscale max replicas") + f.StringVar(&envStr, "env", "", "env map: KEY=VAL,KEY2=VAL2 (overrides all env)") + f.StringVar(&addEnvStr, "addEnv", "", "add env: KEY=VAL,KEY2=VAL2") + f.StringVar(&rmEnvStr, "removeEnv", "", "remove env keys: KEY,KEY2") + f.StringVar(&cmdStr, "command", "", "container command list: /bin/app,--flag") + f.StringVar(&argsStr, "args", "", "container args list: --a,--b=1") + f.StringVar(&wi, "workloadIdentity", "", "workload identity name") + f.StringVar(&pull, "pullSecret", "", "pull secret name") + f.StringVar(&diskName, "disk.name", "", "disk name") + f.StringVar(&diskMount, "disk.mountPath", "", "disk mount path") + f.StringVar(&diskSub, "disk.subPath", "", "disk sub path") + f.StringVar(&schedule, "schedule", "", "cron schedule (CronJob)") + f.StringVar(&reqCPU, "resources.requests.cpu", "", "CPU requests") + f.StringVar(&reqMem, "resources.requests.memory", "", "Memory requests") + f.StringVar(&limCPU, "resources.limits.cpu", "", "CPU limit") + f.StringVar(&limMem, "resources.limits.memory", "", "Memory limit") + f.StringVar(&mountDataStr, "mountData", "", "mount data: /path=VAL,/config=@file") + f.StringVar(&sidecarsFile, "sidecarsFile", "", "path to YAML/JSON file for sidecars array") + f.Parse(args[1:]) + + // Validate deployment type + if typ != "" { + validTypes := map[string]struct{}{ + "WebService": {}, + "Worker": {}, + "CronJob": {}, + "TCPService": {}, + "InternalTCPService": {}, + } + if _, ok := validTypes[typ]; !ok { + return fmt.Errorf("invalid deployment type: %s", typ) + } + } + req.Type = api.ParseDeploymentTypeString(typ) + if port > 0 { + req.Port = &port + } + + // Validate protocol + if protoStr != "" { + validProtocols := map[string]struct{}{ + "http": {}, + "https": {}, + "h2c": {}, + } + if _, ok := validProtocols[protoStr]; !ok { + return fmt.Errorf("invalid protocol: %s", protoStr) + } + } + if protoStr != "" { + p := api.DeploymentProtocol(protoStr) + req.Protocol = &p + } + if internal { + req.Internal = &internal + } + if minReplicas > 0 { + req.MinReplicas = &minReplicas + } + if maxReplicas > 0 { + req.MaxReplicas = &maxReplicas + } + if envStr != "" { + req.Env = parseKVList(envStr) + } + if addEnvStr != "" { + req.AddEnv = parseKVList(addEnvStr) + } + if rmEnvStr != "" { + req.RemoveEnv = splitCommaList(rmEnvStr) + } + if cmdStr != "" { + req.Command = splitCommaList(cmdStr) + } + if argsStr != "" { + req.Args = splitCommaList(argsStr) + } + if wi != "" { + req.WorkloadIdentity = &wi + } + if pull != "" { + req.PullSecret = &pull + } + if diskName != "" || diskMount != "" || diskSub != "" { + req.Disk = &api.DeploymentDisk{ + Name: diskName, + MountPath: diskMount, + SubPath: diskSub, + } + } + + // Validate cron format if schedule is set + if schedule != "" { + _, err := cron.ParseStandard(schedule) + if err != nil { + return fmt.Errorf("invalid cron schedule format: %v", err) + } + req.Schedule = &schedule + } + + if reqCPU != "" || reqMem != "" || limCPU != "" || limMem != "" { + req.Resources = &api.DeploymentResource{ + Requests: api.ResourceItem{CPU: reqCPU, Memory: reqMem}, + Limits: api.ResourceItem{CPU: limCPU, Memory: limMem}, + } + } + + if mountDataStr != "" { + md, err := parseMountData(mountDataStr) + if err != nil { + return err + } + req.MountData = md + } + + if sidecarsFile != "" { + b, err := os.ReadFile(sidecarsFile) + if err != nil { + return err + } + var sc []*api.Sidecar + if strings.HasSuffix(sidecarsFile, ".yaml") || strings.HasSuffix(sidecarsFile, ".yml") { + if err := yaml.Unmarshal(b, &sc); err != nil { + return fmt.Errorf("parse sidecars yaml: %w", err) + } + } else { + if err := json.Unmarshal(b, &sc); err != nil { + return fmt.Errorf("parse sidecars json: %w", err) + } + } + req.Sidecars = sc + } + resp, err = s.Deploy(context.Background(), &req) + if err != nil { + return err + } + return rn.print(resp) +} + func (rn Runner) deploymentSet(args ...string) error { if len(args) == 0 { return fmt.Errorf("invalid command") From 7d95f5b7f8acdbea63ae36f7ca93e5c773918e09 Mon Sep 17 00:00:00 2001 From: Thanapon Johdee <66236295+yokeTH@users.noreply.github.com> Date: Sun, 21 Sep 2025 12:20:34 +0700 Subject: [PATCH 3/4] chore: cleanup nix and change vendorHash --- flake.nix | 44 +++++--------------------------------------- 1 file changed, 5 insertions(+), 39 deletions(-) diff --git a/flake.nix b/flake.nix index 1e8fd2a..c4e4785 100644 --- a/flake.nix +++ b/flake.nix @@ -13,44 +13,16 @@ system: let pkgs = import nixpkgs {inherit system;}; - # go-migrate-pg = pkgs.go-migrate.overrideAttrs (oldAttrs: { - # tags = ["postgres"]; - # }); - - # swag = pkgs.buildGoModule rec { - # pname = "swag"; - # version = "v2.0.0-rc4"; - - # src = pkgs.fetchFromGitHub { - # owner = "swaggo"; - # repo = "swag"; - # rev = version; - # sha256 = "sha256-3dX01PAVEkn8ciVNbIn1IwiSkwogPJLYNo6xTg9jhDA="; - # }; - - # vendorHash = "sha256-bUMW9wjPIT3JLMw9F/NvWqZv1M62o/Y4gIpp6XyHbek="; - # subPackages = ["cmd/swag"]; - # }; - app = pkgs.buildGoModule { - pname = "go-app"; - version = "0.1.0"; + pname = "deploys-cli"; + version = "1.1.0"; src = ./.; - vendorHash = "sha256-jx70HhXLbBt63Vt0iZK8aUwoQnpe57MIjUMzXsnaRgA="; - - nativeBuildInputs = [ - # swag - ]; - - preBuild = '' - # Generate Swagger docs - # swag init -v3.1 -o docs -g main.go --parseDependency --parseInternal - ''; + vendorHash = "sha256-S5nq6DK4356LCMYKX3anjcySAxZhGxFWu1qKXR44C94="; meta = with pkgs.lib; { - description = "Go application with Swagger documentation"; + description = "Deploys.app CLI"; license = licenses.mit; }; }; @@ -62,16 +34,11 @@ devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ - go_1_25 + go_1_24 gopls golangci-lint air - # swag - - # sqlc - # go-migrate-pg - # sql-formatter pre-commit ]; @@ -79,7 +46,6 @@ shellHook = '' echo "Go development environment ready" echo "Go version: $(go version)" - # echo "Swag version: $(swag --version)" ''; }; } From e072a53a867fe986fd6033ea3d33d60465056c0f Mon Sep 17 00:00:00 2001 From: Thanapon Johdee <66236295+yokeTH@users.noreply.github.com> Date: Sun, 21 Sep 2025 12:23:41 +0700 Subject: [PATCH 4/4] chore: Ignore Nix build artifacts and .direnv --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 55bdd6a..5df694f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .idea/ .DS_Store + +# Nix .direnv +result