diff --git a/.github/workflows/build-publish.yaml b/.github/workflows/build-publish.yaml index d59d772..cded38b 100644 --- a/.github/workflows/build-publish.yaml +++ b/.github/workflows/build-publish.yaml @@ -22,6 +22,7 @@ jobs: with: work-dir: chart args: --namespace my-namespace --release my-release --app-version 1.0.0 --chart-version 1.0.0 + test-app: runs-on: ubuntu-latest steps: @@ -35,6 +36,10 @@ jobs: - name: Setup dependencies run: go mod download - name: Run tests + env: + GH_USER: nestobot + GH_TOKEN: ${{ secrets.TESTING_TOKEN }} + CATALOG_URL: https://github.com/nestoca/catalog run: go test ./... build: runs-on: ubuntu-latest @@ -104,7 +109,7 @@ jobs: if: ${{ steps.changelog.outputs.skipped == 'false' }} run: | yq -i '.appVersion = "${{ steps.changelog.outputs.version }}"' 'chart/Chart.yaml' - yq -i '.version = "${{ steps.changelog.outputs.version }}"' 'chart/Chart.yaml' + yq -i '.version = "${{ steps.changelog.outputs.version }}"' 'chart/Chart.yaml' - name: Commit release if: ${{ steps.changelog.outputs.skipped == 'false' }} @@ -141,20 +146,20 @@ jobs: run: | ORG_NAME=${GITHUB_REPOSITORY%%/*} REPO_NAME=${GITHUB_REPOSITORY#*/} - + CHART_NAME=$(yq .name ${{ github.workspace }}/chart/Chart.yaml) - + mkdir -p ${{ runner.temp }}/helm-release - + cd ${{ runner.temp }}/helm-release - + # Try to get the current index.yaml of the helm repo from https://${ORG_NAME}.github.io/${REPO_NAME}/index.yaml # If it fails, create a new index.yaml curl -f -L https://${ORG_NAME}.github.io/${REPO_NAME}/index.yaml -o index.yaml || echo -e "apiVersion: v1\nentries: {}" > index.yaml - + helm package ${{ github.workspace }}/chart helm repo index --url https://github.com/${{ github.repository }}/releases/download/${{ steps.changelog.outputs.tag }} --merge index.yaml . - + echo "artifact-path=${{ runner.temp }}/helm-release/${CHART_NAME}-${{ steps.changelog.outputs.version }}.tgz" >> $GITHUB_OUTPUT - name: Upload pages artifact diff --git a/.gitignore b/.gitignore index c15a80d..bcd4403 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ tmp go.work *.pem + +*.env + +test_output diff --git a/Dockerfile b/Dockerfile index 1f3f472..2080715 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,19 +7,23 @@ ENV CGO_ENABLED=0 \ RUN openssh-client ca-certificates && update-ca-certificates 2>/dev/null || true ENV HOME=/home/golang + WORKDIR /app + RUN adduser -h $HOME -D -u 1000 -G root golang && \ chown golang:root /app && \ chmod g=u /app $HOME + USER golang:root COPY --chown=golang:root go.mod go.sum ./ RUN go mod download -COPY --chown=golang:root main.go ./ +COPY --chown=golang:root cmd ./cmd COPY --chown=golang:root internal ./internal -RUN go build -v -o joy-generator main.go + +RUN go build -v -o joy-generator ./cmd/server FROM scratch AS prod @@ -31,4 +35,5 @@ USER golang:root EXPOSE 8080 WORKDIR /app + ENTRYPOINT ["./joy-generator"] diff --git a/Makefile b/Makefile index 83789cf..e599772 100644 --- a/Makefile +++ b/Makefile @@ -2,3 +2,6 @@ kind-build: docker build -t localhost:5001/joy-generator:latest . docker push localhost:5001/joy-generator:latest + +fmt: + goimports --local github.com/nestoca/joy-generator -w . \ No newline at end of file diff --git a/chart/Makefile b/chart/Makefile new file mode 100644 index 0000000..be826a3 --- /dev/null +++ b/chart/Makefile @@ -0,0 +1,5 @@ +test: + testchart run --namespace my-namespace --release my-release --app-version 1.0.0 --chart-version 1.0.0 + +update: + testchart update --namespace my-namespace --release my-release --app-version 1.0.0 --chart-version 1.0.0 diff --git a/chart/templates/_configData.yaml b/chart/templates/_configData.yaml deleted file mode 100644 index 526aea1..0000000 --- a/chart/templates/_configData.yaml +++ /dev/null @@ -1,16 +0,0 @@ -{{/* -Config ENV Vars for the application -*/}} -{{- define "joy-generator.config" }} -JOY_PLUGIN_TOKEN: "{{ required "pluginToken is required" .Values.applicationConfig.pluginToken }}" -JOY_REPO_URL: "{{ required "repoUrl is required" .Values.applicationConfig.repoUrl }}" -{{- if .Values.applicationConfig.githubApp }} -JOY_GITHUB_APP_ID: "{{ .Values.applicationConfig.githubApp.appId }}" -JOY_GITHUB_APP_INSTALLATION_ID: "{{ .Values.applicationConfig.githubApp.installationId }}" -{{- else if .Values.applicationConfig.githubUser }} -JOY_GITHUB_USER: "{{ .Values.applicationConfig.githubUser.username }}" -JOY_GITHUB_TOKEN: "{{ .Values.applicationConfig.githubUser.token }}" -{{- else }} -{{- fail "one of githubUser or githubAuth is required" }} -{{- end }} -{{- end }} diff --git a/chart/templates/argocd-config.yaml b/chart/templates/argocd-config.yaml index af945e4..7924cc5 100644 --- a/chart/templates/argocd-config.yaml +++ b/chart/templates/argocd-config.yaml @@ -3,5 +3,5 @@ kind: ConfigMap metadata: name: {{ include "joy-generator.fullname" . }}-config data: - token: "${{ include "joy-generator.fullname" . }}-config:JOY_PLUGIN_TOKEN" + token: "${{ include "joy-generator.fullname" . }}-config:PLUGIN_TOKEN" baseUrl: "http://{{ include "joy-generator.fullname" . }}" diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index fd4b34f..fd24ce4 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -29,7 +29,7 @@ spec: volumes: - name: catalog-dir emptyDir: {} - {{- if .Values.applicationConfig.githubApp }} + {{- if .Values.env.GH_APP_ID }} - name: github-app-private-key secret: secretName: {{ include "joy-generator.fullname" . }}-github-app-key @@ -43,19 +43,15 @@ spec: volumeMounts: - mountPath: /tmp/catalog name: catalog-dir - {{- if .Values.applicationConfig.githubApp }} + {{- if .Values.env.GH_APP_ID }} - mountPath: /etc/joy/config name: github-app-private-key {{- end }} + {{- if .Values.env.GH_APP_ID }} env: - - name: JOY_CATALOG_DIR - value: /tmp/catalog - - name: GIN_MODE - value: release - {{- if .Values.applicationConfig.githubApp }} - name: JOY_GITHUB_APP_PRIVATE_KEY_PATH value: /etc/joy/config/githubApp.pem - {{- end }} + {{- end }} envFrom: - secretRef: name: {{ include "joy-generator.fullname" . }}-config diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml index e9e67c6..c259733 100644 --- a/chart/templates/secret.yaml +++ b/chart/templates/secret.yaml @@ -1,3 +1,4 @@ +{{- and (required ".Values.env.PLUGIN_TOKEN is required" .Values.env.PLUGIN_TOKEN) "" -}} {{- if eq .Values.secretType "secret" }} apiVersion: v1 kind: Secret @@ -15,15 +16,16 @@ metadata: {{- end }} {{- if eq .Values.secretType "secret" }} stringData: -{{- include "joy-generator.config" . | indent 2 }} +{{ toYaml .Values.env | indent 2 }} {{- else if eq .Values.secretType "sealedSecret" }} spec: encryptedData: -{{- include "joy-generator.config" . | indent 4 }} +{{ toYaml .Values.env | indent 4 }} {{- end }} --- -{{- if .Values.applicationConfig.githubApp }} +{{- if .Values.env.GH_APP_ID }} +{{- and (required ".Values.env.GH_APP_INSTALLATION_ID is required" .Values.env.GH_APP_INSTALLATION_ID) "" | -}} {{- if eq .Values.secretType "secret" }} apiVersion: v1 kind: Secret @@ -42,11 +44,11 @@ metadata: {{- if eq .Values.secretType "secret" }} stringData: githubApp.pem: | -{{ .Values.applicationConfig.githubApp.privateKey | indent 4 }} +{{ required ".Values.githubApp.privateKey key is required" .Values.githubApp.privateKey | indent 4 }} {{- else if eq .Values.secretType "sealedSecret" }} spec: encryptedData: githubApp.pem: | -{{ .Values.applicationConfig.githubApp.privateKey | indent 6 }} +{{ required " .Values.githubApp.privateKey key is required" .Values.githubApp.privateKey | indent 6 }} {{- end }} {{- end }} diff --git a/chart/tests/with-github-app-and-sealed-secrets/expected.yaml b/chart/tests/with-github-app-and-sealed-secrets/expected.yaml index f89ab86..10da6e9 100644 --- a/chart/tests/with-github-app-and-sealed-secrets/expected.yaml +++ b/chart/tests/with-github-app-and-sealed-secrets/expected.yaml @@ -5,7 +5,7 @@ kind: ConfigMap metadata: name: my-release-joy-generator-config data: - token: "$my-release-joy-generator-config:JOY_PLUGIN_TOKEN" + token: "$my-release-joy-generator-config:PLUGIN_TOKEN" baseUrl: "http://my-release-joy-generator" --- # Source: joy-generator/templates/service.yaml @@ -73,10 +73,6 @@ spec: - mountPath: /etc/joy/config name: github-app-private-key env: - - name: JOY_CATALOG_DIR - value: /tmp/catalog - - name: GIN_MODE - value: release - name: JOY_GITHUB_APP_PRIVATE_KEY_PATH value: /etc/joy/config/githubApp.pem envFrom: @@ -105,11 +101,17 @@ metadata: annotations: sealedsecrets.bitnami.com/cluster-wide: "true" spec: - encryptedData: - JOY_PLUGIN_TOKEN: "@very!l0ngands3curet0ken" - JOY_REPO_URL: "https://github.com/example/foobar.git" - JOY_GITHUB_APP_ID: "123456" - JOY_GITHUB_APP_INSTALLATION_ID: "789101112" + encryptedData: + CATALOG_DIR: /tmp/catalog + CATALOG_REVISION: "" + CATALOG_URL: https://github.com/example/foobar.git + GH_APP_ID: "123456" + GH_APP_INSTALLATION_ID: "789101112" + GH_TOKEN: "" + GH_USER: "" + GRACE_PERIOD: 10s + PLUGIN_TOKEN: '@very!l0ngands3curet0ken' + PORT: :8080 --- # Source: joy-generator/templates/secret.yaml apiVersion: bitnami.com/v1alpha1 diff --git a/chart/tests/with-github-app-and-sealed-secrets/values.yaml b/chart/tests/with-github-app-and-sealed-secrets/values.yaml index de9d2ec..af78a3b 100644 --- a/chart/tests/with-github-app-and-sealed-secrets/values.yaml +++ b/chart/tests/with-github-app-and-sealed-secrets/values.yaml @@ -1,19 +1,14 @@ -applicationConfig: - pluginToken: "@very!l0ngands3curet0ken" - # The HTTPS Git URL of your joy catalog - repoUrl: "https://github.com/example/foobar.git" - # The GitHub app configuration. - # Required if githubToken is not set. - githubApp: - # The GitHub application ID - appId: 123456 - # The GitHub application installation ID - installationId: 789101112 - # The GitHub application private key (PEM format) - privateKey: | - FOOBARLOREMISPUM +env: + CATALOG_URL: https://github.com/example/foobar.git + PLUGIN_TOKEN: "@very!l0ngands3curet0ken" + GH_APP_ID: "123456" + GH_APP_INSTALLATION_ID: "789101112" -secretType: 'sealedSecret' +githubApp: + privateKey: | + FOOBARLOREMISPUM + +secretType: "sealedSecret" sealedSecretAnnotations: sealedsecrets.bitnami.com/cluster-wide: "true" diff --git a/chart/tests/with-github-app/expected.yaml b/chart/tests/with-github-app/expected.yaml index 7d35f2e..951bca2 100644 --- a/chart/tests/with-github-app/expected.yaml +++ b/chart/tests/with-github-app/expected.yaml @@ -4,11 +4,17 @@ apiVersion: v1 kind: Secret metadata: name: my-release-joy-generator-config -stringData: - JOY_PLUGIN_TOKEN: "@very!l0ngands3curet0ken" - JOY_REPO_URL: "https://github.com/example/foobar.git" - JOY_GITHUB_APP_ID: "123456" - JOY_GITHUB_APP_INSTALLATION_ID: "789101112" +stringData: + CATALOG_DIR: /tmp/catalog + CATALOG_REVISION: "" + CATALOG_URL: https://github.com/example/foobar.git + GH_APP_ID: "123" + GH_APP_INSTALLATION_ID: "666" + GH_TOKEN: "" + GH_USER: "" + GRACE_PERIOD: 10s + PLUGIN_TOKEN: '@very!l0ngands3curet0ken' + PORT: :8080 --- # Source: joy-generator/templates/secret.yaml apiVersion: v1 @@ -25,7 +31,7 @@ kind: ConfigMap metadata: name: my-release-joy-generator-config data: - token: "$my-release-joy-generator-config:JOY_PLUGIN_TOKEN" + token: "$my-release-joy-generator-config:PLUGIN_TOKEN" baseUrl: "http://my-release-joy-generator" --- # Source: joy-generator/templates/service.yaml @@ -93,10 +99,6 @@ spec: - mountPath: /etc/joy/config name: github-app-private-key env: - - name: JOY_CATALOG_DIR - value: /tmp/catalog - - name: GIN_MODE - value: release - name: JOY_GITHUB_APP_PRIVATE_KEY_PATH value: /etc/joy/config/githubApp.pem envFrom: diff --git a/chart/tests/with-github-app/values.yaml b/chart/tests/with-github-app/values.yaml index 5f5dd8b..c6fcae2 100644 --- a/chart/tests/with-github-app/values.yaml +++ b/chart/tests/with-github-app/values.yaml @@ -1,18 +1,19 @@ # Configuration settings for the joy-generator instance -applicationConfig: - pluginToken: "@very!l0ngands3curet0ken" +env: + PLUGIN_TOKEN: "@very!l0ngands3curet0ken" + + GH_APP_ID: "123" + GH_APP_INSTALLATION_ID: "666" + # The HTTPS Git URL of your joy catalog - repoUrl: "https://github.com/example/foobar.git" - # The GitHub app configuration. - # Required if githubToken is not set. - githubApp: - # The GitHub application ID - appId: 123456 - # The GitHub application installation ID - installationId: 789101112 - # The GitHub application private key (PEM format) - privateKey: | - FOOBARLOREMISPUM + CATALOG_URL: "https://github.com/example/foobar.git" + +# The GitHub app configuration. +# Required if githubToken is not set. +githubApp: + # The GitHub application private key (PEM format) + privateKey: | + FOOBARLOREMISPUM image: tag: 0.1.2 diff --git a/chart/tests/with-github-token/expected.yaml b/chart/tests/with-github-token/expected.yaml index 33763fb..c06604a 100644 --- a/chart/tests/with-github-token/expected.yaml +++ b/chart/tests/with-github-token/expected.yaml @@ -4,11 +4,17 @@ apiVersion: v1 kind: Secret metadata: name: my-release-joy-generator-config -stringData: - JOY_PLUGIN_TOKEN: "@very!l0ngands3curet0ken" - JOY_REPO_URL: "https://github.com/example/foobar.git" - JOY_GITHUB_USER: "username" - JOY_GITHUB_TOKEN: "12312312312312" +stringData: + CATALOG_DIR: /tmp/catalog + CATALOG_REVISION: "" + CATALOG_URL: https://github.com/example/foobar.git + GH_APP_ID: "" + GH_APP_INSTALLATION_ID: "" + GH_TOKEN: "12312312312312" + GH_USER: username + GRACE_PERIOD: 10s + PLUGIN_TOKEN: '@very!l0ngands3curet0ken' + PORT: :8080 --- # Source: joy-generator/templates/argocd-config.yaml apiVersion: v1 @@ -16,7 +22,7 @@ kind: ConfigMap metadata: name: my-release-joy-generator-config data: - token: "$my-release-joy-generator-config:JOY_PLUGIN_TOKEN" + token: "$my-release-joy-generator-config:PLUGIN_TOKEN" baseUrl: "http://my-release-joy-generator" --- # Source: joy-generator/templates/service.yaml @@ -78,11 +84,6 @@ spec: volumeMounts: - mountPath: /tmp/catalog name: catalog-dir - env: - - name: JOY_CATALOG_DIR - value: /tmp/catalog - - name: GIN_MODE - value: release envFrom: - secretRef: name: my-release-joy-generator-config diff --git a/chart/tests/with-github-token/values.yaml b/chart/tests/with-github-token/values.yaml index 934f512..8410a0f 100644 --- a/chart/tests/with-github-token/values.yaml +++ b/chart/tests/with-github-token/values.yaml @@ -2,14 +2,11 @@ # This is a YAML-formatted file. # Declare variables to be passed into your templates. -# Configuration settings for the joy-generator instance -applicationConfig: - pluginToken: "@very!l0ngands3curet0ken" - # The HTTPS Git URL of your joy catalog - repoUrl: "https://github.com/example/foobar.git" - githubUser: - token: "12312312312312" - username: "username" +env: + PLUGIN_TOKEN: "@very!l0ngands3curet0ken" + CATALOG_URL: https://github.com/example/foobar.git + GH_USER: username + GH_TOKEN: "12312312312312" image: tag: 0.1.2 diff --git a/chart/values.yaml b/chart/values.yaml index 629be5f..48c6503 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -2,6 +2,18 @@ # This is a YAML-formatted file. # Declare variables to be passed into your templates. +env: + PORT: :8080 + GRACE_PERIOD: 10s + PLUGIN_TOKEN: "" + CATALOG_URL: "" + CATALOG_DIR: /tmp/catalog + CATALOG_REVISION: "" + GH_USER: "" + GH_TOKEN: "" + GH_APP_ID: "" + GH_APP_INSTALLATION_ID: "" + # Configuration settings for the joy-generator instance applicationConfig: # The shared token that ArgoCD will use to call the joy-generator plugin API @@ -29,7 +41,7 @@ applicationConfig: # The secret type to use for applicationConfig values. # Can be either 'secret' or 'sealedSecret'. -secretType: 'secret' +secretType: "secret" # Additional annotations for sealed secrets sealedSecretAnnotations: {} @@ -47,10 +59,12 @@ fullnameOverride: "" podAnnotations: {} -podSecurityContext: {} +podSecurityContext: + {} # fsGroup: 2000 -securityContext: {} +securityContext: + {} # capabilities: # drop: # - ALL @@ -62,7 +76,8 @@ service: type: ClusterIP port: 80 -resources: {} +resources: + {} # We usually recommend not to specify default resources and to leave this as a conscious # choice for the user. This also increases chances charts run on environments with little # resources, such as Minikube. If you do want to specify resources, uncomment the following diff --git a/cmd/server/api_get_params_test.go b/cmd/server/api_get_params_test.go new file mode 100644 index 0000000..7ec0750 --- /dev/null +++ b/cmd/server/api_get_params_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "cmp" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/nestoca/joy-generator/internal/generator" + "github.com/nestoca/joy-generator/internal/github" +) + +func TestGetParamsE2E(t *testing.T) { + logs := &TestLogOutputs{} + + logger := zerolog.New(logs) + + user := github.User{ + Name: os.Getenv("GH_USER"), + Token: os.Getenv("GH_TOKEN"), + } + + catalog := github.RepoMetadata{ + Path: cmp.Or(os.Getenv("CATALOG_PATH"), filepath.Join(os.TempDir(), "catalog-test")), + URL: os.Getenv("CATALOG_URL"), + TargetRevision: os.Getenv("CATALOG_REVISION"), + } + + require.NoError(t, os.RemoveAll(catalog.Path)) + + repo, err := user.NewRepo(catalog) + require.NoError(t, err, "failed to create repo for user: %s", user.Name) + + repo = repo.WithLogger(logger) + + handler := Handler(HandlerParams{ + pluginToken: "test-token", + logger: logger, + repo: repo, + generator: &generator.Generator{ + LoadJoyContext: generator.RepoLoader(repo), + Logger: logger, + }, + }) + + server := httptest.NewServer(handler) + defer server.Close() + + req, err := http.NewRequest("POST", server.URL+"/api/v1/getparams.execute", strings.NewReader("{}")) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer test-token") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + var body bytes.Buffer + _, err = io.Copy(&body, resp.Body) + require.NoError(t, err) + + require.Equal(t, 200, resp.StatusCode, body.String()) + + var response generator.GetParamsResponse + require.NoError(t, json.Unmarshal(body.Bytes(), &response)) + + require.Greater(t, len(response.Output.Parameters), 0) + + require.Greater(t, len(logs.Records), 0) + for _, record := range logs.Records { + require.NotEmpty(t, record["level"]) + require.NotEqual(t, "error", record["level"]) + } +} + +type TestLogOutputs struct { + Records []map[string]any +} + +func (output *TestLogOutputs) Write(data []byte) (int, error) { + var record map[string]any + if err := json.Unmarshal(data, &record); err != nil { + return 0, fmt.Errorf("invalid record: %w", err) + } + output.Records = append(output.Records, record) + return len(data), nil +} diff --git a/cmd/server/config.go b/cmd/server/config.go new file mode 100644 index 0000000..9da331e --- /dev/null +++ b/cmd/server/config.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "path/filepath" + "time" + + "github.com/davidmdm/conf" + + "github.com/nestoca/joy-generator/internal/github" +) + +type Config struct { + Port string + GracePeriod time.Duration + + PluginToken string + + Catalog github.RepoMetadata + + Github struct { + User github.User + App github.App + } +} + +func GetConfig() Config { + var cfg Config + + conf.Var(conf.Environ, &cfg.Port, "PORT", conf.Default(":8080")) + conf.Var(conf.Environ, &cfg.GracePeriod, "GRACE_PERIOD", conf.Default(10*time.Second)) + conf.Var(conf.Environ, &cfg.PluginToken, "PLUGIN_TOKEN") + conf.Var(conf.Environ, &cfg.Catalog.URL, "CATALOG_URL") + conf.Var(conf.Environ, &cfg.Catalog.Path, "CATALOG_DIR", conf.Default(filepath.Join(os.TempDir(), "catalog"))) + conf.Var(conf.Environ, &cfg.Catalog.TargetRevision, "CATALOG_REVISION") + conf.Var(conf.Environ, &cfg.Github.User.Token, "GH_TOKEN") + conf.Var(conf.Environ, &cfg.Github.User.Name, "GH_USER") + conf.Var(conf.Environ, &cfg.Github.App.ID, "GH_APP_ID") + conf.Var(conf.Environ, &cfg.Github.App.InstallationID, "GH_APP_INSTALLATION_ID") + conf.Var(conf.Environ, &cfg.Github.App.PrivateKeyPath, "GH_APP_PRIVATE_KEY_PATH") + + conf.Environ.MustParse() + + return cfg +} diff --git a/cmd/server/handler.go b/cmd/server/handler.go new file mode 100644 index 0000000..51c62a4 --- /dev/null +++ b/cmd/server/handler.go @@ -0,0 +1,131 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" + + "github.com/nestoca/joy-generator/internal/generator" + "github.com/nestoca/joy-generator/internal/github" +) + +func init() { + gin.SetMode(gin.ReleaseMode) +} + +type HandlerParams struct { + pluginToken string + logger zerolog.Logger + repo *github.Repo + generator *generator.Generator +} + +func Handler(params HandlerParams) http.Handler { + engine := gin.New() + + engine.Use( + RecoveryMiddleware(params.logger), + ObservabilityMiddleware(params.logger), + ) + + engine.GET("/api/v1/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + engine.GET("/api/v1/readiness", func(c *gin.Context) { + if err := params.repo.Pull(); err != nil { + c.JSON(500, gin.H{ + "status": "error", + "detail": err.Error(), + }) + return + } + + c.JSON(200, gin.H{"status": "ok"}) + }) + + generatorAPI := generator.API{ + Logger: params.logger, + Generator: params.generator, + } + + engine.POST( + "/api/v1/getparams.execute", + func(c *gin.Context) { + if c.GetHeader("Authorization") != "Bearer "+params.pluginToken { + c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"}) + } + }, + generatorAPI.HandleGetParams, + ) + + return engine.Handler() +} + +func RecoveryMiddleware(logger zerolog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + err := recover() + if err == nil { + return + } + logger.Err(fmt.Errorf("%v", err)).Msg("recovered from panic") + + if c.Writer.Written() { + return + } + + c.JSON(500, gin.H{"error": err}) + }() + // Important: c.Next() is needed so that defer statement doesn't execute immediately + // but only after middleware chain is complete or has panicked. + // Great catch by Mr Silphid + c.Next() + } +} + +func ObservabilityMiddleware(logger zerolog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + recorder := ErrorRecorder{ + ResponseWriter: c.Writer, + buffer: bytes.Buffer{}, + } + + c.Writer = &recorder + + c.Next() + + event := func() *zerolog.Event { + if err := recorder.buffer.String(); err != "" { + return logger.Err(errors.New(err)) + } + return logger.Info() + }() + + event. + Str("method", c.Request.Method). + Str("path", c.Request.URL.Path). + Int("code", c.Writer.Status()). + Str("elapsed", time.Since(start).String()). + Msg("served request") + } +} + +type ErrorRecorder struct { + gin.ResponseWriter + buffer bytes.Buffer +} + +func (recorder *ErrorRecorder) Write(data []byte) (int, error) { + if recorder.Status() >= 400 { + _, _ = recorder.buffer.Write(data) + } + return recorder.ResponseWriter.Write(data) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..407423a --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "reflect" + "syscall" + "time" + + "github.com/davidmdm/x/xcontext" + "github.com/rs/zerolog" + + "github.com/nestoca/joy-generator/internal/generator" + "github.com/nestoca/joy-generator/internal/github" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + if errors.Is(err, context.Canceled) { + return + } + os.Exit(1) + } +} + +func run() error { + logger := zerolog.New(os.Stdout) + + cfg := GetConfig() + + repo, err := func() (*github.Repo, error) { + if !reflect.ValueOf(cfg.Github.App).IsZero() { + return cfg.Github.App.NewRepo(cfg.Catalog) + } + return cfg.Github.User.NewRepo(cfg.Catalog) + }() + if err != nil { + return fmt.Errorf("failed to create repo: %w", err) + } + + logger.Info().Str("catalog_path", repo.Metadata.Path).Msg("initialized repo") + + repo = repo.WithLogger(logger) + + server := &http.Server{ + Addr: cfg.Port, + Handler: Handler(HandlerParams{ + pluginToken: cfg.PluginToken, + logger: logger, + repo: repo, + generator: &generator.Generator{ + Logger: logger, + LoadJoyContext: generator.RepoLoader(repo), + }, + }), + ReadHeaderTimeout: 5 * time.Second, + } + + errChan := make(chan error, 1) + + go func() { + logger.Info().Str("address", server.Addr).Msg("starting server") + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errChan <- err + } + }() + + ctx, stop := xcontext.WithSignalCancelation(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + select { + case err := <-errChan: + return fmt.Errorf("failed to listen and serve: %w", err) + case <-ctx.Done(): + } + + shutdownContext, cancel := context.WithTimeout(context.Background(), cfg.GracePeriod) + defer cancel() + + if err := server.Shutdown(shutdownContext); err != nil { + return fmt.Errorf("failed to shutdown server gracefully: %w", err) + } + + return ctx.Err() +} diff --git a/go.mod b/go.mod index 4fcbeda..ed843af 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,11 @@ go 1.22.0 require ( github.com/bradleyfalzon/ghinstallation/v2 v2.9.0 - github.com/gin-contrib/logger v1.0.0 + github.com/davidmdm/conf v0.0.4 + github.com/davidmdm/x/xcontext v0.0.2 github.com/gin-gonic/gin v1.9.1 github.com/go-git/go-git/v5 v5.11.0 - github.com/kelseyhightower/envconfig v1.4.0 - github.com/nestoca/joy v0.39.0 + github.com/nestoca/joy v0.39.1 github.com/rs/zerolog v1.32.0 github.com/stretchr/testify v1.9.0 gopkg.in/yaml.v3 v3.0.1 @@ -20,7 +20,7 @@ require ( github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/TwiN/go-color v1.4.1 // indirect - github.com/bytedance/sonic v1.11.0 // indirect + github.com/bytedance/sonic v1.11.3 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.1 // indirect github.com/cloudflare/circl v1.3.7 // indirect @@ -35,7 +35,7 @@ require ( github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.18.0 // indirect + github.com/go-playground/validator/v10 v10.19.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -46,7 +46,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -55,11 +55,11 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nestoca/survey/v2 v2.0.0 // indirect - github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect - github.com/skeema/knownhosts v1.2.1 // indirect + github.com/skeema/knownhosts v1.2.2 // indirect github.com/spf13/cobra v1.8.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -72,8 +72,8 @@ require ( golang.org/x/sys v0.18.0 // indirect golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.18.0 // indirect - google.golang.org/protobuf v1.32.0 // indirect + golang.org/x/tools v0.19.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/godo.v2 v2.0.9 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 7c6b438..668392f 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/bradleyfalzon/ghinstallation/v2 v2.9.0/go.mod h1:wmkTDJf8CmVypxE8ijIS github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= -github.com/bytedance/sonic v1.11.0 h1:FwNNv6Vu4z2Onf1++LNzxB/QhitD8wuTdpZzMTGITWo= -github.com/bytedance/sonic v1.11.0/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/bytedance/sonic v1.11.3 h1:jRN+yEjakWh8aK5FzrciUHG8OFXK+4/KrAX/ysEtHAA= +github.com/bytedance/sonic v1.11.3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= @@ -41,6 +41,10 @@ github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxG 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/davidmdm/conf v0.0.4 h1:RluYzOw+d/H112lY2OCHWPIQemtOGhidLk0wi3QWqms= +github.com/davidmdm/conf v0.0.4/go.mod h1:xcNNsALh9qR2toeuYnXfyMuOPMVuEAfWN0Nb4lqugzw= +github.com/davidmdm/x/xcontext v0.0.2 h1:8US+zdRCPmnWeSa/28U+dvbhdkBd8mI6XhP7meIZCdk= +github.com/davidmdm/x/xcontext v0.0.2/go.mod h1:lfAacRD741WjdRwwuTrCaKtvEgPsGMSlmfwgib2SpVA= github.com/davidmdm/x/xerr v0.0.3 h1:WwHvo6qzR+eRmHq69Ftgb7PL9832iwy313XpQyJRGtM= github.com/davidmdm/x/xerr v0.0.3/go.mod h1:nEfdhUc3O/FmGUGLiJp2hHRhBeANkon7PJexIQ1DlAE= github.com/davidmdm/x/xfs v0.0.4 h1:7g0ue0Gfs5mrIaV8QGA03hgIPm3tJvHUVdsr6obQTt4= @@ -51,8 +55,6 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/gin-contrib/logger v1.0.0 h1:JCK5gYG1VVwLhahJaiFekMzjpoBMN480Tyw3lgs0CJ0= -github.com/gin-contrib/logger v1.0.0/go.mod h1:ExhM7p84ZZ6Uz9XEj8zGY1GIYDjSDPD132+MwHT4av0= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= @@ -73,10 +75,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.18.0 h1:BvolUXjp4zuvkZ5YN5t7ebzbhlUtPsPm2S9NAZ5nl9U= -github.com/go-playground/validator/v10 v10.18.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= -github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4= +github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -102,13 +102,11 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= -github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= -github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -137,14 +135,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/nestoca/joy v0.39.0 h1:thJ6MA8mgGlhU33cd7fIu3jXL2pwunyQ2DnfvX+eDfY= -github.com/nestoca/joy v0.39.0/go.mod h1:z6dv2SRpGwSFMcJR4kzzjvqjsGc5FEBkDyM3uvnYDxU= +github.com/nestoca/joy v0.39.1 h1:x/wOc5omtY18yuxotOYYa6OGRUzu6+5rrIXqL8nPWpI= +github.com/nestoca/joy v0.39.1/go.mod h1:z6dv2SRpGwSFMcJR4kzzjvqjsGc5FEBkDyM3uvnYDxU= github.com/nestoca/survey/v2 v2.0.0 h1:orM/TXtBSQJCPiZy9N51hcwH0WzFjQJ7TQKCfMTnGoQ= github.com/nestoca/survey/v2 v2.0.0/go.mod h1:QfmcQfCRtqsiBpePFhHEW0MY9QH7lSpw3CQinsdC/dc= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= -github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= -github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= +github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -160,8 +158,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD 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/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= -github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= +github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -169,6 +167,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -255,12 +254,12 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= -golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go deleted file mode 100644 index 4370b7f..0000000 --- a/internal/apiserver/apiserver.go +++ /dev/null @@ -1,117 +0,0 @@ -package apiserver - -import ( - "net/http" - - "github.com/gin-contrib/logger" - "github.com/gin-gonic/gin" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - - "github.com/nestoca/joy-generator/internal/generator" - "github.com/nestoca/joy-generator/internal/gitrepo" -) - -type GeneratorOutput struct { - Parameters []generator.Result `json:"parameters"` -} - -type GetParamsResponse struct { - Output GeneratorOutput `json:"output"` -} - -type GeneratorInput struct { - Parameters map[string]string `json:"parameters"` -} - -type GetParamsRequest struct { - ApplicationSetName string `json:"applicationSetName"` - Input GeneratorInput `json:"input"` -} - -type ApiServer struct { - token string - generator *generator.Generator - repo *gitrepo.GitRepo -} - -func New(token string, g *generator.Generator, repo *gitrepo.GitRepo) *ApiServer { - return &ApiServer{ - token: token, - generator: g, - repo: repo, - } -} - -func (s *ApiServer) Run() error { - router := gin.New() - - router.Use(logger.SetLogger(logger.WithLogger(func(_ *gin.Context, l zerolog.Logger) zerolog.Logger { - return l.Output(gin.DefaultWriter).With().Logger() - }))) - - router.GET("/api/v1/health", s.Health) - router.GET("/api/v1/readiness", s.Readiness) - //goland:noinspection SpellCheckingInspection - router.POST("/api/v1/getparams.execute", s.GetParamsExecute) - - return router.Run() -} - -func (s *ApiServer) Health(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -func (s *ApiServer) Readiness(c *gin.Context) { - if err := s.repo.Status(); err != nil { - log.Error().Err(err).Msg("readiness check failed") - c.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "detail": err.Error(), - }) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -func (s *ApiServer) GetParamsExecute(c *gin.Context) { - if c.GetHeader("Authorization") != "Bearer "+s.token { - log.Debug().Msg("invalid token received") - c.JSON(http.StatusUnauthorized, gin.H{ - "code": http.StatusUnauthorized, - "error": "invalid token", - "detail": "invalid token received", - }) - return - } - - body := &GetParamsRequest{} - err := c.BindJSON(body) - if err != nil { - log.Debug().Err(err).Msg("invalid request body received") - c.JSON(http.StatusBadRequest, gin.H{ - "code": http.StatusBadRequest, - "error": "invalid request body", - "detail": err.Error(), - }) - return - } - - results, err := s.generator.Run() - if err != nil { - log.Error().Err(err).Msg("failed to generate results") - c.JSON(http.StatusInternalServerError, gin.H{ - "code": http.StatusInternalServerError, - "error": "failed to generate results", - "detail": err.Error(), - }) - return - } - - c.JSON(http.StatusOK, GetParamsResponse{ - Output: GeneratorOutput{ - Parameters: results, - }, - }) -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 5664fca..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,55 +0,0 @@ -package config - -import ( - "fmt" - "os" - - "github.com/kelseyhightower/envconfig" - "github.com/rs/zerolog/log" -) - -type Config struct { - PluginToken string `required:"true" split_words:"true"` - RepoUrl string `required:"true" split_words:"true"` - CatalogDir string `split_words:"true"` - GithubToken string `split_words:"true"` - GithubUser string `split_words:"true"` -} - -type GitHubAppConfig struct { - Id int64 `required:"true" split_words:"true"` - InstallationId int64 `required:"true" split_words:"true"` - PrivateKeyPath string `required:"true" split_words:"true"` -} - -func Load() (*Config, *GitHubAppConfig, error) { - newConfig := &Config{} - err := envconfig.Process("joy", newConfig) - if err != nil { - return nil, nil, fmt.Errorf("reading config: %w", err) - } - - ghaConfig := &GitHubAppConfig{} - // If the GithubToken is not set, the GitHub App configuration is required - if newConfig.GithubToken == "" && newConfig.GithubUser == "" { - err := envconfig.Process("joy_github_app", ghaConfig) - if err != nil { - return nil, nil, fmt.Errorf("reading github app config: %w", err) - } - } else { - ghaConfig = nil - } - - // If the catalog directory is not set, create a temporary directory - if newConfig.CatalogDir == "" { - newConfig.CatalogDir, err = os.MkdirTemp("", "joy-catalog") - if err != nil { - panic(err) - } - log.Debug().Msgf("JOY_CATALOG_DIR not set, using %s", newConfig.CatalogDir) - } else { - log.Debug().Msgf("JOY_CATALOG_DIR set to %s", newConfig.CatalogDir) - } - - return newConfig, ghaConfig, nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index ed29326..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package config_test - -import ( - "errors" - "os" - "reflect" - "testing" - - "github.com/nestoca/joy-generator/internal/config" -) - -func TestLoad(t *testing.T) { - type TestData struct { - Env map[string]string - ExpectedConfig *config.Config - ExpectedGHAppConfig *config.GitHubAppConfig - ExpectedError error - } - - testData := map[string]TestData{ - "with github app": { - Env: map[string]string{ - "JOY_PLUGIN_TOKEN": "abcdefg", - "JOY_REPO_URL": "https://github.com/org/repo.git", - "JOY_CATALOG_DIR": "/tmp/joy-catalog", - "JOY_GITHUB_APP_ID": "123456", - "JOY_GITHUB_APP_INSTALLATION_ID": "654321", - "JOY_GITHUB_APP_PRIVATE_KEY_PATH": "/tmp/private-key.pem", - }, - ExpectedError: nil, - ExpectedConfig: &config.Config{ - PluginToken: "abcdefg", - RepoUrl: "https://github.com/org/repo.git", - CatalogDir: "/tmp/joy-catalog", - }, - ExpectedGHAppConfig: &config.GitHubAppConfig{ - Id: 123456, - InstallationId: 654321, - PrivateKeyPath: "/tmp/private-key.pem", - }, - }, - "with github token": { - Env: map[string]string{ - "JOY_PLUGIN_TOKEN": "abcdefg", - "JOY_REPO_URL": "https://github.com/org/repo.git", - "JOY_CATALOG_DIR": "/tmp/joy-catalog", - "JOY_GITHUB_TOKEN": "123456", - }, - ExpectedError: nil, - ExpectedConfig: &config.Config{ - PluginToken: "abcdefg", - RepoUrl: "https://github.com/org/repo.git", - CatalogDir: "/tmp/joy-catalog", - GithubToken: "123456", - }, - ExpectedGHAppConfig: nil, - }, - } - - for name, data := range testData { - t.Run(name, func(t *testing.T) { - for key, value := range data.Env { - err := os.Setenv(key, value) - if err != nil { - t.Fatalf("setting environment variable %s: %s", key, err) - } - } - conf, ghappConfig, err := config.Load() - if !errors.Is(err, data.ExpectedError) { - t.Fatalf("expected error %s, got %s", data.ExpectedError, err) - } - - if !reflect.DeepEqual(conf, data.ExpectedConfig) { - t.Fatalf("expected config %v, got %v", data.ExpectedConfig, conf) - } - - if !reflect.DeepEqual(ghappConfig, data.ExpectedGHAppConfig) { - t.Fatalf("expected github app config %v, got %v", data.ExpectedGHAppConfig, ghappConfig) - } - - os.Clearenv() - }) - } -} diff --git a/internal/generator/api.go b/internal/generator/api.go new file mode 100644 index 0000000..5587834 --- /dev/null +++ b/internal/generator/api.go @@ -0,0 +1,50 @@ +package generator + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" +) + +type Output struct { + Parameters []Result `json:"parameters"` +} + +type GetParamsResponse struct { + Output Output `json:"output"` +} + +type GetParamsRequest struct { + ApplicationSetName string `json:"applicationSetName"` + Input struct { + Parameters map[string]string `json:"parameters"` + } `json:"input"` +} + +type API struct { + Logger zerolog.Logger + Generator *Generator +} + +func (api API) HandleGetParams(c *gin.Context) { + var request GetParamsRequest + if err := c.BindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid request body", + "detail": err.Error(), + }) + return + } + + results, err := api.Generator.Run() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to generate results", + "detail": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, GetParamsResponse{Output: Output{Parameters: results}}) +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go index e06966d..cc0dea2 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -3,17 +3,19 @@ package generator import ( "fmt" - "github.com/rs/zerolog/log" + "github.com/rs/zerolog" "gopkg.in/yaml.v3" - "github.com/nestoca/joy-generator/internal/gitrepo" "github.com/nestoca/joy/api/v1alpha1" joy "github.com/nestoca/joy/pkg" "github.com/nestoca/joy/pkg/catalog" + + "github.com/nestoca/joy-generator/internal/github" ) type Generator struct { - loadJoyContext JoyLoaderFunc + LoadJoyContext JoyLoaderFunc + Logger zerolog.Logger } type Result struct { @@ -37,7 +39,7 @@ type JoyContext struct { type JoyLoaderFunc func() (*JoyContext, error) -func RepoLoader(repo *gitrepo.GitRepo) JoyLoaderFunc { +func RepoLoader(repo *github.Repo) JoyLoaderFunc { return func() (*JoyContext, error) { if err := repo.Pull(); err != nil { return nil, fmt.Errorf("pulling git repo: %w", err) @@ -57,14 +59,10 @@ func RepoLoader(repo *gitrepo.GitRepo) JoyLoaderFunc { } } -func New(load JoyLoaderFunc) *Generator { - return &Generator{load} -} - // Run runs the generator and returns a slice of results. Each result contains the release, the environment where it // will be deployed and the rendered values string. -func (r *Generator) Run() ([]Result, error) { - joyctx, err := r.loadJoyContext() +func (generator *Generator) Run() ([]Result, error) { + joyctx, err := generator.LoadJoyContext() if err != nil { return nil, fmt.Errorf("loading joy context: %w", err) } @@ -72,54 +70,56 @@ func (r *Generator) Run() ([]Result, error) { var reconciledReleases []Result for _, crossRelease := range joyctx.Catalog.Releases.Items { for _, release := range crossRelease.Releases { - if release != nil { - log.Debug().Str("release", release.Name).Str("environment", release.Environment.Name).Msg("processing release") - - chart, err := joy.ChartFromRelease(release, joyctx.Config.Charts, joyctx.Config.DefaultChartRef) - if err != nil { - log. - Error(). - Err(err). - Str("release", release.Name).Str("environment", release.Environment.Name). - Msgf("error getting chart for release %s", release.Name) - continue - } - - release.Spec.Chart.RepoUrl = chart.RepoURL - release.Spec.Chart.Name = chart.Name - release.Spec.Chart.Version = chart.Version - - values, err := joy.ReleaseValues(release, joyctx.Config.ValueMapping) - if err != nil { - log. - Error(). - Err(err). - Str("release", release.Name).Str("environment", release.Environment.Name). - Msgf("error computing values for release %s", release.Name) - - // we don't want to fail the whole process if rendering one release fails, so we'll just skip this one - continue - } - - renderedValues, err := yaml.Marshal(values) - if err != nil { - log. - Error(). - Err(err). - Str("release", release.Name).Str("environment", release.Environment.Name). - Msgf("error marshaling values for release %s", release.Name) - - // we don't want to fail the whole process if rendering one release fails, so we'll just skip this one - continue - } - - reconciledReleases = append(reconciledReleases, Result{ - Release: release, - Environment: release.Environment, - Project: release.Project, - Values: string(renderedValues), - }) + if release == nil { + continue + } + + generator.Logger. + Debug(). + Str("release", release.Name). + Str("environment", release.Environment.Name). + Msg("processing release") + + chart, err := joy.ChartFromRelease(release, joyctx.Config.Charts, joyctx.Config.DefaultChartRef) + if err != nil { + generator.Logger. + Error(). + Err(err). + Str("release", release.Name).Str("environment", release.Environment.Name). + Msgf("error getting chart for release %s", release.Name) + continue } + + release.Spec.Chart.RepoUrl = chart.RepoURL + release.Spec.Chart.Name = chart.Name + release.Spec.Chart.Version = chart.Version + + values, err := joy.ReleaseValues(release, joyctx.Config.ValueMapping) + if err != nil { + generator.Logger. + Error(). + Err(err). + Str("release", release.Name).Str("environment", release.Environment.Name). + Msgf("error computing values for release %s", release.Name) + continue + } + + renderedValues, err := yaml.Marshal(values) + if err != nil { + generator.Logger. + Error(). + Err(err). + Str("release", release.Name).Str("environment", release.Environment.Name). + Msgf("error marshaling values for release %s", release.Name) + continue + } + + reconciledReleases = append(reconciledReleases, Result{ + Release: release, + Environment: release.Environment, + Project: release.Project, + Values: string(renderedValues), + }) } } diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 61ed631..b516551 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -147,16 +147,18 @@ func TestGenerator(t *testing.T) { for _, tc := range cases { t.Run(tc.Name, func(t *testing.T) { - generator := New(func() (*JoyContext, error) { - return &JoyContext{ - Catalog: BuildCatalogFromRelease(tc.Release), - Config: &joy.Config{ - DefaultChartRef: tc.DefaultChartRef, - Charts: tc.Charts, - ValueMapping: tc.ValueMapping, - }, - }, nil - }) + generator := Generator{ + LoadJoyContext: func() (*JoyContext, error) { + return &JoyContext{ + Catalog: BuildCatalogFromRelease(tc.Release), + Config: &joy.Config{ + DefaultChartRef: tc.DefaultChartRef, + Charts: tc.Charts, + ValueMapping: tc.ValueMapping, + }, + }, nil + }, + } results, err := generator.Run() require.NoError(t, err) diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..fceed9a --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,215 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/rs/zerolog" +) + +type User struct { + Name string + Token string +} + +type RepoMetadata struct { + // Path is the local directory where the catalog repositoryAddress should be cloned. Ex: /tmp/joy-catalog + Path string + + // URL is the HTTPS git address of the catalog repositoryAddress. Ex: https://github.com/my-org/joy-catalog.git + URL string + + // TargetRevision is the revision we wish to check out: Ex: main + TargetRevision string +} + +type Repo struct { + Metadata RepoMetadata + + credentials func() (*githttp.BasicAuth, error) + + repository *git.Repository + + mutex *sync.Mutex + + logger zerolog.Logger +} + +// WithLogger create a shallow clone of the repo with the new logger set. +func (repo *Repo) WithLogger(logger zerolog.Logger) *Repo { + clone := *repo + clone.logger = logger + return &clone +} + +type App struct { + ID int64 + InstallationID int64 + PrivateKeyPath string +} + +func (app App) NewRepo(metadata RepoMetadata) (*Repo, error) { + transport, err := ghinstallation.NewKeyFromFile(http.DefaultTransport, app.ID, app.InstallationID, app.PrivateKeyPath) + if err != nil { + return nil, fmt.Errorf("creating github installation transport: %w", err) + } + + getCredentials := func() (*githttp.BasicAuth, error) { + token, err := transport.Token(context.TODO()) + if err != nil { + return nil, fmt.Errorf("getting github installation token: %w", err) + } + return &githttp.BasicAuth{ + Username: "x-access-token", + Password: token, + }, nil + } + + repo := &Repo{ + Metadata: metadata, + credentials: getCredentials, + mutex: &sync.Mutex{}, + logger: zerolog.Nop(), + } + + if err := repo.init(); err != nil { + return nil, fmt.Errorf("initializing git repo: %w", err) + } + + return repo, nil +} + +func (user User) NewRepo(metadata RepoMetadata) (*Repo, error) { + r := &Repo{ + Metadata: metadata, + credentials: func() (*githttp.BasicAuth, error) { + return &githttp.BasicAuth{ + Username: user.Name, + Password: user.Token, + }, nil + }, + mutex: &sync.Mutex{}, + logger: zerolog.Nop(), + } + + if err := r.init(); err != nil { + return nil, fmt.Errorf("initializing git repo: %w", err) + } + + return r, nil +} + +func (r *Repo) init() error { + r.mutex.Lock() + defer r.mutex.Unlock() + + r.logger.Debug().Msg("opening git repository") + + repository, err := git.PlainOpen(r.Metadata.Path) + if err != nil { + if !errors.Is(err, git.ErrRepositoryNotExists) { + return fmt.Errorf("opening git repository: %w", err) + } + auth, err := r.credentials() + if err != nil { + return fmt.Errorf("getting git credentials: %w", err) + } + repository, err = git.PlainClone(r.Metadata.Path, false, &git.CloneOptions{ + URL: r.Metadata.URL, + Auth: auth, + }) + if err != nil { + return fmt.Errorf("cloning git repository: %w", err) + } + } + + if revision := r.Metadata.TargetRevision; revision != "" { + hash, err := repository.ResolveRevision(plumbing.Revision("refs/remotes/origin/" + revision)) + if err != nil { + return fmt.Errorf("resolving revision %s: %w", revision, err) + } + + worktree, err := repository.Worktree() + if err != nil { + return fmt.Errorf("getting worktree: %w", err) + } + + checkoutOpts := &git.CheckoutOptions{ + Hash: *hash, + } + if err := worktree.Checkout(checkoutOpts); err != nil { + return fmt.Errorf("checking out: %s: %w", revision, err) + } + } + + r.repository = repository + return nil +} + +func (r *Repo) Directory() string { + return r.Metadata.Path +} + +func (r *Repo) Pull() error { + auth, err := r.credentials() + if err != nil { + return fmt.Errorf("getting git authentication credentials: %w", err) + } + + r.mutex.Lock() + defer r.mutex.Unlock() + + r.logger.Debug().Msg("load git worktree") + + worktree, err := r.repository.Worktree() + if err != nil { + return fmt.Errorf("loading git worktree: %w", err) + } + + r.logger.Debug().Msg("pull git repo") + + pullOpts := &git.PullOptions{ + Auth: auth, + Force: true, + } + + if r.Metadata.TargetRevision != "" { + pullOpts.ReferenceName = plumbing.ReferenceName("refs/heads/" + r.Metadata.TargetRevision) + } + + if err := worktree.Pull(pullOpts); err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) { + return nil + } else if !errors.Is(err, git.ErrNonFastForwardUpdate) { + return err + } + + // If non-fast-forward update, manually reset the branch to the remote HEAD + localHead, err := r.repository.Head() + if err != nil { + return fmt.Errorf("getting local HEAD: %w", err) + } + + remoteRefName := fmt.Sprintf("refs/remotes/origin/%s", strings.Split(localHead.Name().String(), "/")[2]) + + remoteHeadHash, err := r.repository.ResolveRevision(plumbing.Revision(remoteRefName)) + if err != nil { + return fmt.Errorf("resolving remote HEAD: %w", err) + } + + if localHead.Hash() != *remoteHeadHash { + r.logger.Debug().Msg("resetting to previous commit") + if err := worktree.Reset(&git.ResetOptions{Commit: *remoteHeadHash, Mode: git.HardReset}); err != nil { + return fmt.Errorf("resetting to previous commit: %w", err) + } + } + + return nil +} diff --git a/internal/gitrepo/gitrepo.go b/internal/gitrepo/gitrepo.go deleted file mode 100644 index abf3e21..0000000 --- a/internal/gitrepo/gitrepo.go +++ /dev/null @@ -1,207 +0,0 @@ -package gitrepo - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - "sync" - - "github.com/bradleyfalzon/ghinstallation/v2" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - githttp "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/rs/zerolog/log" -) - -type GitRepo struct { - // dir is the local directory where the catalog repositoryAddress should be cloned. Ex: /tmp/joy-catalog - dir string - - // repositoryAddress is the HTTPS git address of the catalog repositoryAddress. Ex: https://github.com/my-org/joy-catalog.git - url string - - // ghInstallTransport is the GitHub App authentication transport. It's used to generate a token that can be used to - // authenticate git calls to the catalog repositoryAddress. - ghAppInstallation *ghinstallation.Transport - - // githubToken is the GitHub Token used to authenticate API calls to the catalog repositoryAddress. When set, ghInstallTransport is - // not used - githubToken string - - // githubUser is the GitHub user used to authenticate API calls to the catalog repositoryAddress. Defaults to "x-access-token" - githubUser string - - repository *git.Repository - - mutex *sync.Mutex -} - -// NewWithGithubApp creates a new GitRepo instance using GitHub App authentication -func NewWithGithubApp(url string, dir string, githubAppId int64, githubInstallationId int64, privateKeyPath string) (*GitRepo, error) { - t, err := ghinstallation.NewKeyFromFile(http.DefaultTransport, githubAppId, githubInstallationId, privateKeyPath) - if err != nil { - return nil, fmt.Errorf("creating github installation transport: %w", err) - } - - r := &GitRepo{ - dir: dir, - url: url, - ghAppInstallation: t, - mutex: &sync.Mutex{}, - } - - if err := r.init(); err != nil { - return nil, fmt.Errorf("initializing git repo: %w", err) - } - - return r, nil -} - -// NewWithGithubToken creates a new GitRepo instance using GitHub Token authentication -func NewWithGithubToken(url string, dir string, githubToken string, githubUser string) (*GitRepo, error) { - r := &GitRepo{ - dir: dir, - url: url, - githubToken: githubToken, - githubUser: githubUser, - mutex: &sync.Mutex{}, - } - - if err := r.init(); err != nil { - return nil, fmt.Errorf("initializing git repo: %w", err) - } - - return r, nil -} - -func (r *GitRepo) init() error { - auth, err := r.getCredentials() - if err != nil { - return fmt.Errorf("getting git credentials: %w", err) - } - - r.mutex.Lock() - defer r.mutex.Unlock() - log.Debug().Msg("opening git repository") - repository, err := git.PlainOpen(r.dir) - if err != nil { - if errors.Is(err, git.ErrRepositoryNotExists) { - repository, err = git.PlainClone(r.dir, false, &git.CloneOptions{ - URL: r.url, - Auth: auth, - }) - if err != nil { - return fmt.Errorf("cloning git repository: %w", err) - } - } else { - return fmt.Errorf("opening git repository: %w", err) - } - } - - r.repository = repository - return nil -} - -func (r *GitRepo) Directory() string { - return r.dir -} - -// getCredentials returns an implementation githttp.AuthMethod that can be used to authenticate git calls -// to the catalog repositoryAddress -func (r *GitRepo) getCredentials() (*githttp.BasicAuth, error) { - var token string - var err error - if r.githubToken != "" { - token = r.githubToken - } else if r.ghAppInstallation != nil { - // The call to .Token will automatically renew the token if it's expired - token, err = r.ghAppInstallation.Token(context.TODO()) - if err != nil { - return nil, fmt.Errorf("getting github installation token: %w", err) - } - } else { - return nil, fmt.Errorf("no github authentication method provided. Either githubToken or ghAppInstallation must be set") - } - - user := "x-access-token" - if r.githubUser != "" { - user = r.githubUser - } - - return &githttp.BasicAuth{ - Username: user, - Password: token, - }, nil -} - -func (r *GitRepo) Pull() error { - auth, err := r.getCredentials() - if err != nil { - return fmt.Errorf("getting git authentication credentials: %w", err) - } - - r.mutex.Lock() - defer r.mutex.Unlock() - - log.Debug().Msg("load git worktree") - w, err := r.repository.Worktree() - if err != nil { - return fmt.Errorf("loading git worktree: %w", err) - } - - log.Debug().Msg("pull git repo") - err = w.Pull(&git.PullOptions{ - Auth: auth, - Force: true, - }) - // If non-fast-forward update, manually reset the branch to the remote HEAD - if errors.Is(err, git.NoErrAlreadyUpToDate) || errors.Is(err, git.ErrNonFastForwardUpdate) { - localHead, err := r.repository.Head() - if err != nil { - return fmt.Errorf("getting local HEAD: %w", err) - } - - remoteRefName := fmt.Sprintf("refs/remotes/origin/%s", strings.Split(localHead.Name().String(), "/")[2]) - - remoteHeadHash, err := r.repository.ResolveRevision(plumbing.Revision(remoteRefName)) - if err != nil { - return fmt.Errorf("resolving remote HEAD: %w", err) - } - - if localHead.Hash() != *remoteHeadHash { - log.Debug().Msg("resetting to previous commit") - err = w.Reset(&git.ResetOptions{ - Commit: *remoteHeadHash, - Mode: git.HardReset, - }) - if err != nil { - return fmt.Errorf("resetting to previous commit: %w", err) - } - } - } else if err != nil { - return fmt.Errorf("pulling git repo: %w", err) - } - - return nil -} - -// Status does a git fetch to ensure that the connection to the git repo is still intact. Used for pod status checks. -func (r *GitRepo) Status() error { - auth, err := r.getCredentials() - if err != nil { - return fmt.Errorf("getting git authentication credentials: %w", err) - } - - r.mutex.Lock() - defer r.mutex.Unlock() - err = r.repository.Fetch(&git.FetchOptions{ - Auth: auth, - }) - if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { - return fmt.Errorf("fetching repo: %w", err) - } - - return nil -} diff --git a/main.go b/main.go deleted file mode 100644 index 5b1a03d..0000000 --- a/main.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "flag" - "os" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - - "github.com/nestoca/joy-generator/internal/apiserver" - "github.com/nestoca/joy-generator/internal/config" - "github.com/nestoca/joy-generator/internal/generator" - "github.com/nestoca/joy-generator/internal/gitrepo" -) - -func main() { - debug := flag.Bool("debug", false, "sets log level to debug") - - flag.Parse() - - // Default level for this example is info, unless debug flag is present - zerolog.SetGlobalLevel(zerolog.InfoLevel) - if *debug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - } - - cfg, ghAppCfg, err := config.Load() - if err != nil { - log.Error().Err(err).Msg("failed to load config") - os.Exit(1) - } - - var repo *gitrepo.GitRepo - if ghAppCfg != nil { - repo, err = gitrepo.NewWithGithubApp( - cfg.RepoUrl, - cfg.CatalogDir, - ghAppCfg.Id, - ghAppCfg.InstallationId, - ghAppCfg.PrivateKeyPath, - ) - } else { - repo, err = gitrepo.NewWithGithubToken( - cfg.RepoUrl, - cfg.CatalogDir, - cfg.GithubToken, - cfg.GithubUser, - ) - } - if err != nil { - log.Error().Err(err).Msg("failed to initialize git repo") - os.Exit(1) - } - - gen := generator.New(generator.RepoLoader(repo)) - - server := apiserver.New(cfg.PluginToken, gen, repo) - - if err := server.Run(); err != nil { - log.Error().Err(err).Msg("failed to start server") - os.Exit(1) - } -}