diff --git a/.github/workflows/ship_it.yml b/.github/workflows/ship_it.yml new file mode 100644 index 0000000..b476b29 --- /dev/null +++ b/.github/workflows/ship_it.yml @@ -0,0 +1,33 @@ +name: "Ship It!" + +concurrency: + # There should only be able one running job per repository / branch combo. + # We do not want multiple deploys running in parallel. + group: ${{ github.repository }}-${{ github.ref_name }} + +on: + push: + branches: + - 'master' + - 'daggerize' + pull_request: + workflow_dispatch: + +jobs: + dagger: + runs-on: ubuntu-latest + steps: + - name: "Checkout code..." + uses: actions/checkout@v3 + + - name: "Setup Go..." + uses: actions/setup-go@v4 + with: + go-version: "1.20" + + - name: "Ship it!" + env: + GHCR_USERNAME: "${{ vars.GHCR_USERNAME }}" + GHCR_PASSWORD: "${{ secrets.GHCR_PASSWORD }}" + run: | + go run main.go cicd diff --git a/.tool-versions b/.tool-versions index 69ed28b..d168d5d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ ruby 2.3.3 +flyctl 0.1.104 diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..63bfb11 --- /dev/null +++ b/fly.toml @@ -0,0 +1,17 @@ +# https://fly.io/docs/reference/configuration/ +app = "changelog-nightly-2023-10-09" +primary_region = "iad" + +[mounts] + source = "changelog_nightly_2023_10_09" + destination = "/app/dist" + +[http_service] + internal_port = 80 + force_https = true + +[[http_service.checks]] + interval = "5s" + timeout = "4s" + method = "GET" + path = "/health" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e5d02c1 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/thechangelog/nightly + +go 1.20 + +require ( + dagger.io/dagger v0.8.7 + github.com/urfave/cli/v2 v2.25.7 +) + +require ( + github.com/99designs/gqlgen v0.17.31 // indirect + github.com/Khan/genqlient v0.6.0 // indirect + github.com/adrg/xdg v0.4.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/vektah/gqlparser/v2 v2.5.6 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.12.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4d2d87e --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +dagger.io/dagger v0.8.7 h1:3wGzK9RKjLcNk5AnIYqkO7TzIJyftb8fT+h0WM9chAM= +dagger.io/dagger v0.8.7/go.mod h1:DbJi6aSXaRLuio0lHlnpNxfuAL5uMJvRy4UIytmbtLo= +github.com/99designs/gqlgen v0.17.31 h1:VncSQ82VxieHkea8tz11p7h/zSbvHSxSDZfywqWt158= +github.com/99designs/gqlgen v0.17.31/go.mod h1:i4rEatMrzzu6RXaHydq1nmEPZkb3bKQsnxNRHS4DQB4= +github.com/Khan/genqlient v0.6.0 h1:Bwb1170ekuNIVIwTJEqvO8y7RxBxXu639VJOkKSrwAk= +github.com/Khan/genqlient v0.6.0/go.mod h1:rvChwWVTqXhiapdhLDV4bp9tz/Xvtewwkon4DpWWCRM= +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/vektah/gqlparser/v2 v2.5.6 h1:Ou14T0N1s191eRMZ1gARVqohcbe1e8FrcONScsq8cRU= +github.com/vektah/gqlparser/v2 v2.5.6/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9536c7b --- /dev/null +++ b/main.go @@ -0,0 +1,401 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "dagger.io/dagger" + "github.com/urfave/cli/v2" +) + +func main() { + ctx := context.Background() + dag, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr)) + if err != nil { + panic(err) + } + defer dag.Close() + + app := &cli.App{ + Name: "nightly", + Usage: "Changelog Nightly CI/CD pipeline commands", + Version: "v2023.10.08", + Compiled: time.Now(), + Authors: []*cli.Author{ + { + Name: "Gerhard Lazu", + Email: "gerhard@changelog.com", + }, + }, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "nocache", + Aliases: []string{"n"}, + Usage: "Bust Dagger ops cache", + EnvVars: []string{"NOCACHE"}, + }, + &cli.BoolFlag{ + Name: "debug", + Aliases: []string{"d"}, + Usage: "Debug command", + EnvVars: []string{"DEBUG"}, + }, + &cli.StringFlag{ + Name: "platform", + Aliases: []string{"p"}, + Usage: "Runtime platform", + Value: "linux/amd64", + EnvVars: []string{"PLATFORM"}, + }, + }, + Commands: []*cli.Command{ + { + Name: "build", + Aliases: []string{"b"}, + Usage: "Builds container image", + Action: func(cCtx *cli.Context) error { + newPipeline(ctx, cCtx, dag). + Build() + + return nil + }, + }, + { + Name: "test", + Aliases: []string{"t"}, + Usage: "Runs tests", + Action: func(cCtx *cli.Context) error { + newPipeline(ctx, cCtx, dag). + Build(). + Test() + + return nil + }, + }, + { + Name: "cicd", + Usage: "Runs the entire CI/CD pipeline", + Action: func(cCtx *cli.Context) error { + newPipeline(ctx, cCtx, dag). + Build(). + Test(). + Prod(). + Publish() + + return nil + }, + }, + }, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +type Pipeline struct { + ctx context.Context + dag *dagger.Client + debug bool + nocache bool + platform dagger.Platform + workspace *dagger.Container + tools *Versions +} + +func newPipeline(ctx context.Context, cCtx *cli.Context, dag *dagger.Client) *Pipeline { + p := &Pipeline{ + ctx: ctx, + platform: dagger.Platform(cCtx.String("platform")), + debug: cCtx.Bool("debug"), + nocache: cCtx.Bool("nocache"), + dag: dag, + tools: currentToolVersions(), + } + + p.workspace = p.Container() + + return p +} + +func (p *Pipeline) OK() *Pipeline { + var err error + p.workspace, err = p.workspace.Sync(p.ctx) + if err != nil { + panic(err) + } + return p +} + +func (p *Pipeline) Container() *dagger.Container { + return p.dag.Container(dagger.ContainerOpts{ + Platform: p.platform, + }) +} + +func (p *Pipeline) Build() *Pipeline { + p.workspace = p.workspace.Pipeline("container image"). + From(fmt.Sprintf("ruby:%s-alpine", p.tools.Ruby())). + WithExec([]string{"ruby", "--version"}). + WithExec([]string{"apk", "update"}). + WithExec([]string{"apk", "add", "git", "build-base", "sqlite-dev", "bash"}) + + p.workspace = p.workspace. + WithExec([]string{"apk", "add", "nginx"}). + WithFile("/etc/nginx/nginx.conf", p.dag.Host().File("nginx.conf")). + WithExec([]string{"nginx", "-t"}) + + if p.nocache { + p.workspace = p.workspace.WithEnvVariable("DAGGER_CACHE_BUSTED_AT", time.Now().String()) + } + + app := p.dag.Host().Directory(".", dagger.HostDirectoryOpts{ + Include: []string{ + "images", + "lib", + "styles", + "views", + "Gemfile", + "Gemfile.lock", + "LICENSE", + "Rakefile", + }}) + + p.workspace = p.workspace. + WithDirectory("/app", app). + WithWorkdir("/app"). + WithExec([]string{"bundle", "install", "--frozen", "--without=test"}). + WithExec([]string{"rake", "-T"}). + WithEntrypoint(nil). + WithDefaultArgs() + + if p.debug { + p.workspace = p.workspace.Pipeline("generate with local config"). + WithFile("/app/.env", p.dag.Host().File(".env")). + WithFile("/app/bq-key.p12", p.dag.Host().File("bq-key.p12")). + WithFile("/app/github.db", p.dag.Host().File("github.db")). + WithExec([]string{"bash", "-c", `DATE=2023-10-08 rake generate`}) + + _, err := p.workspace.Pipeline("export tmp/image.tar"). + Export(p.ctx, "tmp/image.tar") + if err != nil { + panic(err) + } + } + + return p.OK() +} + +func (p *Pipeline) Test() *Pipeline { + if p.nocache { + p.workspace = p.workspace.WithEnvVariable("DAGGER_CACHE_BUSTED_AT", time.Now().String()) + } + + p.workspace = p.workspace. + WithExec([]string{"bundle", "install", "--frozen", "--with=test"}). + WithDirectory("/app/spec", p.dag.Host().Directory("spec")). + WithExec([]string{"rspec"}) + + return p.OK() +} + +func (p *Pipeline) Prod() *Pipeline { + if p.nocache { + p.workspace = p.workspace.WithEnvVariable("DAGGER_CACHE_BUSTED_AT", time.Now().String()) + } + + p.workspace = p.workspace. + WithEntrypoint([]string{"nginx"}) + + return p.OK() +} + +func (p *Pipeline) Publish() *Pipeline { + newFlyio(p). + App(). + Publish(). + Deploy() + + return p +} + +type Versions struct { + toolVersions map[string]string +} + +// https://www.ruby-lang.org/en/downloads/releases/ || asdf list all ruby +func (v *Versions) Ruby() string { + return v.toolVersions["ruby"] +} + +// https://hub.docker.com/r/flyio/flyctl/tags +func (v *Versions) Flyctl() string { + return v.toolVersions["flyctl"] +} + +func currentToolVersions() *Versions { + return &Versions{ + toolVersions: toolVersions(), + } +} + +func toolVersions() map[string]string { + wd, err := os.Getwd() + if err != nil { + panic(err) + } + versions, err := os.Open(filepath.Join(wd, ".tool-versions")) + if err != nil { + panic(err) + } + toolVersions := make(map[string]string) + scanner := bufio.NewScanner(versions) + for scanner.Scan() { + line := scanner.Text() + toolAndVersion := strings.Split(line, " ") + toolVersions[toolAndVersion[0]] = toolAndVersion[1] + } + + return toolVersions +} + +type Flyio struct { + app string + deployStrategy string + deployWait string + publishedImageRef string + org string + pipeline *Pipeline + region string + registry string + token *dagger.Secret + version string + volume string +} + +func newFlyio(p *Pipeline) *Flyio { + token := os.Getenv("FLY_API_TOKEN") + if token == "" { + panic("FLY_API_TOKEN env var must be set") + } + + f := &Flyio{ + app: "changelog-nightly-2023-10-09", + deployStrategy: "bluegreen", + deployWait: "60", + org: "changelog", + pipeline: p, + region: "iad", + registry: "registry.fly.io", + token: p.dag.SetSecret("FLY_API_TOKEN", token), + version: p.tools.Flyctl(), + } + + f.volume = strings.ReplaceAll(f.app, "-", "_") + + return f +} + +func (f *Flyio) Cli() *dagger.Container { + container := f.pipeline.Container().Pipeline("fly.io"). + From(fmt.Sprintf("flyio/flyctl:v%s", f.version)). + WithSecretVariable("FLY_API_TOKEN", f.token). + WithEnvVariable("RUN_AT", time.Now().String()). + WithNewFile("fly.toml", dagger.ContainerWithNewFileOpts{ + Contents: f.Config(), + }) + + _, err := container.File("fly.toml").Export(f.pipeline.ctx, "fly.toml") + if err != nil { + panic(err) + } + + return container +} + +func (f *Flyio) Config() string { + return fmt.Sprintf(`# https://fly.io/docs/reference/configuration/ +app = "%s" +primary_region = "%s" + +[mounts] + source = "%s" + destination = "/app/dist" + +[http_service] + internal_port = 80 + force_https = true + +[[http_service.checks]] + interval = "5s" + timeout = "4s" + method = "GET" + path = "/health"`, + f.app, f.region, f.volume) +} + +func (f *Flyio) App() *Flyio { + cli := f.Cli() + + _, err := cli. + WithExec([]string{"status"}). + Sync(f.pipeline.ctx) + if err != nil { + _, err = cli. + WithExec([]string{"apps", "create", f.app, "--org", f.org}). + WithExec([]string{"volume", "create", f.volume, "--yes", "--region", f.region}). + Sync(f.pipeline.ctx) + if err != nil { + panic(err) + } + } + + return f +} + +func (f *Flyio) ImageRef() string { + gitSHA := os.Getenv("GITHUB_SHA") + if gitSHA == "" { + gitSHA = "dev" + } + + return fmt.Sprintf("%s/%s:%s", f.registry, f.app, gitSHA) +} + +func (f *Flyio) Publish() *Flyio { + var err error + + f.publishedImageRef, err = f.pipeline.workspace. + Pipeline("publish"). + WithRegistryAuth(f.registry, "x", f.token). + Publish(f.pipeline.ctx, f.ImageRef()) + if err != nil { + panic(err) + } + + return f +} + +func (f *Flyio) Deploy() *Flyio { + _, err := f.Cli().Pipeline("deploy"). + WithExec([]string{ + "deploy", "--now", + "--app", f.app, + "--image", f.publishedImageRef, + "--ha=false", // we have volumes & can only deploy one + "--strategy", f.deployStrategy, + "--wait-timeout", f.deployWait, + }). + Sync(f.pipeline.ctx) + if err != nil { + panic(err) + } + + return f +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..d4cb265 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,35 @@ +daemon off; +user nginx; +worker_processes auto; + +error_log /dev/stderr warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" "$http_x_forwarded_for"'; + access_log /dev/stdout main; + + sendfile on; + + server { + listen 80; + listen [::]:80; + server_name localhost; + + location / { + root /app/dist; + try_files $uri $uri/index.html $uri.html =404; + } + + location /health { + return 204; + } + } +}