├── front
├── src
│ ├── hooks
│ │ ├── useDelRegistry.ts
│ │ ├── useLiveLogs.ts
│ │ ├── useBuildLogs.ts
│ │ ├── useReposByOwner.ts
│ │ ├── useEnvironmentsByRepo.ts
│ │ ├── useOwner.ts
│ │ ├── useRegistries.ts
│ │ ├── useAddRegistry.ts
│ │ ├── usePublicEnvironment.ts
│ │ ├── useOwners.ts
│ │ ├── useRepo.ts
│ │ ├── useAuthError.tsx
│ │ ├── useBool.ts
│ │ ├── useProfile.ts
│ │ ├── useVariables.ts
│ │ ├── usePermanentBranches.tsx
│ │ ├── useEnvironment.ts
│ │ ├── useLogs.ts
│ │ └── useTheme.tsx
│ ├── react-app-env.d.ts
│ ├── css.ts
│ ├── setupTests.ts
│ ├── App.test.tsx
│ ├── components
│ │ ├── GitHubLogo.tsx
│ │ ├── HidableSpan.tsx
│ │ ├── StripePricingTable.tsx
│ │ ├── Loading.tsx
│ │ ├── Logo.tsx
│ │ ├── AuthAlert.tsx
│ │ ├── BillingAlert.tsx
│ │ ├── ButtonV2.tsx
│ │ ├── RepositoryList.tsx
│ │ ├── Input.tsx
│ │ ├── Button.tsx
│ │ ├── RequireAuth.tsx
│ │ ├── EmptyState.tsx
│ │ ├── WebsitePath.tsx
│ │ ├── PermanentBranchesInput.tsx
│ │ ├── EnvLink.tsx
│ │ ├── List.tsx
│ │ └── Select.tsx
│ ├── pages
│ │ ├── Error.tsx
│ │ ├── Loading.tsx
│ │ ├── Purchase.tsx
│ │ ├── Login.tsx
│ │ ├── NoInstallation.tsx
│ │ └── Projects.tsx
│ ├── reportWebVitals.ts
│ ├── index.tsx
│ ├── App.tsx
│ └── index.css
├── public
│ ├── robots.txt
│ ├── favicon.ico
│ ├── logo192.png
│ ├── logo384.png
│ ├── fonts
│ │ ├── Ubuntu-Bold.ttf
│ │ ├── Ubuntu-Light.ttf
│ │ ├── Ubuntu-Italic.ttf
│ │ ├── Ubuntu-Medium.ttf
│ │ ├── Ubuntu-Regular.ttf
│ │ ├── Ubuntu-BoldItalic.ttf
│ │ ├── inter-var-latin.woff2
│ │ ├── Ubuntu-LightItalic.ttf
│ │ └── Ubuntu-MediumItalic.ttf
│ ├── manifest.json
│ ├── github-mark-white.svg
│ └── index.html
├── .prettierrc
├── .gitignore
├── .dockerignore
├── tsconfig.json
├── Dockerfile
├── tailwind.config.js
├── package.json
└── README.md
├── migrations
├── 005_add_ghcommentid_to_env.sql
├── 014_add_degraded_reason_to_environments.sql
├── 017_add_build_tool_to_environent.sql
├── 013_add_branch_owner_to_env.sql
├── 018_add_branch_to_env_vars.sql
├── 016_add_status_and_ports_to_services.sql
├── 006_create_env_vars_table.sql
├── 008_create_environment_limits_table.sql
├── 012_create_stripe_subs_table.sql
├── 002_create_registries_table.sql
├── 015_create_deployed_branches_table.sql
├── 003_create_marketplace_events_table.sql
├── 004_create_services_table.sql
├── 009_create_users_table.sql
├── 001_create_environments_table.sql
├── 010_introduce_stale_environments_table.sql
├── 007_create_private_registries_table.sql
└── 011_add_timezone_to_timestamps.sql
├── docker-compose.yml
├── internal
├── transformer
│ ├── transformer.go
│ ├── builder_test.go
│ └── transformer_test.go
├── api
│ ├── github
│ │ ├── launch.go
│ │ ├── terminate.go
│ │ ├── webhook.go
│ │ ├── marketplace.go
│ │ ├── routes.go
│ │ ├── push.go
│ │ ├── pullrequest.go
│ │ └── user.go
│ ├── auth
│ │ ├── logout.go
│ │ ├── login.go
│ │ ├── github.go
│ │ ├── middleware.go
│ │ ├── routes.go
│ │ ├── profile.go
│ │ └── callback.go
│ ├── stripe
│ │ ├── routes.go
│ │ └── webhook.go
│ ├── variables
│ │ ├── routes.go
│ │ ├── list.go
│ │ └── upsert.go
│ ├── registries
│ │ ├── routes.go
│ │ ├── list.go
│ │ ├── del.go
│ │ └── create.go
│ ├── environments
│ │ ├── routes.go
│ │ ├── getpublic.go
│ │ └── list.go
│ └── permanentbranches
│ │ ├── routes.go
│ │ └── list.go
├── dockerutils
│ ├── dockerutils.go
│ └── dockerutils_test.go
├── users
│ ├── users.go
│ └── dbservice.go
├── ergopack
│ └── ergopack.go
├── oauthutils
│ └── oauthutils.go
├── env
│ └── env.go
├── permanentbranches
│ └── permanentbranches.go
├── git
│ └── git.go
├── privregistry
│ ├── privregistry.go
│ └── ecr.go
├── database
│ ├── marketplace.go
│ ├── database.go
│ ├── service.go
│ └── migrations.go
├── ginutils
│ └── ginutils.go
├── payment
│ └── payment.go
├── environments
│ ├── environments.go
│ └── dbprovider_test.go
├── crypto
│ └── crypto.go
├── elastic
│ └── elastic.go
├── logger
│ └── logger.go
├── servicelogs
│ └── query.go
├── cluster
│ ├── deploy.go
│ └── client.go
└── github
│ └── ghlauncher
│ └── comments.go
├── e2e
├── v2
│ ├── github
│ │ └── utils.go
│ └── health_test.go
└── testutils
│ ├── database.go
│ └── todoapp.go
├── cmd
├── migrator
│ └── main.go
└── cli
│ ├── utils
│ └── main.go
│ ├── privregistries
│ └── main.go
│ └── envvars
│ └── main.go
├── .github
└── workflows
│ ├── lint-and-format.yml
│ └── test.yml
├── Dockerfile
├── Makefile
├── scripts
└── reset-minikube.sh
├── .dockerignore
├── .gitignore
└── mocks
├── cluster
└── Starter.go
├── users
└── Service.go
├── elastic
└── ElasticSearch.go
├── github
└── ghlauncher
│ └── GHLauncher.go
└── servicelogs
└── LogStreamer.go
/front/src/hooks/useDelRegistry.ts:
--------------------------------------------------------------------------------
1 | export const a = 2
2 |
--------------------------------------------------------------------------------
/front/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/front/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/front/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/favicon.ico
--------------------------------------------------------------------------------
/front/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/logo192.png
--------------------------------------------------------------------------------
/front/public/logo384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/logo384.png
--------------------------------------------------------------------------------
/front/public/fonts/Ubuntu-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/fonts/Ubuntu-Bold.ttf
--------------------------------------------------------------------------------
/front/public/fonts/Ubuntu-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/fonts/Ubuntu-Light.ttf
--------------------------------------------------------------------------------
/front/src/css.ts:
--------------------------------------------------------------------------------
1 | export function classNames(...names: string[]): string {
2 | return names.filter(Boolean).join(' ')
3 | }
4 |
--------------------------------------------------------------------------------
/front/public/fonts/Ubuntu-Italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/fonts/Ubuntu-Italic.ttf
--------------------------------------------------------------------------------
/front/public/fonts/Ubuntu-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/fonts/Ubuntu-Medium.ttf
--------------------------------------------------------------------------------
/front/public/fonts/Ubuntu-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/fonts/Ubuntu-Regular.ttf
--------------------------------------------------------------------------------
/front/public/fonts/Ubuntu-BoldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/fonts/Ubuntu-BoldItalic.ttf
--------------------------------------------------------------------------------
/front/public/fonts/inter-var-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/fonts/inter-var-latin.woff2
--------------------------------------------------------------------------------
/front/public/fonts/Ubuntu-LightItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/fonts/Ubuntu-LightItalic.ttf
--------------------------------------------------------------------------------
/front/public/fonts/Ubuntu-MediumItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/briefercloud/ergomake/HEAD/front/public/fonts/Ubuntu-MediumItalic.ttf
--------------------------------------------------------------------------------
/migrations/005_add_ghcommentid_to_env.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | ALTER TABLE environments ADD COLUMN gh_comment_id BIGINT NOT NULL DEFAULT 0;
3 |
4 | -- +migrate Down
5 | ALTER TABLE environments DROP COLUMN gh_comment_id;
6 |
--------------------------------------------------------------------------------
/migrations/014_add_degraded_reason_to_environments.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 |
3 | ALTER TABLE environments ADD COLUMN degraded_reason JSONB;
4 |
5 | -- +migrate Down
6 |
7 | ALTER TABLE environments DROP COLUMN degraded_reason;
8 |
--------------------------------------------------------------------------------
/front/src/hooks/useLiveLogs.ts:
--------------------------------------------------------------------------------
1 | import useLogs, { LogData } from './useLogs'
2 |
3 | function useLiveLogs(envId: string): [LogData, Error | null, () => void] {
4 | return useLogs(envId, 'live')
5 | }
6 |
7 | export default useLiveLogs
8 |
--------------------------------------------------------------------------------
/front/src/hooks/useBuildLogs.ts:
--------------------------------------------------------------------------------
1 | import useLogs, { LogData } from './useLogs'
2 |
3 | function useBuildLogs(envId: string): [LogData, Error | null, () => void] {
4 | return useLogs(envId, 'build')
5 | }
6 |
7 | export default useBuildLogs
8 |
--------------------------------------------------------------------------------
/migrations/017_add_build_tool_to_environent.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 |
3 | ALTER TABLE environments ADD COLUMN build_tool VARCHAR(255) NOT NULL DEFAULT 'kaniko';
4 |
5 | -- +migrate Down
6 |
7 | ALTER TABLE environments DROP COLUMN build_tool;
8 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.5'
2 |
3 | services:
4 | postgres:
5 | image: 'postgres'
6 | ports:
7 | - '5432:5432'
8 | environment:
9 | - POSTGRES_PASSWORD=ergomake
10 | - POSTGRES_USER=ergomake
11 | - POSTGRES_DB=ergomake
12 |
--------------------------------------------------------------------------------
/front/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom'
6 |
--------------------------------------------------------------------------------
/internal/transformer/transformer.go:
--------------------------------------------------------------------------------
1 | package transformer
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/ergomake/ergomake/internal/cluster"
7 | )
8 |
9 | type Transformer interface {
10 | Transform(ctx context.Context, namespace string) (*cluster.ClusterEnv, *Environment, error)
11 | }
12 |
--------------------------------------------------------------------------------
/front/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "singleQuote": true,
4 | "semi": false,
5 | "tabWidth": 2,
6 | "plugins": ["@trivago/prettier-plugin-sort-imports"],
7 | "importOrder": ["^@preview", "^[./]"],
8 | "importOrderSeparation": true,
9 | "importOrderSortSpecifiers": true
10 | }
11 |
--------------------------------------------------------------------------------
/front/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react'
2 | import React from 'react'
3 |
4 | import App from './App'
5 |
6 | test('renders learn react link', () => {
7 | render()
8 | const linkElement = screen.getByText(/learn react/i)
9 | expect(linkElement).toBeInTheDocument()
10 | })
11 |
--------------------------------------------------------------------------------
/front/src/components/GitHubLogo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const GitHubLogo = () => {
4 | // Return image from public github-mark-white.svg
5 | return (
6 |
7 |

8 |
9 | )
10 | }
11 |
12 | export default GitHubLogo
13 |
--------------------------------------------------------------------------------
/migrations/013_add_branch_owner_to_env.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | ALTER TABLE environments ADD COLUMN branch_owner VARCHAR(255) NOT NULL DEFAULT '';
3 | UPDATE environments SET branch_owner = owner;
4 | ALTER TABLE environments ALTER COLUMN branch_owner DROP DEFAULT;
5 |
6 | -- +migrate Down
7 | ALTER TABLE environments DROP COLUMN branch_owner;
8 |
--------------------------------------------------------------------------------
/internal/api/github/launch.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/ergomake/ergomake/internal/github/ghlauncher"
7 | )
8 |
9 | func (r *githubRouter) launchEnvironment(ctx context.Context, event ghlauncher.LaunchEnvironmentRequest) error {
10 | return r.ghLauncher.LaunchEnvironment(ctx, event)
11 | }
12 |
--------------------------------------------------------------------------------
/front/src/hooks/useReposByOwner.ts:
--------------------------------------------------------------------------------
1 | import { HTTPResponse, useHTTPRequest } from './useHTTPRequest'
2 | import { Repo } from './useRepo'
3 |
4 | export const useReposByOwner = (owner: string): HTTPResponse => {
5 | const url = `${process.env.REACT_APP_ERGOMAKE_API}/v2/github/owner/${owner}/repos`
6 | return useHTTPRequest(url)[0]
7 | }
8 |
--------------------------------------------------------------------------------
/internal/api/github/terminate.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/ergomake/ergomake/internal/environments"
7 | )
8 |
9 | func (r *githubRouter) terminateEnvironment(ctx context.Context, req environments.TerminateEnvironmentRequest) error {
10 | return r.environmentsProvider.TerminateEnvironment(ctx, req)
11 | }
12 |
--------------------------------------------------------------------------------
/internal/dockerutils/dockerutils.go:
--------------------------------------------------------------------------------
1 | package dockerutils
2 |
3 | import (
4 | "github.com/google/go-containerregistry/pkg/name"
5 | )
6 |
7 | func ExtractDockerRegistryURL(imageURL string) (string, error) {
8 | ref, err := name.ParseReference(imageURL)
9 | if err != nil {
10 | return "", err
11 | }
12 |
13 | return ref.Context().RegistryStr(), nil
14 | }
15 |
--------------------------------------------------------------------------------
/migrations/018_add_branch_to_env_vars.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 |
3 | ALTER TABLE env_vars ADD COLUMN branch VARCHAR(255);
4 |
5 | ALTER TABLE env_vars DROP CONSTRAINT env_vars_owner_repo_name_key;
6 | ALTER TABLE env_vars ADD CONSTRAINT env_vars_owner_repo_name_key UNIQUE (owner, repo, name, branch);
7 |
8 | -- +migrate Down
9 |
10 | ALTER TABLE env_vars DROP COLUMN branch;
11 |
--------------------------------------------------------------------------------
/front/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/front/.dockerignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/internal/users/users.go:
--------------------------------------------------------------------------------
1 | package users
2 |
3 | import "context"
4 |
5 | type Provider string
6 |
7 | const (
8 | ProviderGithub Provider = "github"
9 | )
10 |
11 | type User struct {
12 | Email string `json:"email"`
13 | Username string `json:"login"`
14 | Name string `json:"name"`
15 | Provider Provider `json:"provider"`
16 | }
17 |
18 | type Service interface {
19 | Save(ctx context.Context, user User) error
20 | }
21 |
--------------------------------------------------------------------------------
/internal/ergopack/ergopack.go:
--------------------------------------------------------------------------------
1 | package ergopack
2 |
3 | type Ergopack struct {
4 | Apps map[string]ErgopackApp `yaml:"apps"`
5 | }
6 |
7 | type ErgopackApp struct {
8 | Path string `yaml:"path"`
9 | Image string `yaml:"image"`
10 | PublicPort string `yaml:"publicPort"`
11 | InternalPorts []string `yaml:"internalPorts"`
12 | Env map[string]string `yaml:"env"`
13 | }
14 |
--------------------------------------------------------------------------------
/front/src/hooks/useEnvironmentsByRepo.ts:
--------------------------------------------------------------------------------
1 | import { Environment } from './useEnvironment'
2 | import { UseHTTPRequest, useHTTPRequest } from './useHTTPRequest'
3 |
4 | export const useEnvironmentsByRepo = (
5 | owner: string,
6 | repo: string
7 | ): UseHTTPRequest => {
8 | const url = `${process.env.REACT_APP_ERGOMAKE_API}/v2/environments/?owner=${owner}&repo=${repo}`
9 |
10 | return useHTTPRequest(url)
11 | }
12 |
--------------------------------------------------------------------------------
/internal/oauthutils/oauthutils.go:
--------------------------------------------------------------------------------
1 | package oauthutils
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gregjones/httpcache"
7 | "golang.org/x/oauth2"
8 | )
9 |
10 | func CachedHTTPClient(token *oauth2.Token, cache httpcache.Cache) *http.Client {
11 | ts := oauth2.StaticTokenSource(token)
12 | return &http.Client{
13 | Transport: &oauth2.Transport{
14 | Base: httpcache.NewTransport(cache),
15 | Source: ts,
16 | },
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/migrations/016_add_status_and_ports_to_services.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 |
3 | ALTER TABLE services
4 | ADD COLUMN public_port VARCHAR(255),
5 | ADD COLUMN internal_ports text[],
6 | ADD COLUMN build_status VARCHAR(255)
7 | CHECK (build_status IN ('image', 'building', 'build-failed', 'build-success'));
8 |
9 | -- +migrate Down
10 |
11 | ALTER TABLE services
12 | DROP COLUMN public_port,
13 | DROP COLUMN internal_ports,
14 | DROP COLUMN build_status;
15 |
--------------------------------------------------------------------------------
/migrations/006_create_env_vars_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | CREATE TABLE env_vars (
3 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
5 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
6 | owner VARCHAR(255) NOT NULL,
7 | repo VARCHAR(255) NOT NULL,
8 | name TEXT NOT NULL,
9 | value TEXT NOT NULL,
10 | UNIQUE(owner, repo, name)
11 | );
12 |
13 |
14 | -- +migrate Down
15 | DROP TABLE env_vars;
16 |
--------------------------------------------------------------------------------
/migrations/008_create_environment_limits_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | CREATE TABLE environment_limits (
3 | id UUID DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY,
4 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
5 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
6 | deleted_at TIMESTAMP NULL,
7 | owner VARCHAR(255) NOT NULL,
8 | env_limit INT NOT NULL,
9 | UNIQUE(owner)
10 | );
11 |
12 | -- +migrate Down
13 | DROP TABLE IF EXISTS environment_limits;
14 |
--------------------------------------------------------------------------------
/front/src/pages/Error.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Logo from '../components/Logo'
4 |
5 | function ErrorLayout() {
6 | return (
7 |
8 |
9 |
10 |
Oops! Something went wrong.
11 |
12 |
13 | )
14 | }
15 |
16 | export default ErrorLayout
17 |
--------------------------------------------------------------------------------
/front/src/hooks/useOwner.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import { HTTPResponse, map } from './useHTTPRequest'
4 | import { Owner, useOwners } from './useOwners'
5 |
6 | export const useOwner = (login: string): HTTPResponse => {
7 | const res = useOwners()
8 |
9 | return useMemo(
10 | () =>
11 | map(
12 | res,
13 | (owners) => owners.find((owner) => owner.login === login) ?? null
14 | ),
15 | [res, login]
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/front/src/hooks/useRegistries.ts:
--------------------------------------------------------------------------------
1 | import { UseHTTPRequest, useHTTPRequest } from './useHTTPRequest'
2 |
3 | export type RegistryProvider = 'ecr' | 'gcr' | 'hub'
4 | export type Registry = {
5 | id: string
6 | provider: RegistryProvider
7 | url: string
8 | }
9 |
10 | export const useRegistries = (owner: string): UseHTTPRequest => {
11 | const url = `${process.env.REACT_APP_ERGOMAKE_API}/v2/owner/${owner}/registries`
12 | return useHTTPRequest(url)
13 | }
14 |
--------------------------------------------------------------------------------
/internal/env/env.go:
--------------------------------------------------------------------------------
1 | package env
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/joho/godotenv"
7 | "github.com/kelseyhightower/envconfig"
8 | "github.com/pkg/errors"
9 | )
10 |
11 | func LoadEnv(e interface{}) error {
12 | err := godotenv.Load(".env.local")
13 | if err != nil {
14 | if !os.IsNotExist(err) {
15 | return errors.Wrap(err, "fail to load .env.local file")
16 | }
17 | }
18 |
19 | return errors.Wrap(envconfig.Process("", e), "fail to extract env variables")
20 | }
21 |
--------------------------------------------------------------------------------
/front/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals'
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry)
7 | getFID(onPerfEntry)
8 | getFCP(onPerfEntry)
9 | getLCP(onPerfEntry)
10 | getTTFB(onPerfEntry)
11 | })
12 | }
13 | }
14 |
15 | export default reportWebVitals
16 |
--------------------------------------------------------------------------------
/internal/api/auth/logout.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 |
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | func (ar *authRouter) logout(c *gin.Context) {
11 | redirectURL, err := url.Parse(c.Query("redirectUrl"))
12 | if err != nil {
13 | c.String(http.StatusBadRequest, "invalid redirectUrl")
14 | return
15 | }
16 |
17 | c.SetCookie(AuthTokenCookieName, "", -1, "/", "", false, true)
18 |
19 | c.Redirect(http.StatusTemporaryRedirect, redirectURL.String())
20 | }
21 |
--------------------------------------------------------------------------------
/migrations/012_create_stripe_subs_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | CREATE TABLE stripe_subscriptions (
3 | id UUID DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY,
4 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
5 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
6 | deleted_at TIMESTAMP NULL,
7 | owner VARCHAR(255) NOT NULL,
8 | subscription_id VARCHAR(255) NOT NULL,
9 | UNIQUE(owner, subscription_id)
10 | );
11 |
12 | -- +migrate Down
13 | DROP TABLE IF EXISTS stripe_subscriptions;
14 |
--------------------------------------------------------------------------------
/migrations/002_create_registries_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
3 |
4 | CREATE TABLE registries (
5 | id UUID DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY,
6 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
7 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
8 | deleted_at TIMESTAMP NULL,
9 | owner VARCHAR(255) NOT NULL,
10 | url VARCHAR(255) NOT NULL,
11 | token TEXT NOT NULL
12 | );
13 |
14 | -- +migrate Down
15 | DROP TABLE IF EXISTS registries;
16 |
--------------------------------------------------------------------------------
/internal/permanentbranches/permanentbranches.go:
--------------------------------------------------------------------------------
1 | package permanentbranches
2 |
3 | import "context"
4 |
5 | type BatchUpsertResult struct {
6 | Added []string
7 | Removed []string
8 | Result []string
9 | }
10 | type PermanentBranchesProvider interface {
11 | List(ctx context.Context, owner, repo string) ([]string, error)
12 | IsPermanentBranch(ctx context.Context, owner, repo, branch string) (bool, error)
13 | BatchUpsert(ctx context.Context, owner, repo string, branches []string) (BatchUpsertResult, error)
14 | }
15 |
--------------------------------------------------------------------------------
/migrations/015_create_deployed_branches_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | CREATE TABLE deployed_branches (
3 | id UUID DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY,
4 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
5 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
6 | deleted_at TIMESTAMP NULL,
7 | owner VARCHAR(255) NOT NULL,
8 | repo VARCHAR(255) NOT NULL,
9 | branch VARCHAR(255) NOT NULL,
10 | UNIQUE(owner, repo, branch)
11 | );
12 |
13 | -- +migrate Down
14 | DROP TABLE IF EXISTS deployed_branches;
15 |
--------------------------------------------------------------------------------
/front/src/hooks/useAddRegistry.ts:
--------------------------------------------------------------------------------
1 | import { UseHTTPMutation, useHTTPMutation } from './useHTTPRequest'
2 | import { Registry, RegistryProvider } from './useRegistries'
3 |
4 | export type NewRegistry = {
5 | provider: RegistryProvider
6 | url: string
7 | credentials: string
8 | }
9 |
10 | export const useAddRegistry = (
11 | owner: string
12 | ): UseHTTPMutation => {
13 | const url = `${process.env.REACT_APP_ERGOMAKE_API}/v2/owner/${owner}/registries`
14 |
15 | return useHTTPMutation(url)
16 | }
17 |
--------------------------------------------------------------------------------
/migrations/003_create_marketplace_events_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | CREATE TABLE marketplace_events (
3 | id UUID DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY,
4 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
5 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
6 | deleted_at TIMESTAMP NULL,
7 | owner VARCHAR(255) NOT NULL,
8 | action VARCHAR(255) NOT NULL CHECK (action IN ('purchased', 'cancelled', 'pending_change', 'pending_change_cancelled', 'changed'))
9 | );
10 |
11 | -- +migrate Down
12 | DROP TABLE IF EXISTS marketplace_events;
13 |
--------------------------------------------------------------------------------
/e2e/v2/github/utils.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha256"
6 | "encoding/hex"
7 | "encoding/json"
8 | "fmt"
9 | "testing"
10 |
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | type want struct {
15 | status int
16 | }
17 |
18 | func genSignature(t *testing.T, payload interface{}) string {
19 | message, err := json.Marshal(payload)
20 | require.NoError(t, err)
21 |
22 | mac := hmac.New(sha256.New, []byte("secret"))
23 | mac.Write(message)
24 |
25 | return fmt.Sprintf("sha256=%s", hex.EncodeToString(mac.Sum(nil)))
26 | }
27 |
--------------------------------------------------------------------------------
/migrations/004_create_services_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | CREATE TABLE services (
3 | id UUID DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY,
4 | name VARCHAR(255) NOT NULL,
5 | environment_id UUID REFERENCES environments(id) ON DELETE CASCADE,
6 | url TEXT NOT NULL,
7 | image TEXT NOT NULL,
8 | build TEXT NOT NULL,
9 | index INTEGER NOT NULL,
10 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
11 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
12 | deleted_at TIMESTAMP NULL
13 | );
14 |
15 | -- +migrate Down
16 | DROP TABLE IF EXISTS services;
17 |
--------------------------------------------------------------------------------
/internal/api/stripe/routes.go:
--------------------------------------------------------------------------------
1 | package stripe
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 |
6 | "github.com/ergomake/ergomake/internal/payment"
7 | )
8 |
9 | type stripeRouter struct {
10 | paymentProvider payment.PaymentProvider
11 | webhookSecret string
12 | }
13 |
14 | func NewStripeRouter(
15 | paymentProvider payment.PaymentProvider,
16 | webhookSecret string,
17 | ) *stripeRouter {
18 | return &stripeRouter{paymentProvider, webhookSecret}
19 | }
20 |
21 | func (stp *stripeRouter) AddRoutes(router *gin.RouterGroup) {
22 | router.POST("/webhook", stp.webhook)
23 | }
24 |
--------------------------------------------------------------------------------
/internal/git/git.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "context"
5 | )
6 |
7 | type RemoteGitClient interface {
8 | GetCloneToken(ctx context.Context, owner string, repo string) (string, error)
9 | CloneRepo(ctx context.Context, owner string, repo string, branch string, dir string, isPublic bool) error
10 | GetCloneUrl() string
11 | GetCloneParams() []string
12 | GetDefaultBranch(ctx context.Context, owner string, repo string, branchOwner string) (string, error)
13 | DoesBranchExist(ctx context.Context, owner string, repo string, branch string, branchOwner string) (bool, error)
14 | }
15 |
--------------------------------------------------------------------------------
/front/src/hooks/usePublicEnvironment.ts:
--------------------------------------------------------------------------------
1 | import { EnvironmentStatus } from './useEnvironment'
2 | import { UseHTTPRequest, useOptionalHTTPRequest } from './useHTTPRequest'
3 |
4 | export type PublicEnvironment = {
5 | id: string
6 | owner: string
7 | repo: string
8 | status: EnvironmentStatus
9 | areServicesAlive: boolean
10 | }
11 |
12 | export const usePublicEnvironment = (
13 | id: string
14 | ): UseHTTPRequest => {
15 | const url = `${process.env.REACT_APP_ERGOMAKE_API}/v2/environments/${id}/public`
16 |
17 | return useOptionalHTTPRequest(url)
18 | }
19 |
--------------------------------------------------------------------------------
/migrations/009_create_users_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | CREATE TABLE users (
3 | id UUID DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY,
4 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
5 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
6 | deleted_at TIMESTAMP NULL,
7 | email VARCHAR(255) NOT NULL,
8 | username VARCHAR(255) NOT NULL,
9 | name VARCHAR(255) NOT NULL,
10 | provider VARCHAR(255) NOT NULL CHECK (provider in ('github', 'bitbucket', 'gitlab')),
11 | UNIQUE(email, username, provider)
12 | );
13 |
14 | -- +migrate Down
15 | DROP TABLE IF EXISTS users;
16 |
--------------------------------------------------------------------------------
/front/src/hooks/useOwners.ts:
--------------------------------------------------------------------------------
1 | import { sortBy } from 'ramda'
2 | import { useMemo } from 'react'
3 |
4 | import { HTTPResponse, map, useHTTPRequest } from './useHTTPRequest'
5 |
6 | export type Owner = {
7 | login: string
8 | avatar: string
9 | isPaying: boolean
10 | }
11 | export const useOwners = (): HTTPResponse => {
12 | const url = `${process.env.REACT_APP_ERGOMAKE_API}/v2/github/user/organizations`
13 | const res = useHTTPRequest(url)[0]
14 |
15 | return useMemo(
16 | () => map(res, (owners) => sortBy((o) => o.login, owners)),
17 | [res]
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/front/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Ergomake",
3 | "name": "Ergomake",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo384.png",
17 | "type": "image/png",
18 | "sizes": "384x384"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/internal/api/variables/routes.go:
--------------------------------------------------------------------------------
1 | package variables
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 |
6 | "github.com/ergomake/ergomake/internal/envvars"
7 | )
8 |
9 | type variablesRouter struct {
10 | envVarsProvider envvars.EnvVarsProvider
11 | }
12 |
13 | func NewVariablesRouter(envVarsProvider envvars.EnvVarsProvider) *variablesRouter {
14 | return &variablesRouter{envVarsProvider}
15 | }
16 |
17 | func (er *variablesRouter) AddRoutes(router *gin.RouterGroup) {
18 | router.GET("/owner/:owner/repos/:repo/variables", er.list)
19 | router.POST("/owner/:owner/repos/:repo/variables", er.upsert)
20 | }
21 |
--------------------------------------------------------------------------------
/migrations/001_create_environments_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | CREATE TABLE environments (
3 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
5 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
6 | deleted_at TIMESTAMP,
7 | owner VARCHAR(255) NOT NULL,
8 | repo VARCHAR(255) NOT NULL,
9 | branch VARCHAR(255),
10 | pull_request INT,
11 | author VARCHAR(255),
12 | status VARCHAR(255) NOT NULL CHECK (status IN ('pending', 'building', 'success', 'degraded', 'limited'))
13 | );
14 |
15 | -- +migrate Down
16 | DROP TABLE environments;
17 |
--------------------------------------------------------------------------------
/internal/api/auth/login.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "encoding/base64"
5 | "net/http"
6 | "net/url"
7 |
8 | "github.com/gin-gonic/gin"
9 | "golang.org/x/oauth2"
10 | )
11 |
12 | func (ar *authRouter) login(c *gin.Context) {
13 | redirectURL, err := url.Parse(c.Query("redirectUrl"))
14 | if err != nil {
15 | c.String(http.StatusBadRequest, "invalid redirectUrl")
16 | return
17 | }
18 |
19 | state := base64.URLEncoding.EncodeToString([]byte(redirectURL.String()))
20 | authURL := ar.oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOnline)
21 | c.Redirect(http.StatusTemporaryRedirect, authURL)
22 | }
23 |
--------------------------------------------------------------------------------
/front/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 |
4 | import App from './App'
5 | import './index.css'
6 | import reportWebVitals from './reportWebVitals'
7 |
8 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
9 | root.render(
10 |
11 |
12 |
13 | )
14 |
15 | // If you want to start measuring performance in your app, pass a function
16 | // to log results (for example: reportWebVitals(console.log))
17 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
18 | reportWebVitals()
19 |
--------------------------------------------------------------------------------
/migrations/010_introduce_stale_environments_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | ALTER TABLE environments DROP CONSTRAINT environments_status_check;
3 |
4 | ALTER TABLE environments
5 | ADD CONSTRAINT environments_status_check
6 | CHECK (status IN ('pending', 'building', 'success', 'degraded', 'limited', 'stale'));
7 |
8 | -- +migrate Down
9 | UPDATE environments SET status = 'degraded' WHERE status = 'stale';
10 |
11 | ALTER TABLE DROP CONSTRAINT environments_status_check;
12 |
13 | ALTER TABLE environments
14 | ADD CONSTRAINT environments_status_check
15 | CHECK (status IN ('pending', 'building', 'success', 'degraded', 'limited'));
16 |
--------------------------------------------------------------------------------
/front/src/components/HidableSpan.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from 'react'
2 |
3 | interface Props extends React.HTMLAttributes {
4 | startHidden?: boolean
5 | }
6 | function HidableSpan(props: Props) {
7 | const { startHidden, ...spanProps } = props
8 | const [hidden, setHidden] = useState(props.startHidden ?? false)
9 |
10 | const onToggle = useCallback(() => {
11 | setHidden((h) => !h)
12 | }, [setHidden])
13 |
14 | return (
15 |
16 | {hidden ? '************' : props.children}
17 |
18 | )
19 | }
20 |
21 | export default HidableSpan
22 |
--------------------------------------------------------------------------------
/front/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "noUncheckedIndexedAccess": true
19 | },
20 | "include": ["src"]
21 | }
22 |
--------------------------------------------------------------------------------
/front/src/hooks/useRepo.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import { HTTPResponse, map } from './useHTTPRequest'
4 | import { useReposByOwner } from './useReposByOwner'
5 |
6 | export type Repo = {
7 | owner: string
8 | name: string
9 | isInstalled: boolean
10 | environmentCount: number
11 | lastDeployedAt: string | null
12 | branches: string[]
13 | }
14 | export const useRepo = (
15 | owner: string,
16 | repo: string
17 | ): HTTPResponse => {
18 | const res = useReposByOwner(owner)
19 |
20 | return useMemo(
21 | () => map(res, (repos) => repos.find((r) => r.name === repo) ?? null),
22 | [res, repo]
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/internal/api/registries/routes.go:
--------------------------------------------------------------------------------
1 | package registries
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 |
6 | "github.com/ergomake/ergomake/internal/privregistry"
7 | )
8 |
9 | type registriesRouter struct {
10 | privRegistryProvider privregistry.PrivRegistryProvider
11 | }
12 |
13 | func NewRegistriesRouter(privRegistryProvider privregistry.PrivRegistryProvider) *registriesRouter {
14 | return ®istriesRouter{privRegistryProvider}
15 | }
16 |
17 | func (rr *registriesRouter) AddRoutes(router *gin.RouterGroup) {
18 | router.POST("/owner/:owner/registries", rr.create)
19 | router.GET("/owner/:owner/registries", rr.list)
20 | router.DELETE("/owner/:owner/registries/:registryID", rr.del)
21 | }
22 |
--------------------------------------------------------------------------------
/front/src/hooks/useAuthError.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState } from 'react'
2 |
3 | type UseAuthError = [boolean, (error: boolean) => void]
4 | export const AuthErrorContext = createContext([
5 | false,
6 | (_: boolean) => {},
7 | ])
8 |
9 | export const useAuthErrorProvider = (): UseAuthError => {
10 | return useState(false)
11 | }
12 |
13 | export function AuthErrorProvider({ children }: { children: React.ReactNode }) {
14 | const themeValue = useAuthErrorProvider()
15 |
16 | return (
17 |
18 | {children}
19 |
20 | )
21 | }
22 |
23 | export const useAuthError = () => useContext(AuthErrorContext)
24 |
--------------------------------------------------------------------------------
/internal/privregistry/privregistry.go:
--------------------------------------------------------------------------------
1 | package privregistry
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/google/uuid"
7 | "github.com/pkg/errors"
8 | )
9 |
10 | var ErrRegistryNotFound = errors.New("registry not found")
11 |
12 | type RegistryCreds struct {
13 | ID uuid.UUID `json:"id"`
14 | URL string `json:"url"`
15 | Provider string `json:"provider"`
16 | Token string `json:"-"`
17 | }
18 |
19 | type PrivRegistryProvider interface {
20 | ListCredsByOwner(ctx context.Context, owner string, skipToken bool) ([]RegistryCreds, error)
21 | FetchCreds(ctx context.Context, owner, image string) (*RegistryCreds, error)
22 | StoreRegistry(ctx context.Context, owner, url, provider, credentials string) error
23 | DeleteRegistry(ctx context.Context, id uuid.UUID) error
24 | }
25 |
--------------------------------------------------------------------------------
/internal/database/marketplace.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/google/uuid"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // Action can only be "purchased", "cancelled", "pending_change", "pending_change_cancelled", "changed"
11 | type MarketplaceEvent struct {
12 | ID string `gorm:"primaryKey"`
13 | CreatedAt time.Time
14 | UpdatedAt time.Time
15 | DeletedAt gorm.DeletedAt `gorm:"index"`
16 | Owner string
17 | Action string
18 | }
19 |
20 | func (e *MarketplaceEvent) BeforeCreate(tx *gorm.DB) error {
21 | e.ID = uuid.NewString()
22 | return nil
23 | }
24 |
25 | func (db *DB) SaveEvent(owner, action string) error {
26 | event := &MarketplaceEvent{
27 | Owner: owner,
28 | Action: action,
29 | }
30 |
31 | result := db.Create(event)
32 | return result.Error
33 | }
34 |
--------------------------------------------------------------------------------
/cmd/migrator/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 |
6 | _ "github.com/lib/pq"
7 | "github.com/pkg/errors"
8 |
9 | "github.com/ergomake/ergomake/internal/database"
10 | "github.com/ergomake/ergomake/internal/env"
11 | )
12 |
13 | type cfg struct {
14 | DatabaseUrl string `split_words:"true"`
15 | }
16 |
17 | func main() {
18 | var cfg cfg
19 | err := env.LoadEnv(&cfg)
20 | if err != nil {
21 | log.Panic(errors.Wrap(err, "fail to load environment variables"))
22 | }
23 |
24 | db, err := database.Connect(cfg.DatabaseUrl)
25 | if err != nil {
26 | log.Panic(errors.Wrap(err, "fail to connect to database"))
27 | }
28 |
29 | n, err := db.Migrate()
30 | if err != nil {
31 | log.Panic(errors.Wrap(err, "fail to execute db migrations"))
32 | }
33 |
34 | log.Println("Applied", n, "migrations")
35 | }
36 |
--------------------------------------------------------------------------------
/.github/workflows/lint-and-format.yml:
--------------------------------------------------------------------------------
1 | name: Lint and Format
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | types: [opened, synchronize]
9 |
10 | jobs:
11 | lint-and-format:
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Checkout Repository
16 | uses: actions/checkout@v2
17 |
18 | - name: Set up Go
19 | uses: actions/setup-go@v4
20 | with:
21 | go-version: stable
22 |
23 | - name: Install Tools
24 | run: |
25 | go install golang.org/x/tools/cmd/goimports@latest
26 | go install honnef.co/go/tools/cmd/staticcheck@latest
27 |
28 | - name: Linting
29 | run: make lint
30 |
31 | - name: Formatting
32 | run: |
33 | make fmt
34 | git diff --exit-code
35 |
36 |
--------------------------------------------------------------------------------
/front/src/hooks/useBool.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useMemo, useState } from 'react'
2 |
3 | type UseBool = [
4 | boolean,
5 | {
6 | setTrue: () => void
7 | setFalse: () => void
8 | toggle: () => void
9 | },
10 | ]
11 | const useBool = (initial: boolean): UseBool => {
12 | const [state, setState] = useState(initial)
13 |
14 | const setTrue = useCallback(() => {
15 | setState(true)
16 | }, [setState])
17 |
18 | const setFalse = useCallback(() => {
19 | setState(false)
20 | }, [setState])
21 |
22 | const toggle = useCallback(() => {
23 | setState((s) => !s)
24 | }, [setState])
25 |
26 | return useMemo(
27 | () => [
28 | state,
29 | {
30 | setTrue,
31 | setFalse,
32 | toggle,
33 | },
34 | ],
35 | [state, setTrue, setFalse, toggle]
36 | )
37 | }
38 |
39 | export default useBool
40 |
--------------------------------------------------------------------------------
/internal/database/database.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "log"
5 | "os"
6 | "time"
7 |
8 | "gorm.io/driver/postgres"
9 | "gorm.io/gorm"
10 | "gorm.io/gorm/logger"
11 | )
12 |
13 | type DB struct {
14 | *gorm.DB
15 | }
16 |
17 | func Connect(url string) (*DB, error) {
18 | config := &gorm.Config{
19 | Logger: logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{
20 | SlowThreshold: 200 * time.Millisecond,
21 | LogLevel: logger.Warn,
22 | IgnoreRecordNotFoundError: true,
23 | Colorful: true,
24 | })}
25 |
26 | db, err := gorm.Open(postgres.Open(url), config)
27 | if err != nil {
28 | return nil, err
29 | }
30 |
31 | return &DB{db}, nil
32 | }
33 |
34 | func (db *DB) Close() error {
35 | sql, err := db.DB.DB()
36 | if err != nil {
37 | return err
38 | }
39 |
40 | return sql.Close()
41 | }
42 |
--------------------------------------------------------------------------------
/internal/dockerutils/dockerutils_test.go:
--------------------------------------------------------------------------------
1 | package dockerutils
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestExtractDockerRegistryURL(t *testing.T) {
10 | t.Parallel()
11 |
12 | tt := []struct {
13 | imageURL string
14 | expectedURL string
15 | expectedError error
16 | }{
17 | {
18 | imageURL: "gcr.io/project/image:tag",
19 | expectedURL: "gcr.io",
20 | expectedError: nil,
21 | },
22 | {
23 | imageURL: "postgres:13-alpine",
24 | expectedURL: "index.docker.io",
25 | expectedError: nil,
26 | },
27 | }
28 |
29 | for _, tc := range tt {
30 | tc := tc
31 | t.Run(tc.imageURL, func(t *testing.T) {
32 | t.Parallel()
33 |
34 | url, err := ExtractDockerRegistryURL(tc.imageURL)
35 |
36 | assert.Equal(t, tc.expectedURL, url)
37 | assert.Equal(t, tc.expectedError, err)
38 | })
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/front/src/components/StripePricingTable.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 |
3 | interface Props {
4 | owner: string
5 | }
6 |
7 | const StripePricingTable = (props: Props) => {
8 | useEffect(() => {
9 | const script = document.createElement('script')
10 | script.src = 'https://js.stripe.com/v3/pricing-table.js'
11 | script.async = true
12 |
13 | document.body.appendChild(script)
14 |
15 | return () => {
16 | document.body.removeChild(script)
17 | }
18 | }, [])
19 |
20 | const pricingTableId = process.env.REACT_APP_STRIPE_PRICING_TABLE_ID
21 | const publishableKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY
22 |
23 | return React.createElement('stripe-pricing-table', {
24 | 'pricing-table-id': pricingTableId,
25 | 'publishable-key': publishableKey,
26 | 'client-reference-id': props.owner,
27 | })
28 | }
29 |
30 | export default StripePricingTable
31 |
--------------------------------------------------------------------------------
/front/src/components/Loading.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowPathIcon } from '@heroicons/react/24/solid'
2 | import React, { useEffect, useState } from 'react'
3 |
4 | interface Props {
5 | size?: number
6 | className?: string
7 | color?: string
8 | }
9 | function Loading(props: Props) {
10 | const color = props.color ?? '#f2f2f2'
11 | const [showIcon, setShowIcon] = useState(false)
12 | useEffect(() => {
13 | const timeout = setTimeout(() => {
14 | setShowIcon(true)
15 | }, 200)
16 |
17 | return () => clearTimeout(timeout)
18 | }, [])
19 |
20 | if (!showIcon) {
21 | return null
22 | }
23 |
24 | const size = props.size ?? 8
25 | const className = props.className ?? ''
26 | return (
27 |
33 | )
34 | }
35 |
36 | export default Loading
37 |
--------------------------------------------------------------------------------
/front/src/components/Logo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | // Returns the logo big by default, if small is true, returns the small logo
4 | const Logo = ({ small }: { small?: boolean }) => {
5 | let Logo = LogoBig
6 |
7 | if (small) {
8 | Logo = LogoSmall
9 | }
10 |
11 | return
12 | }
13 |
14 | const LogoSmall = () => {
15 | return (
16 |
20 | )
21 | }
22 |
23 | const LogoBig = () => {
24 | return (
25 |
26 | ergo
27 | make_
28 |
29 | )
30 | }
31 |
32 | export default Logo
33 |
--------------------------------------------------------------------------------
/front/src/hooks/useProfile.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import {
4 | HTTPResponse,
5 | andThen,
6 | isError,
7 | map,
8 | useHTTPRequest,
9 | } from './useHTTPRequest'
10 | import { Owner, useOwners } from './useOwners'
11 |
12 | export type Profile = {
13 | name: string
14 | username: string
15 | avatar: string
16 | owners: Owner[]
17 | }
18 | export const useProfile = (): HTTPResponse => {
19 | const url = `${process.env.REACT_APP_ERGOMAKE_API}/v2/auth/profile`
20 |
21 | const [res] = useHTTPRequest(url)
22 | const owners = useOwners()
23 |
24 | return useMemo(() => {
25 | if (isError(res) && res.err._tag === 'authentication') {
26 | return { _tag: 'success', body: null, loading: false, refreshing: false }
27 | }
28 |
29 | return andThen(res, (profile) =>
30 | map(owners, (owners) => ({ ...profile.body, owners }))
31 | )
32 | }, [res, owners])
33 | }
34 |
--------------------------------------------------------------------------------
/migrations/007_create_private_registries_table.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | DROP TABLE IF EXISTS registries;
3 | CREATE TABLE private_registries (
4 | id UUID DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY,
5 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
6 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
7 | deleted_at TIMESTAMP NULL,
8 | owner VARCHAR(255) NOT NULL,
9 | url VARCHAR(255) NOT NULL,
10 | provider VARCHAR(255) NOT NULL CHECK (provider in ('ecr', 'gcr', 'hub')),
11 | credentials TEXT NOT NULL
12 | );
13 |
14 | -- +migrate Down
15 | DROP TABLE IF EXISTS private_registries;
16 | CREATE TABLE registries (
17 | id UUID DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY,
18 | created_at TIMESTAMP NOT NULL DEFAULT NOW(),
19 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
20 | deleted_at TIMESTAMP NULL,
21 | owner VARCHAR(255) NOT NULL,
22 | url VARCHAR(255) NOT NULL,
23 | token TEXT NOT NULL
24 | );
25 |
--------------------------------------------------------------------------------
/front/public/github-mark-white.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/ginutils/ginutils.go:
--------------------------------------------------------------------------------
1 | package ginutils
2 |
3 | import (
4 | "io/ioutil"
5 | "net/http"
6 |
7 | "github.com/pkg/errors"
8 | )
9 |
10 | type bytesBinding struct{}
11 |
12 | var BYTES = bytesBinding{}
13 |
14 | func (bytesBinding) Name() string {
15 | return "bytes"
16 | }
17 |
18 | // Bind reads the body of the http.Request and copies it into obj
19 | func (bytesBinding) Bind(req *http.Request, obj any) error {
20 | body, err := ioutil.ReadAll(req.Body)
21 | if err != nil {
22 | return errors.Wrap(err, "failed to read request body")
23 | }
24 | defer req.Body.Close()
25 |
26 | return bytesBinding{}.BindBody(body, obj)
27 | }
28 |
29 | // BindBody copies the given body into obj
30 | func (bytesBinding) BindBody(body []byte, obj any) error {
31 | bytesObj, ok := obj.(*[]byte)
32 | if !ok {
33 | return errors.New("obj must be a pointer to []byte")
34 | }
35 | *bytesObj = make([]byte, len(body))
36 | copy(*bytesObj, body)
37 | return nil
38 | }
39 |
--------------------------------------------------------------------------------
/internal/api/auth/github.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/google/go-github/v52/github"
7 | "github.com/pkg/errors"
8 | "golang.org/x/oauth2"
9 | )
10 |
11 | // TODO: move this to ghoauth.GHOAuthClient
12 | func IsAuthorized(ctx context.Context, owner string, authData *AuthData) (bool, error) {
13 | tokenSource := oauth2.StaticTokenSource(authData.GithubToken)
14 | oauth2Client := oauth2.NewClient(ctx, tokenSource)
15 | client := github.NewClient(oauth2Client)
16 |
17 | user, _, err := client.Users.Get(ctx, "")
18 | if err != nil {
19 | return false, errors.Wrap(err, "fail to get github authenticated user")
20 | }
21 |
22 | if *user.Login == owner {
23 | return true, nil
24 | }
25 |
26 | isMember, _, err := client.Organizations.IsMember(ctx, owner, *user.Login)
27 | if err != nil {
28 | return false, errors.Wrapf(err, "fail to check if user %s is member of org %s", *user.Login, owner)
29 | }
30 |
31 | return isMember, nil
32 | }
33 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use an official Golang runtime as a parent image
2 | FROM --platform=linux/amd64 golang:latest as builder
3 |
4 | # Set the working directory to /app
5 | WORKDIR /app
6 |
7 | # Copy the current directory contents into the container at /app
8 | COPY . /app
9 |
10 | # Install any needed packages specified in go.mod
11 | RUN go mod download
12 |
13 | # Compile the Go program for a statically linked binary
14 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /app/ergomake cmd/ergomake/main.go
15 |
16 | # Use an official Alpine Linux as a base image
17 | FROM --platform=linux/amd64 alpine:latest
18 |
19 | RUN apk update && apk add --no-cache git
20 |
21 | # Set the working directory to /app
22 | WORKDIR /app
23 |
24 | # Copy the compiled binary from the builder image
25 | COPY --from=builder /app/ergomake /app/ergomake
26 |
27 | EXPOSE 8080
28 | EXPOSE 9090
29 |
30 | # Define the command to run the executable when the container starts
31 | CMD ["/app/ergomake"]
32 |
--------------------------------------------------------------------------------
/internal/api/environments/routes.go:
--------------------------------------------------------------------------------
1 | package environments
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 |
6 | "github.com/ergomake/ergomake/internal/cluster"
7 | "github.com/ergomake/ergomake/internal/database"
8 | "github.com/ergomake/ergomake/internal/servicelogs"
9 | )
10 |
11 | type environmentsRouter struct {
12 | db *database.DB
13 | logStreamer servicelogs.LogStreamer
14 | clusterClient cluster.Client
15 | jwtSecret string
16 | }
17 |
18 | func NewEnvironmentsRouter(
19 | db *database.DB,
20 | logStreamer servicelogs.LogStreamer,
21 | clusterClient cluster.Client,
22 | jwtSecret string,
23 | ) *environmentsRouter {
24 | return &environmentsRouter{db, logStreamer, clusterClient, jwtSecret}
25 | }
26 |
27 | func (er *environmentsRouter) AddRoutes(router *gin.RouterGroup) {
28 | router.GET("/", er.list)
29 | router.GET("/:envID/logs/build", er.buildLogs)
30 | router.GET("/:envID/logs/live", er.liveLogs)
31 | router.GET("/:envID/public", er.getPublic)
32 | }
33 |
--------------------------------------------------------------------------------
/front/Dockerfile:
--------------------------------------------------------------------------------
1 | ########## BUILDER ##########
2 | FROM --platform=linux/amd64 node:18 AS builder
3 |
4 | ARG REACT_APP_ERGOMAKE_API
5 | ENV REACT_APP_ERGOMAKE_API $REACT_APP_ERGOMAKE_API
6 |
7 | ARG REACT_APP_INSTALLATION_URL
8 | ENV REACT_APP_INSTALLATION_URL $REACT_APP_INSTALLATION_URL
9 |
10 | ARG REACT_APP_VERSION
11 | ENV REACT_APP_VERSION $REACT_APP_VERSION
12 |
13 | ARG REACT_APP_STRIPE_PRICING_TABLE_ID
14 | ENV REACT_APP_STRIPE_PRICING_TABLE_ID $REACT_APP_STRIPE_PRICING_TABLE_ID
15 |
16 | ARG REACT_APP_STRIPE_PUBLISHABLE_KEY
17 | ENV REACT_APP_STRIPE_PUBLISHABLE_KEY $REACT_APP_STRIPE_PUBLISHABLE_KEY
18 |
19 | WORKDIR /app
20 |
21 | COPY package*.json yarn.lock ./
22 |
23 | RUN yarn install
24 |
25 | COPY . .
26 |
27 | RUN yarn build
28 |
29 | ########## RUNNER ##########
30 | FROM --platform=linux/amd64 node:18-alpine
31 |
32 | WORKDIR /app
33 |
34 | RUN yarn global add serve
35 |
36 | COPY --from=builder /app/build ./build
37 |
38 | EXPOSE 3000
39 |
40 | CMD ["serve", "-s", "build"]
41 |
--------------------------------------------------------------------------------
/front/src/components/AuthAlert.tsx:
--------------------------------------------------------------------------------
1 | import { ExclamationTriangleIcon } from '@heroicons/react/24/solid'
2 |
3 | function AuthAlert() {
4 | const redirectUrl = `${window.location.protocol}//${window.location.host}`
5 | const loginUrl = `${process.env.REACT_APP_ERGOMAKE_API}/v2/auth/login?redirectUrl=${redirectUrl}`
6 |
7 | return (
8 |
20 | )
21 | }
22 |
23 | export default AuthAlert
24 |
--------------------------------------------------------------------------------
/front/src/components/BillingAlert.tsx:
--------------------------------------------------------------------------------
1 | import { ExclamationTriangleIcon } from '@heroicons/react/24/solid'
2 | import { Link } from 'react-router-dom'
3 |
4 | interface Props {
5 | owner: string
6 | }
7 |
8 | const BillingAlert = (props: Props) => {
9 | return (
10 |
11 |
12 |
13 | On a free plan you can only have three simultaneous preview links.{' '}
14 |
18 | Upgrade to get unlimited previews.
19 |
20 |
21 |
22 | )
23 | }
24 |
25 | export default BillingAlert
26 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: mocks
2 | mocks:
3 | rm -rf mocks && mockery --all --keeptree --with-expecter --dir internal
4 |
5 | .PHONY: fmt
6 | fmt:
7 | goimports -local=github.com/ergomake/ergomake -w cmd internal e2e
8 |
9 | .PHONY: lint
10 | lint:
11 | go vet ./...
12 | staticcheck ./...
13 |
14 | .PHONY: tidy
15 | tidy:
16 | go mod tidy
17 |
18 | TESTS = ./internal/...
19 | .PHONY: test
20 | test:
21 | go test -v -race $(TESTS)
22 |
23 | .PHONY: e2e-test
24 | e2e-test:
25 | go test -v -race -timeout=15m ./e2e/...
26 |
27 | COVERPKG = ./internal/...
28 | .PHONY: coverage
29 | coverage:
30 | go test -v -race -covermode=atomic -coverprofile cover.out -coverpkg $(COVERPKG) $(TESTS)
31 |
32 | .PHONY: deps
33 | deps:
34 | go run github.com/playwright-community/playwright-go/cmd/playwright install --with-deps
35 | go install golang.org/x/tools/cmd/goimports@latest
36 | go install honnef.co/go/tools/cmd/staticcheck@latest
37 | go install github.com/vektra/mockery/v2@v2.26.1
38 |
39 | .PHONY: migrate
40 | migrate:
41 | go run cmd/migrator/main.go
42 |
--------------------------------------------------------------------------------
/front/src/hooks/useVariables.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import {
4 | HTTPResponse,
5 | andThen,
6 | isLoading,
7 | useHTTPMutation,
8 | useHTTPRequest,
9 | } from './useHTTPRequest'
10 |
11 | export type Variable = {
12 | name: string
13 | value: string
14 | branch: string | null
15 | }
16 |
17 | type UseVariables = [HTTPResponse, (variables: Variable[]) => void]
18 |
19 | export const useVariables = (owner: string, repo: string): UseVariables => {
20 | const url = `${process.env.REACT_APP_ERGOMAKE_API}/v2/owner/${owner}/repos/${repo}/variables`
21 |
22 | const [initial] = useHTTPRequest(url)
23 | const [vars, update] = useHTTPMutation(url)
24 |
25 | return useMemo((): UseVariables => {
26 | if (vars._tag === 'pristine') {
27 | return [initial, update]
28 | }
29 |
30 | if (isLoading(vars)) {
31 | return [andThen(initial, (i) => ({ ...i, refreshing: true })), update]
32 | }
33 |
34 | return [vars, update]
35 | }, [initial, vars, update])
36 | }
37 |
--------------------------------------------------------------------------------
/scripts/reset-minikube.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -xe
4 |
5 | minikube delete
6 | minikube start --insecure-registry=host.minikube.internal:5001 --nodes 4 --network-plugin=cni --cni=calico --kubernetes-version="v1.24.3" --memory=max --cpus=max
7 | minikube addons enable ingress
8 |
9 | kubectl label nodes minikube "preview.ergomake.dev/role=core"
10 | kubectl label nodes minikube-m02 "preview.ergomake.dev/role=preview"
11 | kubectl label nodes minikube-m03 "preview.ergomake.dev/role=system"
12 | kubectl label nodes minikube-m04 "preview.ergomake.dev/role=build"
13 |
14 | kubectl label nodes minikube-m02 "preview.ergomake.dev/logs=true"
15 | kubectl label nodes minikube-m04 "preview.ergomake.dev/logs=true"
16 |
17 | kubectl taint nodes minikube "preview.ergomake.dev/domain=core:NoSchedule"
18 | kubectl taint nodes minikube-m02 "preview.ergomake.dev/domain=previews:NoSchedule"
19 | kubectl taint nodes minikube-m04 "preview.ergomake.dev/domain=build:NoSchedule"
20 |
21 |
22 | echo "Install the ergomake helm chart to create the build namespace and service account."
23 |
--------------------------------------------------------------------------------
/front/src/pages/Loading.tsx:
--------------------------------------------------------------------------------
1 | import ArrowPathIcon from '@heroicons/react/24/solid/ArrowPathIcon'
2 | import React, { useEffect, useState } from 'react'
3 |
4 | import Logo from '../components/Logo'
5 |
6 | interface Props {
7 | caption?: string
8 | }
9 | function Loading(props: Props) {
10 | const [showInnerDiv, setShowInnerDiv] = useState(false)
11 | useEffect(() => {
12 | const timeout = setTimeout(() => {
13 | setShowInnerDiv(true)
14 | }, 1000)
15 |
16 | return () => clearTimeout(timeout)
17 | }, [])
18 |
19 | return (
20 |
21 | {showInnerDiv && (
22 |
23 |
27 | {props.caption &&
{props.caption}
}
28 |
29 | )}
30 |
31 | )
32 | }
33 |
34 | export default Loading
35 |
--------------------------------------------------------------------------------
/internal/database/service.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/google/uuid"
7 | "github.com/lib/pq"
8 | "github.com/pkg/errors"
9 | "gorm.io/gorm"
10 | )
11 |
12 | type Service struct {
13 | ID string `gorm:"primaryKey"`
14 | Name string
15 | EnvironmentID uuid.UUID `gorm:"type:uuid;index"`
16 | Url string
17 | Image string
18 | Build string
19 | BuildStatus string
20 | Index int
21 | PublicPort string
22 | InternalPorts pq.StringArray `gorm:"type:text[]"`
23 | CreatedAt time.Time
24 | UpdatedAt time.Time
25 | DeletedAt gorm.DeletedAt `gorm:"index"`
26 | }
27 |
28 | func (db *DB) FindServicesByEnvironment(environmentID uuid.UUID) ([]Service, error) {
29 | services := make([]Service, 0)
30 | result := db.Where(map[string]interface{}{
31 | "environment_id": environmentID,
32 | }).Order("index ASC").Find(&services)
33 |
34 | if errors.Is(result.Error, gorm.ErrRecordNotFound) {
35 | return services, nil
36 | }
37 |
38 | return services, result.Error
39 | }
40 |
--------------------------------------------------------------------------------
/internal/payment/payment.go:
--------------------------------------------------------------------------------
1 | package payment
2 |
3 | import "context"
4 |
5 | type PaymentPlan string
6 |
7 | const (
8 | PaymentPlanFree PaymentPlan = "free"
9 | PaymentPlanStandard PaymentPlan = "standard"
10 | PaymentPlanProfessional PaymentPlan = "professional"
11 | )
12 |
13 | func (plan *PaymentPlan) ActiveEnvironmentsLimit() int {
14 | switch *plan {
15 | case PaymentPlanFree:
16 | return 1
17 | case PaymentPlanStandard:
18 | return 3
19 | case PaymentPlanProfessional:
20 | return 8
21 | }
22 |
23 | panic("unreachable")
24 | }
25 |
26 | func (plan *PaymentPlan) PermanentEnvironmentsLimit() int {
27 | switch *plan {
28 | case PaymentPlanFree:
29 | return 0
30 | case PaymentPlanStandard:
31 | return 1
32 | case PaymentPlanProfessional:
33 | return 4
34 | }
35 |
36 | panic("unreachable")
37 | }
38 |
39 | const StandardPlanEnvLimit = 10
40 |
41 | type PaymentProvider interface {
42 | SaveSubscription(ctx context.Context, owner, subscriptionID string) error
43 | GetOwnerPlan(ctx context.Context, owner string) (PaymentPlan, error)
44 | }
45 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | types: [opened, synchronize]
9 |
10 | jobs:
11 | test:
12 | runs-on: ubuntu-latest
13 |
14 | services:
15 | postgres:
16 | image: postgres:13
17 | env:
18 | POSTGRES_USER: ergomake
19 | POSTGRES_PASSWORD: ergomake
20 | POSTGRES_DB: ergomake
21 | ports:
22 | - 5432:5432
23 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
24 |
25 |
26 | steps:
27 | - name: Checkout Repository
28 | uses: actions/checkout@v2
29 |
30 | - name: Set up Go
31 | uses: actions/setup-go@v4
32 | with:
33 | go-version: stable
34 |
35 | - run: make migrate
36 | env:
37 | DATABASE_URL: postgresql://ergomake:ergomake@localhost:5432/ergomake?sslmode=disable
38 |
39 | - run: make test
40 | env:
41 | DATABASE_URL: postgresql://ergomake:ergomake@localhost:5432/ergomake?sslmode=disable
42 |
--------------------------------------------------------------------------------
/internal/environments/environments.go:
--------------------------------------------------------------------------------
1 | package environments
2 |
3 | import (
4 | "context"
5 | "errors"
6 |
7 | "github.com/google/uuid"
8 |
9 | "github.com/ergomake/ergomake/internal/database"
10 | )
11 |
12 | var ErrEnvironmentNotFound = errors.New("environment not found")
13 |
14 | type TerminateEnvironmentRequest struct {
15 | Owner string
16 | Repo string
17 | Branch string
18 | PrNumber *int
19 | }
20 |
21 | type EnvironmentsProvider interface {
22 | IsOwnerLimited(ctx context.Context, owner string) (bool, error)
23 | GetEnvironmentFromHost(ctx context.Context, host string) (*database.Environment, error)
24 | SaveEnvironment(ctx context.Context, env *database.Environment) error
25 | ListSuccessEnvironments(ctx context.Context) ([]*database.Environment, error)
26 | ShouldDeploy(ctx context.Context, owner string, repo string, branch string) (bool, error)
27 | ListEnvironmentsByBranch(ctx context.Context, owner, repo, branch string) ([]*database.Environment, error)
28 | DeleteEnvironment(ctx context.Context, id uuid.UUID) error
29 | TerminateEnvironment(ctx context.Context, req TerminateEnvironmentRequest) error
30 | }
31 |
--------------------------------------------------------------------------------
/front/src/hooks/usePermanentBranches.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import {
4 | HTTPResponse,
5 | andThen,
6 | isLoading,
7 | useHTTPMutation,
8 | useHTTPRequest,
9 | } from './useHTTPRequest'
10 |
11 | export type PermanentBranch = {
12 | name: string
13 | }
14 | type Payload = { branches: string[] }
15 | type UsePermanentBranches = [
16 | HTTPResponse,
17 | (payload: Payload) => void,
18 | ]
19 |
20 | export const usePermanentBranches = (
21 | owner: string,
22 | repo: string
23 | ): UsePermanentBranches => {
24 | const url = `${process.env.REACT_APP_ERGOMAKE_API}/v2/owner/${owner}/repos/${repo}/permanent-branches`
25 |
26 | const [initial] = useHTTPRequest(url)
27 | const [vars, update] = useHTTPMutation(url)
28 |
29 | return useMemo((): UsePermanentBranches => {
30 | if (vars._tag === 'pristine') {
31 | return [initial, update]
32 | }
33 |
34 | if (isLoading(vars)) {
35 | return [andThen(initial, (i) => ({ ...i, refreshing: true })), update]
36 | }
37 |
38 | return [vars, update]
39 | }, [initial, vars, update])
40 | }
41 |
--------------------------------------------------------------------------------
/internal/api/auth/middleware.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/golang-jwt/jwt"
8 | )
9 |
10 | func ExtractAuthDataMiddleware(jwtSecret string) gin.HandlerFunc {
11 | return func(c *gin.Context) {
12 | authToken, err := c.Cookie(AuthTokenCookieName)
13 | if err != nil {
14 | c.Next()
15 | return
16 | }
17 |
18 | token, err := jwt.ParseWithClaims(authToken, &AuthData{}, func(token *jwt.Token) (interface{}, error) {
19 | if token.Method != jwt.SigningMethodHS256 {
20 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
21 | }
22 |
23 | return []byte(jwtSecret), nil
24 | })
25 |
26 | if err != nil || !token.Valid {
27 | c.Next()
28 | return
29 | }
30 |
31 | claims, ok := token.Claims.(*AuthData)
32 | if !ok {
33 | c.Next()
34 | return
35 | }
36 |
37 | c.Set("customClaims", claims)
38 | c.Next()
39 | }
40 | }
41 |
42 | func GetAuthData(c *gin.Context) (*AuthData, bool) {
43 | v, ok := c.Get("customClaims")
44 | if !ok {
45 | return nil, false
46 | }
47 |
48 | claims, ok := v.(*AuthData)
49 | if !ok {
50 | return nil, false
51 | }
52 |
53 | return claims, true
54 | }
55 |
--------------------------------------------------------------------------------
/front/src/components/ButtonV2.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames'
2 |
3 | import Loading from './Loading'
4 |
5 | interface Props extends React.ButtonHTMLAttributes {
6 | loading?: boolean
7 | animated?: boolean
8 | }
9 |
10 | function Button(props: Props) {
11 | const className = classNames(
12 | props.className,
13 | 'rounded-md',
14 | 'px-3',
15 | 'py-2',
16 | 'text-sm',
17 | 'font-semibold',
18 | 'text-white',
19 | 'shadow-sm',
20 | 'focus-visible:outline',
21 | 'focus-visible:outline-2',
22 | 'focus-visible:outline-offset-2',
23 | 'focus-visible:outline-primary-600',
24 | {
25 | 'bg-primary-600': !props.disabled,
26 | 'bg-gray-400': props.disabled,
27 | 'hover:bg-primary-500': !props.disabled,
28 | flex: props.loading,
29 | 'items-center': props.loading,
30 | 'hover:bg-gradient-to-r from-primary-600 via-primary-900 to-primary-300 hover:background-animate':
31 | props.animated,
32 | }
33 | )
34 |
35 | return (
36 |
40 | )
41 | }
42 |
43 | export default Button
44 |
--------------------------------------------------------------------------------
/front/src/components/RepositoryList.tsx:
--------------------------------------------------------------------------------
1 | import Button from '../components/Button'
2 | import List from '../components/List'
3 | import { Repo } from '../hooks/useRepo'
4 |
5 | type RepositoryListProps = {
6 | repos: Repo[]
7 | onConfigure: (repo: Repo) => void
8 | }
9 |
10 | type ConfigureButtonProps = {
11 | onClick: () => void
12 | }
13 |
14 | const ConfigureButton = ({ onClick }: ConfigureButtonProps) => {
15 | return
16 | }
17 |
18 | const RepositoryList = ({ repos, onConfigure }: RepositoryListProps) => {
19 | const repoItems = repos.map((repo) => {
20 | const envWord = repo.environmentCount === 1 ? 'environment' : 'environments'
21 |
22 | return {
23 | name: repo.name,
24 | descriptionLeft: `${repo.environmentCount} ${envWord}`,
25 | descriptionRight: 'Deploys from GitHub',
26 | chevron: repo.lastDeployedAt ? undefined : (
27 | onConfigure(repo)} />
28 | ),
29 | url: repo.lastDeployedAt
30 | ? `/gh/${repo.owner}/repos/${repo.name}`
31 | : undefined,
32 | data: repo,
33 | }
34 | })
35 |
36 | return
37 | }
38 |
39 | export default RepositoryList
40 |
--------------------------------------------------------------------------------
/cmd/cli/utils/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 |
8 | "github.com/pkg/errors"
9 |
10 | "github.com/ergomake/ergomake/internal/api"
11 | "github.com/ergomake/ergomake/internal/env"
12 | "github.com/ergomake/ergomake/internal/github/ghapp"
13 | )
14 |
15 | func main() {
16 | var cfg api.Config
17 | err := env.LoadEnv(&cfg)
18 | if err != nil {
19 | panic(errors.Wrap(err, "fail to load environment variables"))
20 | }
21 |
22 | ghApp, err := ghapp.NewGithubClient(cfg.GithubPrivateKey, cfg.GithubAppID)
23 | if err != nil {
24 | panic(errors.Wrap(err, "fail to create GitHub client"))
25 | }
26 |
27 | if len(os.Args) < 2 {
28 | printUsage()
29 | os.Exit(1)
30 | }
31 |
32 | command := os.Args[1]
33 |
34 | switch command {
35 | case "installations":
36 | owners, err := ghApp.ListInstalledOwners(context.Background())
37 | if err != nil {
38 | panic(errors.Wrap(err, "fail to list installed owners"))
39 | }
40 |
41 | for _, owner := range owners {
42 | fmt.Println(owner)
43 | }
44 | }
45 | }
46 |
47 | func printUsage() {
48 | fmt.Printf("Usage: %s [arguments]\n", os.Args[0])
49 | fmt.Println("Commands:")
50 | fmt.Println(" installations List current ergomake installations")
51 | }
52 |
--------------------------------------------------------------------------------
/internal/api/permanentbranches/routes.go:
--------------------------------------------------------------------------------
1 | package permanentbranches
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 |
6 | "github.com/ergomake/ergomake/internal/environments"
7 | "github.com/ergomake/ergomake/internal/github/ghapp"
8 | "github.com/ergomake/ergomake/internal/github/ghlauncher"
9 | "github.com/ergomake/ergomake/internal/permanentbranches"
10 | )
11 |
12 | type permanentBranchesRouter struct {
13 | ghApp ghapp.GHAppClient
14 | ghLaunccher ghlauncher.GHLauncher
15 | permanentbranchesProvider permanentbranches.PermanentBranchesProvider
16 | environmentsProvider environments.EnvironmentsProvider
17 | }
18 |
19 | func NewPermanentBranchesRouter(
20 | ghApp ghapp.GHAppClient,
21 | ghLaunccher ghlauncher.GHLauncher,
22 | permanentbranchesProvider permanentbranches.PermanentBranchesProvider,
23 | environmentsProvider environments.EnvironmentsProvider,
24 | ) *permanentBranchesRouter {
25 | return &permanentBranchesRouter{ghApp, ghLaunccher, permanentbranchesProvider, environmentsProvider}
26 | }
27 |
28 | func (er *permanentBranchesRouter) AddRoutes(router *gin.RouterGroup) {
29 | router.GET("/owner/:owner/repos/:repo/permanent-branches", er.list)
30 | router.POST("/owner/:owner/repos/:repo/permanent-branches", er.upsert)
31 | }
32 |
--------------------------------------------------------------------------------
/front/src/pages/Purchase.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { useParams } from 'react-router-dom'
3 |
4 | import Layout from '../components/Layout'
5 | import StripePricingTable from '../components/StripePricingTable'
6 | import { orElse } from '../hooks/useHTTPRequest'
7 | import { useOwners } from '../hooks/useOwners'
8 | import { Profile } from '../hooks/useProfile'
9 |
10 | interface Props {
11 | profile: Profile
12 | }
13 |
14 | function Purchase({ profile }: Props) {
15 | const params = useParams<{ owner: string }>()
16 | const ownersRes = useOwners()
17 | const owners = useMemo(() => orElse(ownersRes, []), [ownersRes])
18 | const currentOwner = owners.find((o) => o.login === params.owner)
19 |
20 | const pages = [
21 | {
22 | name: 'Purchase',
23 | href: `/gh/${params.owner}/purchase`,
24 | label: 'Repositories',
25 | },
26 | ]
27 |
28 | return (
29 |
30 |
31 |
32 | Upgrade your plan for {currentOwner?.login}
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | export default Purchase
41 |
--------------------------------------------------------------------------------
/front/src/components/Input.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames'
2 |
3 | type InputProps = {
4 | label: string
5 | onChange: (value: string) => void
6 | value: string
7 | placeholder: string
8 | disabled?: boolean
9 | type?: React.HTMLInputTypeAttribute
10 | onPaste?: React.ClipboardEventHandler
11 | }
12 |
13 | const Input = ({
14 | label,
15 | onChange,
16 | value,
17 | placeholder,
18 | disabled,
19 | type,
20 | onPaste,
21 | }: InputProps) => {
22 | return (
23 |
24 | {
29 | onChange(e.target.value)
30 | }}
31 | placeholder={placeholder}
32 | className={classNames(
33 | 'block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-neutral-800 placeholder:text-gray-400 dark:placeholder:text-gray-600 focus:ring-2 focus:ring-inset dark:focus:ring-primary-800 focus:ring-primary-600 sm:text-sm sm:leading-6 dark:bg-neutral-950/30',
34 | { 'bg-gray-100 dark:bg-neutral-700': disabled }
35 | )}
36 | disabled={disabled}
37 | onPaste={onPaste}
38 | />
39 |
40 | )
41 | }
42 | export default Input
43 |
--------------------------------------------------------------------------------
/internal/api/registries/list.go:
--------------------------------------------------------------------------------
1 | package registries
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 |
8 | "github.com/ergomake/ergomake/internal/api/auth"
9 | "github.com/ergomake/ergomake/internal/logger"
10 | )
11 |
12 | func (rr *registriesRouter) list(c *gin.Context) {
13 | authData, ok := auth.GetAuthData(c)
14 | if !ok {
15 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
16 | return
17 | }
18 |
19 | owner := c.Param("owner")
20 | if owner == "" {
21 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
22 | return
23 | }
24 |
25 | isAuthorized, err := auth.IsAuthorized(c, owner, authData)
26 | if err != nil {
27 | logger.Ctx(c).Err(err).Msg("fail to list registries")
28 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
29 | return
30 | }
31 |
32 | if !isAuthorized {
33 | c.JSON(http.StatusForbidden, http.StatusText(http.StatusForbidden))
34 | return
35 | }
36 |
37 | creds, err := rr.privRegistryProvider.ListCredsByOwner(c, owner, true)
38 | if err != nil {
39 | logger.Ctx(c).Err(err).Msgf("fail to list registries for owner %s", owner)
40 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
41 | return
42 | }
43 |
44 | c.JSON(http.StatusOK, creds)
45 | }
46 |
--------------------------------------------------------------------------------
/internal/api/auth/routes.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "golang.org/x/oauth2"
6 |
7 | "github.com/ergomake/ergomake/internal/github/ghapp"
8 | "github.com/ergomake/ergomake/internal/users"
9 | )
10 |
11 | type authRouter struct {
12 | oauthConfig *oauth2.Config
13 | jwtSecret string
14 | secure bool
15 | usersService users.Service
16 | frontendURL string
17 | ghApp ghapp.GHAppClient
18 | }
19 |
20 | func NewAuthRouter(
21 | clientID string,
22 | clientSecret string,
23 | redirectURL string,
24 | jwtSecret string,
25 | secure bool,
26 | usersService users.Service,
27 | frontendURL string,
28 | ghapp ghapp.GHAppClient,
29 | ) *authRouter {
30 | oauthConfig := &oauth2.Config{
31 | ClientID: clientID,
32 | ClientSecret: clientSecret,
33 | RedirectURL: redirectURL,
34 | Endpoint: oauth2.Endpoint{
35 | AuthURL: "https://github.com/login/oauth/authorize",
36 | TokenURL: "https://github.com/login/oauth/access_token",
37 | },
38 | }
39 |
40 | return &authRouter{oauthConfig, jwtSecret, secure, usersService, frontendURL, ghapp}
41 | }
42 |
43 | func (ar *authRouter) AddRoutes(router *gin.RouterGroup) {
44 | router.GET("/login", ar.login)
45 | router.GET("/logout", ar.logout)
46 | router.GET("/callback", ar.callback)
47 | router.GET("/profile", ar.profile)
48 | }
49 |
--------------------------------------------------------------------------------
/front/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames'
2 |
3 | import Loading from './Loading'
4 |
5 | interface Props extends React.ButtonHTMLAttributes {
6 | loading?: boolean
7 | tag?: 'button' | 'a'
8 | href?: string
9 | }
10 |
11 | function Button(props: Props) {
12 | const { href, loading, className, children, ...buttonProps } = props
13 | const cn = classNames(
14 | className,
15 | 'rounded-md',
16 | 'px-3',
17 | 'py-2',
18 | 'text-sm',
19 | 'font-semibold',
20 | 'text-white',
21 | 'shadow-sm',
22 | 'focus-visible:outline',
23 | 'focus-visible:outline-2',
24 | 'focus-visible:outline-offset-2',
25 | 'focus-visible:outline-primary-600',
26 | 'dark:border dark:border-primary-400',
27 | {
28 | 'bg-primary-600 dark:bg-primary-800': !buttonProps.disabled,
29 | 'bg-gray-400': buttonProps.disabled,
30 | 'hover:bg-primary-500 dark:hover:bg-primary-700': !buttonProps.disabled,
31 | flex: loading,
32 | 'items-center': loading,
33 | 'justify-center': loading,
34 | }
35 | )
36 |
37 | const Tag = props.tag ?? 'button'
38 |
39 | return (
40 |
41 | {loading && }
42 | {children}
43 |
44 | )
45 | }
46 |
47 | export default Button
48 |
--------------------------------------------------------------------------------
/internal/api/variables/list.go:
--------------------------------------------------------------------------------
1 | package variables
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 |
8 | "github.com/ergomake/ergomake/internal/api/auth"
9 | "github.com/ergomake/ergomake/internal/logger"
10 | )
11 |
12 | func (vr *variablesRouter) list(c *gin.Context) {
13 | authData, ok := auth.GetAuthData(c)
14 | if !ok {
15 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
16 | return
17 | }
18 |
19 | owner := c.Param("owner")
20 | repo := c.Param("repo")
21 | if owner == "" || repo == "" {
22 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
23 | return
24 | }
25 |
26 | isAuthorized, err := auth.IsAuthorized(c, owner, authData)
27 | if err != nil {
28 | logger.Ctx(c).Err(err).Msg("fail to list variables")
29 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
30 | return
31 | }
32 |
33 | if !isAuthorized {
34 | c.JSON(http.StatusForbidden, http.StatusText(http.StatusForbidden))
35 | return
36 | }
37 |
38 | variables, err := vr.envVarsProvider.ListByRepo(c, owner, repo)
39 | if err != nil {
40 | logger.Ctx(c).Err(err).Msgf("fail to list variables for repo %s/%s", owner, repo)
41 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
42 | return
43 | }
44 |
45 | c.JSON(http.StatusOK, variables)
46 | }
47 |
--------------------------------------------------------------------------------
/internal/api/auth/profile.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 |
8 | "github.com/ergomake/ergomake/internal/github/ghoauth"
9 | "github.com/ergomake/ergomake/internal/logger"
10 | "github.com/ergomake/ergomake/internal/users"
11 | )
12 |
13 | func (ar *authRouter) profile(c *gin.Context) {
14 | authData, ok := GetAuthData(c)
15 | if !ok {
16 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
17 | return
18 | }
19 |
20 | client := ghoauth.FromToken(authData.GithubToken)
21 | user, r, err := client.GetUser(c)
22 | if err != nil {
23 | if r.StatusCode == http.StatusUnauthorized {
24 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
25 | return
26 | }
27 |
28 | logger.Ctx(c).Err(err).Msg("fail to get user from github")
29 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
30 | return
31 | }
32 |
33 | err = ar.usersService.Save(c, users.User{
34 | Email: user.GetEmail(),
35 | Username: user.GetLogin(),
36 | Name: user.GetName(),
37 | Provider: users.ProviderGithub,
38 | })
39 | if err != nil {
40 | logger.Ctx(c).Err(err).Msg("fail to save user")
41 | }
42 |
43 | c.JSON(http.StatusOK, gin.H{
44 | "avatar": user.GetAvatarURL(),
45 | "username": user.GetLogin(),
46 | "name": user.GetName(),
47 | })
48 | }
49 |
--------------------------------------------------------------------------------
/internal/api/registries/del.go:
--------------------------------------------------------------------------------
1 | package registries
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/google/uuid"
8 |
9 | "github.com/ergomake/ergomake/internal/api/auth"
10 | "github.com/ergomake/ergomake/internal/logger"
11 | )
12 |
13 | func (rr *registriesRouter) del(c *gin.Context) {
14 | authData, ok := auth.GetAuthData(c)
15 | if !ok {
16 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
17 | return
18 | }
19 |
20 | owner := c.Param("owner")
21 | registryID, err := uuid.Parse(c.Param("registryID"))
22 | if owner == "" || err != nil {
23 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
24 | return
25 | }
26 |
27 | isAuthorized, err := auth.IsAuthorized(c, owner, authData)
28 | if err != nil {
29 | logger.Ctx(c).Err(err).Msg("fail to create registry")
30 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
31 | return
32 | }
33 |
34 | if !isAuthorized {
35 | c.JSON(http.StatusForbidden, http.StatusText(http.StatusForbidden))
36 | return
37 | }
38 |
39 | err = rr.privRegistryProvider.DeleteRegistry(c, registryID)
40 | if err != nil {
41 | logger.Ctx(c).Err(err).Msg("fail to create registry")
42 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
43 | return
44 | }
45 |
46 | c.JSON(http.StatusCreated, nil)
47 | }
48 |
--------------------------------------------------------------------------------
/e2e/testutils/database.go:
--------------------------------------------------------------------------------
1 | package testutils
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "math/rand"
7 | "testing"
8 | "time"
9 |
10 | _ "github.com/lib/pq"
11 | "github.com/stretchr/testify/require"
12 |
13 | "github.com/ergomake/ergomake/internal/database"
14 | )
15 |
16 | func CreateRandomDB(t *testing.T) *database.DB {
17 | oldConnStr := "postgres://ergomake:ergomake@localhost/ergomake?sslmode=disable"
18 |
19 | sqlDB, err := sql.Open("postgres", oldConnStr)
20 | require.NoError(t, err)
21 | defer sqlDB.Close()
22 |
23 | rand.Seed(time.Now().UnixNano())
24 | dbName := fmt.Sprintf("ergomake_testdb_%d", rand.Intn(10000))
25 |
26 | _, err = sqlDB.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName))
27 | require.NoError(t, err)
28 |
29 | err = sqlDB.Close()
30 | require.NoError(t, err)
31 |
32 | t.Cleanup(func() {
33 | db, err := sql.Open("postgres", oldConnStr)
34 | require.NoError(t, err)
35 | defer db.Close()
36 |
37 | _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName))
38 | require.NoError(t, err)
39 | })
40 |
41 | connectionString := fmt.Sprintf("postgres://ergomake:ergomake@localhost/%s?sslmode=disable", dbName)
42 | db, err := database.Connect(connectionString)
43 | require.NoError(t, err)
44 | t.Cleanup(func() {
45 | err := db.Close()
46 | require.NoError(t, err)
47 | })
48 |
49 | _, err = db.Migrate()
50 | require.NoError(t, err)
51 |
52 | return db
53 | }
54 |
--------------------------------------------------------------------------------
/internal/api/environments/getpublic.go:
--------------------------------------------------------------------------------
1 | package environments
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/google/uuid"
9 | "gorm.io/gorm"
10 |
11 | "github.com/ergomake/ergomake/internal/logger"
12 | )
13 |
14 | func (er *environmentsRouter) getPublic(c *gin.Context) {
15 | envID, err := uuid.Parse(c.Param("envID"))
16 | if err != nil {
17 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
18 | return
19 | }
20 |
21 | env, err := er.db.FindEnvironmentByID(envID)
22 | if err != nil {
23 | if errors.Is(err, gorm.ErrRecordNotFound) {
24 | c.JSON(http.StatusNotFound, http.StatusText(http.StatusNotFound))
25 | return
26 | }
27 |
28 | logger.Ctx(c).Err(err).Msgf("fail to find environment by id %s", envID)
29 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
30 | return
31 | }
32 |
33 | areServicesAlive, err := er.clusterClient.AreServicesAlive(c, env.ID.String())
34 | if err != nil {
35 | logger.Ctx(c).Err(err).Msg("fail to check if services are alive")
36 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
37 | return
38 | }
39 |
40 | c.JSON(http.StatusOK, gin.H{
41 | "id": env.ID,
42 | "owner": env.Owner,
43 | "repo": env.Repo,
44 | "status": env.Status,
45 | "areServicesAlive": areServicesAlive,
46 | })
47 | }
48 |
--------------------------------------------------------------------------------
/internal/users/dbservice.go:
--------------------------------------------------------------------------------
1 | package users
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/google/uuid"
8 | "github.com/pkg/errors"
9 | "gorm.io/gorm"
10 |
11 | "github.com/ergomake/ergomake/internal/database"
12 | )
13 |
14 | type dbUsersService struct {
15 | db *database.DB
16 | }
17 |
18 | type databaseUser struct {
19 | User
20 |
21 | ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"`
22 | CreatedAt time.Time
23 | UpdatedAt time.Time
24 | DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
25 | }
26 |
27 | func NewDBUsersService(db *database.DB) *dbUsersService {
28 | return &dbUsersService{db}
29 | }
30 |
31 | func (up *dbUsersService) Save(ctx context.Context, user User) error {
32 | var dbUser databaseUser
33 |
34 | var err error
35 | if user.Email == "" {
36 | err = up.db.Table("users").
37 | Find(
38 | &dbUser,
39 | "provider = ? AND username = ?",
40 | user.Provider,
41 | user.Username,
42 | ).Error
43 | } else {
44 | err = up.db.Table("users").
45 | Find(
46 | &dbUser,
47 | "provider = ? AND (email = ? OR username = ?)",
48 | user.Provider,
49 | user.Email,
50 | user.Username,
51 | ).Error
52 | }
53 |
54 | if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
55 | return errors.Wrap(err, "fail to check for existing user in db")
56 | }
57 |
58 | dbUser.User = user
59 | err = up.db.Table("users").Save(&dbUser).Error
60 | return errors.Wrap(err, "fail to save user to db")
61 | }
62 |
--------------------------------------------------------------------------------
/front/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { fontFamily } = require('tailwindcss/defaultTheme')
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | content: ['./src/**/*.{js,jsx,ts,tsx}'],
6 | theme: {
7 | extend: {
8 | fontFamily: {
9 | primary: ['Inter', ...fontFamily.sans],
10 | mono: ['Ubuntu', ...fontFamily.mono],
11 | },
12 | colors: {
13 | primary: {
14 | // Customize it on globals.css :root
15 | 50: 'rgb(var(--tw-color-primary-50) / )',
16 | 100: 'rgb(var(--tw-color-primary-100) / )',
17 | 200: 'rgb(var(--tw-color-primary-200) / )',
18 | 300: 'rgb(var(--tw-color-primary-300) / )',
19 | 400: 'rgb(var(--tw-color-primary-400) / )',
20 | 500: 'rgb(var(--tw-color-primary-500) / )',
21 | 600: 'rgb(var(--tw-color-primary-600) / )',
22 | 700: 'rgb(var(--tw-color-primary-700) / )',
23 | 800: 'rgb(var(--tw-color-primary-800) / )',
24 | 900: 'rgb(var(--tw-color-primary-900) / )',
25 | 1000: 'rgb(var(--tw-color-primary-1000) / )',
26 | light: 'rgb(var(--tw-color-primary-light) / )',
27 | },
28 | dark: '#040404',
29 | outcolor: '#4a5953',
30 | },
31 | },
32 | },
33 | plugins: [require('@tailwindcss/typography'), require('@tailwindcss/forms')],
34 | darkMode: 'class',
35 | }
36 |
--------------------------------------------------------------------------------
/internal/api/permanentbranches/list.go:
--------------------------------------------------------------------------------
1 | package permanentbranches
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 |
8 | "github.com/ergomake/ergomake/internal/api/auth"
9 | "github.com/ergomake/ergomake/internal/logger"
10 | )
11 |
12 | func (pbr *permanentBranchesRouter) list(c *gin.Context) {
13 | authData, ok := auth.GetAuthData(c)
14 | if !ok {
15 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
16 | return
17 | }
18 |
19 | owner := c.Param("owner")
20 | repo := c.Param("repo")
21 | if owner == "" || repo == "" {
22 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
23 | return
24 | }
25 |
26 | isAuthorized, err := auth.IsAuthorized(c, owner, authData)
27 | if err != nil {
28 | logger.Ctx(c).Err(err).Msg("fail to list variables")
29 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
30 | return
31 | }
32 |
33 | if !isAuthorized {
34 | c.JSON(http.StatusForbidden, http.StatusText(http.StatusForbidden))
35 | return
36 | }
37 |
38 | branches, err := pbr.permanentbranchesProvider.List(c, owner, repo)
39 | if err != nil {
40 | logger.Ctx(c).Err(err).Msgf("fail to list permanent branches for repo %s/%s", owner, repo)
41 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
42 | return
43 | }
44 |
45 | output := make([]gin.H, 0)
46 | for _, b := range branches {
47 | output = append(output, gin.H{"name": b})
48 | }
49 | c.JSON(http.StatusOK, output)
50 | }
51 |
--------------------------------------------------------------------------------
/internal/database/migrations.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "database/sql"
5 | "os"
6 | "path/filepath"
7 | "runtime"
8 |
9 | _ "github.com/lib/pq"
10 | "github.com/pkg/errors"
11 | migrate "github.com/rubenv/sql-migrate"
12 | )
13 |
14 | func GetDatabaseConnection(dbUrl string) (*sql.DB, error) {
15 | return sql.Open("postgres", dbUrl)
16 | }
17 |
18 | func (db *DB) Migrate() (int, error) {
19 | pkgRoot, err := findPackageRoot()
20 | if err != nil {
21 | return 0, errors.Wrap(err, "failed to find package root")
22 | }
23 |
24 | migrations := &migrate.FileMigrationSource{
25 | Dir: pkgRoot + "/migrations",
26 | }
27 |
28 | sql, err := db.DB.DB()
29 | if err != nil {
30 | return 0, errors.Wrap(err, "fail to get sql.DB")
31 | }
32 |
33 | n, err := migrate.Exec(sql, "postgres", migrations, migrate.Up)
34 |
35 | return n, errors.Wrap(err, "failed to apply migrations")
36 | }
37 |
38 | func findPackageRoot() (string, error) {
39 | // Get the path to the current file
40 | _, filename, _, ok := runtime.Caller(0)
41 | if !ok {
42 | return "", errors.New("failed to get path to current file")
43 | }
44 |
45 | // Traverse up the directory tree until we find a directory that contains a go.mod file
46 | dir := filepath.Dir(filename)
47 | for {
48 | if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
49 | break
50 | }
51 |
52 | parent := filepath.Dir(dir)
53 | if parent == dir {
54 | return "", errors.New("failed to find package root directory")
55 | }
56 |
57 | dir = parent
58 | }
59 |
60 | return dir, nil
61 | }
62 |
--------------------------------------------------------------------------------
/front/src/components/RequireAuth.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Navigate, useLocation } from 'react-router-dom'
3 |
4 | import { fold } from '../hooks/useHTTPRequest'
5 | import { Profile, useProfile } from '../hooks/useProfile'
6 | import ErrorLayout from '../pages/Error'
7 | import Loading from '../pages/Loading'
8 |
9 | interface RequireAuthProps {
10 | children: (profile: Profile) => React.ReactNode
11 | }
12 | export function RequireAuth({ children }: RequireAuthProps) {
13 | const profile = useProfile()
14 |
15 | const location = useLocation()
16 | const originalUrl = `${window.location.protocol}//${window.location.host}${location.pathname}`
17 |
18 | return fold(profile, {
19 | onError: () => ,
20 | onLoading: () => ,
21 | onSuccess: ({ body }) =>
22 | body ? (
23 | <>{children(body)}>
24 | ) : (
25 |
26 | ),
27 | })
28 | }
29 |
30 | interface RequireNoAuthProps {
31 | children: React.ReactNode
32 | }
33 | export function RequireNoAuth({ children }: RequireNoAuthProps) {
34 | const profile = useProfile()
35 |
36 | return fold(profile, {
37 | onError: () => ,
38 | onLoading: () => ,
39 | onSuccess: ({ body }) => {
40 | if (!body) {
41 | return <>{children}>
42 | }
43 |
44 | const first = body.owners[0]
45 | if (!first) {
46 | return
47 | }
48 |
49 | return
50 | },
51 | })
52 | }
53 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
17 | # Go workspace file
18 | go.work
19 |
20 | # coverage files
21 | cover.html
22 | cover.out
23 |
24 | # Local .terraform directories
25 | **/.terraform/*
26 |
27 | # .tfstate files
28 | *.tfstate
29 | *.tfstate.*
30 |
31 | # Crash log files
32 | crash.log
33 | crash.*.log
34 |
35 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as
36 | # password, private keys, and other secrets. These should not be part of version
37 | # control as they are data points which are potentially sensitive and subject
38 | # to change depending on the environment.
39 | *.tfvars
40 | *.tfvars.json
41 |
42 | # Ignore override files as they are usually used to override resources locally and so
43 | # are not checked in
44 | override.tf
45 | override.tf.json
46 | *_override.tf
47 | *_override.tf.json
48 |
49 | # Include override files you do wish to add to version control using negated pattern
50 | # !example_override.tf
51 |
52 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
53 | # example: *tfplan*
54 |
55 | # Ignore CLI configuration files
56 | .terraformrc
57 | terraform.rc
58 |
59 |
60 | # misc
61 | .DS_Store
62 | *.pem
63 |
64 | # local env files
65 | .env.local
66 | .env.development.local
67 | .env.test.local
68 | .env.production.local
69 |
70 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
17 | # Go workspace file
18 | go.work
19 |
20 | # coverage files
21 | cover.html
22 | cover.out
23 |
24 | # Local .terraform directories
25 | **/.terraform/*
26 |
27 | # .tfstate files
28 | *.tfstate
29 | *.tfstate.*
30 |
31 | # Crash log files
32 | crash.log
33 | crash.*.log
34 |
35 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as
36 | # password, private keys, and other secrets. These should not be part of version
37 | # control as they are data points which are potentially sensitive and subject
38 | # to change depending on the environment.
39 | *.tfvars
40 | *.tfvars.json
41 |
42 | # Ignore override files as they are usually used to override resources locally and so
43 | # are not checked in
44 | override.tf
45 | override.tf.json
46 | *_override.tf
47 | *_override.tf.json
48 |
49 | # Include override files you do wish to add to version control using negated pattern
50 | # !example_override.tf
51 |
52 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
53 | # example: *tfplan*
54 |
55 | # Ignore CLI configuration files
56 | .terraformrc
57 | terraform.rc
58 |
59 |
60 | # misc
61 | .DS_Store
62 | *.pem
63 | gitserver
64 |
65 | # local env files
66 | .env.local
67 | .env.development.local
68 | .env.test.local
69 | .env.production.local
70 |
71 | helm/values-prod.yaml
72 |
--------------------------------------------------------------------------------
/internal/crypto/crypto.go:
--------------------------------------------------------------------------------
1 | package crypto
2 |
3 | import (
4 | "bytes"
5 | "crypto/aes"
6 | "crypto/cipher"
7 | "crypto/rand"
8 | "encoding/hex"
9 | "errors"
10 | "io"
11 | )
12 |
13 | func Encrypt(encryptionKey string, text string) (string, error) {
14 | key, err := hex.DecodeString(encryptionKey)
15 | if err != nil {
16 | return "", err
17 | }
18 |
19 | plaintext := []byte(text)
20 |
21 | iv := make([]byte, 16)
22 | if _, err := io.ReadFull(rand.Reader, iv); err != nil {
23 | return "", err
24 | }
25 |
26 | block, err := aes.NewCipher(key)
27 | if err != nil {
28 | return "", err
29 | }
30 |
31 | ciphertext := make([]byte, len(plaintext))
32 | cfb := cipher.NewCFBEncrypter(block, iv)
33 | cfb.XORKeyStream(ciphertext, plaintext)
34 |
35 | return hex.EncodeToString(iv) + ":" + hex.EncodeToString(ciphertext), nil
36 | }
37 |
38 | func Decrypt(encryptionKey string, hash string) (string, error) {
39 | key, err := hex.DecodeString(encryptionKey)
40 | if err != nil {
41 | return "", err
42 | }
43 |
44 | textParts := bytes.SplitN([]byte(hash), []byte(":"), 2)
45 | if len(textParts) != 2 {
46 | return "", errors.New("invalid hash format")
47 | }
48 |
49 | iv, err := hex.DecodeString(string(textParts[0]))
50 | if err != nil {
51 | return "", err
52 | }
53 |
54 | ciphertext, err := hex.DecodeString(string(textParts[1]))
55 | if err != nil {
56 | return "", err
57 | }
58 |
59 | block, err := aes.NewCipher(key)
60 | if err != nil {
61 | return "", err
62 | }
63 |
64 | plaintext := make([]byte, len(ciphertext))
65 | cfb := cipher.NewCFBDecrypter(block, iv)
66 | cfb.XORKeyStream(plaintext, ciphertext)
67 |
68 | return string(plaintext), nil
69 | }
70 |
--------------------------------------------------------------------------------
/internal/api/github/webhook.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/google/go-github/v52/github"
9 |
10 | "github.com/ergomake/ergomake/internal/ginutils"
11 | "github.com/ergomake/ergomake/internal/logger"
12 | )
13 |
14 | var ownersBlockList = map[string]struct{}{"RahmaNiftaliyev": {}}
15 |
16 | func (r *githubRouter) webhook(c *gin.Context) {
17 | log := logger.Ctx(c.Request.Context())
18 |
19 | var bodyBytes []byte
20 | err := c.ShouldBindBodyWith(&bodyBytes, ginutils.BYTES)
21 | if err != nil {
22 | log.Err(err).Msg("fail to read body")
23 | c.JSON(
24 | http.StatusInternalServerError,
25 | http.StatusText(http.StatusInternalServerError),
26 | )
27 | return
28 | }
29 |
30 | payload, err := github.ValidatePayloadFromBody(
31 | c.ContentType(),
32 | bytes.NewReader(bodyBytes),
33 | c.GetHeader("X-Hub-Signature-256"),
34 | []byte(r.webhookSecret),
35 | )
36 | if err != nil {
37 | c.JSON(
38 | http.StatusUnauthorized,
39 | http.StatusText(http.StatusUnauthorized),
40 | )
41 | return
42 | }
43 |
44 | event, err := github.ParseWebHook(github.WebHookType(c.Request), payload)
45 | if err != nil {
46 | c.JSON(
47 | http.StatusBadRequest,
48 | http.StatusText(http.StatusBadRequest),
49 | )
50 | return
51 | }
52 |
53 | c.Status(http.StatusNoContent)
54 |
55 | githubDelivery := c.GetHeader("X-GitHub-Delivery")
56 |
57 | go func() {
58 | switch event := event.(type) {
59 | case *github.PushEvent:
60 | r.handlePushEvent(githubDelivery, event)
61 | case *github.PullRequestEvent:
62 | r.handlePullRequestEvent(githubDelivery, event)
63 | }
64 | }()
65 | }
66 |
--------------------------------------------------------------------------------
/front/src/hooks/useEnvironment.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import { useEnvironmentsByRepo } from './useEnvironmentsByRepo'
4 | import { UseHTTPRequest, map } from './useHTTPRequest'
5 |
6 | export type EnvironmentStatus =
7 | | 'pending'
8 | | 'building'
9 | | 'success'
10 | | 'degraded'
11 | | 'limited'
12 | | 'stale'
13 |
14 | export type EnvironmentService = {
15 | id: string
16 | name: string
17 | url: string
18 | build: string
19 | }
20 |
21 | export type DegradedReason =
22 | | {
23 | type: 'compose-not-found'
24 | message: string
25 | }
26 | | {
27 | type: 'invalid-compose'
28 | message: string
29 | }
30 |
31 | export type Environment = {
32 | id: string
33 | branch: string
34 | source: 'cli' | 'pr' | 'branch'
35 | status: EnvironmentStatus
36 | services: EnvironmentService[]
37 | createdAt: string
38 | degradedReason: DegradedReason | null
39 | }
40 |
41 | export const hasLogs = (env: Environment): boolean => {
42 | if (env.status === 'limited') {
43 | return false
44 | }
45 |
46 | if (env.status === 'degraded') {
47 | if (
48 | env.degradedReason?.type === 'compose-not-found' ||
49 | env.degradedReason?.type === 'invalid-compose'
50 | ) {
51 | return false
52 | }
53 | }
54 |
55 | return true
56 | }
57 |
58 | export const useEnvironment = (
59 | owner: string,
60 | repo: string,
61 | id: string
62 | ): UseHTTPRequest => {
63 | const [envs, refetch] = useEnvironmentsByRepo(owner, repo)
64 |
65 | return useMemo(
66 | () => [map(envs, (envs) => envs.find((e) => e.id === id) ?? null), refetch],
67 | [envs, refetch, id]
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/internal/api/registries/create.go:
--------------------------------------------------------------------------------
1 | package registries
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 |
8 | "github.com/ergomake/ergomake/internal/api/auth"
9 | "github.com/ergomake/ergomake/internal/logger"
10 | )
11 |
12 | type createRegistry struct {
13 | URL string `json:"url"`
14 | Provider string `json:"provider"`
15 | Credentials string `json:"credentials"`
16 | }
17 |
18 | func (rr *registriesRouter) create(c *gin.Context) {
19 | authData, ok := auth.GetAuthData(c)
20 | if !ok {
21 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
22 | return
23 | }
24 |
25 | owner := c.Param("owner")
26 | if owner == "" {
27 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
28 | return
29 | }
30 |
31 | var body createRegistry
32 | if err := c.ShouldBindJSON(&body); err != nil {
33 | c.JSON(http.StatusBadRequest, gin.H{"reason": "malformed-payload"})
34 | return
35 | }
36 |
37 | isAuthorized, err := auth.IsAuthorized(c, owner, authData)
38 | if err != nil {
39 | logger.Ctx(c).Err(err).Msg("fail to create registry")
40 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
41 | return
42 | }
43 |
44 | if !isAuthorized {
45 | c.JSON(http.StatusForbidden, http.StatusText(http.StatusForbidden))
46 | return
47 | }
48 |
49 | err = rr.privRegistryProvider.StoreRegistry(c, owner, body.URL, body.Provider, body.Credentials)
50 | if err != nil {
51 | logger.Ctx(c).Err(err).Msg("fail to create registry")
52 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
53 | return
54 | }
55 |
56 | c.JSON(http.StatusCreated, nil)
57 | }
58 |
--------------------------------------------------------------------------------
/front/src/components/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | import { PlusIcon } from '@heroicons/react/20/solid'
2 | import React from 'react'
3 |
4 | type EmptyStateProps = {
5 | title: string
6 | description: string
7 | action?: React.ReactNode
8 | onAction?: () => void
9 | // Look away
10 | icon: any
11 | }
12 |
13 | const EmptyState = ({
14 | title,
15 | description,
16 | action,
17 | onAction,
18 | icon,
19 | }: EmptyStateProps) => {
20 | const Icon = icon
21 |
22 | const actionComponent =
23 | action && onAction ? (
24 |
25 |
32 |
33 | ) : null
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 | {title}
43 |
44 |
{description}
45 |
46 | {actionComponent}
47 |
48 |
49 | )
50 | }
51 |
52 | export default EmptyState
53 |
--------------------------------------------------------------------------------
/front/src/components/WebsitePath.tsx:
--------------------------------------------------------------------------------
1 | import { HomeIcon } from '@heroicons/react/20/solid'
2 |
3 | export type Pages = { name: string; href: string }
4 |
5 | type WebsitePathProps = {
6 | pages: Pages[]
7 | }
8 |
9 | const WebsitePath = ({ pages }: WebsitePathProps) => {
10 | return (
11 |
47 | )
48 | }
49 |
50 | export default WebsitePath
51 |
--------------------------------------------------------------------------------
/front/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "my-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@headlessui/react": "^1.7.14",
7 | "@heroicons/react": "^2.0.18",
8 | "@tailwindcss/forms": "^0.5.3",
9 | "@tailwindcss/typography": "^0.5.9",
10 | "@testing-library/jest-dom": "^5.16.5",
11 | "@testing-library/react": "^13.4.0",
12 | "@testing-library/user-event": "^13.5.0",
13 | "@types/jest": "^27.5.2",
14 | "@types/node": "^16.18.32",
15 | "@types/react": "^18.2.6",
16 | "@types/react-dom": "^18.2.4",
17 | "ansi-to-html": "^0.7.2",
18 | "classnames": "^2.3.2",
19 | "date-fns": "^2.30.0",
20 | "ramda": "^0.29.0",
21 | "react": "^18.2.0",
22 | "react-copy-to-clipboard": "^5.1.0",
23 | "react-dom": "^18.2.0",
24 | "react-markdown": "^8.0.7",
25 | "react-router-dom": "^6.11.2",
26 | "react-scripts": "5.0.1",
27 | "typescript": "^4.9.5",
28 | "use-http": "^1.0.28",
29 | "web-vitals": "^2.1.4"
30 | },
31 | "scripts": {
32 | "start": "react-scripts start",
33 | "build": "react-scripts build",
34 | "test": "react-scripts test",
35 | "eject": "react-scripts eject"
36 | },
37 | "eslintConfig": {
38 | "extends": [
39 | "react-app",
40 | "react-app/jest"
41 | ]
42 | },
43 | "browserslist": {
44 | "production": [
45 | ">0.2%",
46 | "not dead",
47 | "not op_mini all"
48 | ],
49 | "development": [
50 | "last 1 chrome version",
51 | "last 1 firefox version",
52 | "last 1 safari version"
53 | ]
54 | },
55 | "devDependencies": {
56 | "@trivago/prettier-plugin-sort-imports": "^4.1.1",
57 | "@types/ramda": "^0.29.2",
58 | "@types/react-copy-to-clipboard": "^5.0.4",
59 | "prettier": "^2.8.8",
60 | "tailwindcss": "^3.3.2"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/internal/privregistry/ecr.go:
--------------------------------------------------------------------------------
1 | package privregistry
2 |
3 | import (
4 | "encoding/base64"
5 | "strings"
6 |
7 | "github.com/aws/aws-sdk-go/aws"
8 | "github.com/aws/aws-sdk-go/aws/credentials"
9 | "github.com/aws/aws-sdk-go/aws/session"
10 | "github.com/aws/aws-sdk-go/service/ecr"
11 | "github.com/pkg/errors"
12 | )
13 |
14 | func GetECRToken(registry string, accessKeyID string, secretAccessKey string, region string) (string, error) {
15 | registryURLParts := strings.Split(registry, ".")
16 | if len(registryURLParts) < 1 {
17 | return "", errors.Errorf("fail to extract registryID from registry %s", registry)
18 | }
19 | registryID := registryURLParts[0]
20 |
21 | sess, err := session.NewSession(&aws.Config{
22 | Region: aws.String(region),
23 | Credentials: credentials.NewStaticCredentials(accessKeyID, secretAccessKey, ""),
24 | })
25 | if err != nil {
26 | return "", errors.Wrap(err, "fail to create AWS session")
27 | }
28 |
29 | ecrClient := ecr.New(sess)
30 | input := &ecr.GetAuthorizationTokenInput{
31 | RegistryIds: []*string{
32 | aws.String(registryID),
33 | },
34 | }
35 |
36 | result, err := ecrClient.GetAuthorizationToken(input)
37 | if err != nil {
38 | return "", errors.Wrap(err, "fail to get ECR authorization token")
39 | }
40 |
41 | if len(result.AuthorizationData) == 0 {
42 | return "", errors.New("returned authorization data array is empty")
43 | }
44 |
45 | authorizationData := result.AuthorizationData[0]
46 | if authorizationData.AuthorizationToken == nil {
47 | return "", errors.New("returned authorization data token is nil")
48 | }
49 |
50 | token := *authorizationData.AuthorizationToken
51 | decodedToken, err := base64.StdEncoding.DecodeString(token)
52 | if err != nil {
53 | return "", errors.Wrap(err, "fail to decode authorization token")
54 | }
55 |
56 | return string(decodedToken), nil
57 | }
58 |
--------------------------------------------------------------------------------
/internal/api/github/marketplace.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/google/go-github/v52/github"
9 |
10 | "github.com/ergomake/ergomake/internal/ginutils"
11 | "github.com/ergomake/ergomake/internal/logger"
12 | )
13 |
14 | func (r *githubRouter) marketplaceWebhook(c *gin.Context) {
15 | log := logger.Ctx(c.Request.Context())
16 |
17 | var bodyBytes []byte
18 | err := c.ShouldBindBodyWith(&bodyBytes, ginutils.BYTES)
19 | if err != nil {
20 | log.Err(err).Msg("fail to read body")
21 | c.JSON(
22 | http.StatusInternalServerError,
23 | http.StatusText(http.StatusInternalServerError),
24 | )
25 | return
26 | }
27 |
28 | payload, err := github.ValidatePayloadFromBody(
29 | c.ContentType(),
30 | bytes.NewReader(bodyBytes),
31 | c.GetHeader("X-Hub-Signature-256"),
32 | []byte(r.webhookSecret),
33 | )
34 | if err != nil {
35 | c.JSON(
36 | http.StatusUnauthorized,
37 | http.StatusText(http.StatusUnauthorized),
38 | )
39 | return
40 | }
41 |
42 | event, err := github.ParseWebHook(github.WebHookType(c.Request), payload)
43 | if err != nil {
44 | c.JSON(
45 | http.StatusBadRequest,
46 | http.StatusText(http.StatusBadRequest),
47 | )
48 | return
49 | }
50 |
51 | switch event := event.(type) {
52 | case *github.MarketplacePurchaseEvent:
53 | action := event.GetAction()
54 | installationTarget := event.MarketplacePurchase.Account.GetLogin()
55 |
56 | logCtx := logger.With(log).
57 | Str("action", action).
58 | Str("installation_target", installationTarget).
59 | Str("installation_originator", event.Sender.GetLogin()).
60 | Logger()
61 | log = &logCtx
62 |
63 | log.Info().Msg("got a marketplace event")
64 |
65 | err := r.db.SaveEvent(installationTarget, action)
66 | if err != nil {
67 | log.Err(err).Msg("fail to save marketplace event in database")
68 | }
69 | }
70 |
71 | c.Status(http.StatusNoContent)
72 | }
73 |
--------------------------------------------------------------------------------
/mocks/cluster/Starter.go:
--------------------------------------------------------------------------------
1 | // Code generated by mockery v2.26.1. DO NOT EDIT.
2 |
3 | package mocks
4 |
5 | import mock "github.com/stretchr/testify/mock"
6 |
7 | // Starter is an autogenerated mock type for the Starter type
8 | type Starter struct {
9 | mock.Mock
10 | }
11 |
12 | type Starter_Expecter struct {
13 | mock *mock.Mock
14 | }
15 |
16 | func (_m *Starter) EXPECT() *Starter_Expecter {
17 | return &Starter_Expecter{mock: &_m.Mock}
18 | }
19 |
20 | // Start provides a mock function with given fields: stopCh
21 | func (_m *Starter) Start(stopCh <-chan struct{}) {
22 | _m.Called(stopCh)
23 | }
24 |
25 | // Starter_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
26 | type Starter_Start_Call struct {
27 | *mock.Call
28 | }
29 |
30 | // Start is a helper method to define mock.On call
31 | // - stopCh <-chan struct{}
32 | func (_e *Starter_Expecter) Start(stopCh interface{}) *Starter_Start_Call {
33 | return &Starter_Start_Call{Call: _e.mock.On("Start", stopCh)}
34 | }
35 |
36 | func (_c *Starter_Start_Call) Run(run func(stopCh <-chan struct{})) *Starter_Start_Call {
37 | _c.Call.Run(func(args mock.Arguments) {
38 | run(args[0].(<-chan struct{}))
39 | })
40 | return _c
41 | }
42 |
43 | func (_c *Starter_Start_Call) Return() *Starter_Start_Call {
44 | _c.Call.Return()
45 | return _c
46 | }
47 |
48 | func (_c *Starter_Start_Call) RunAndReturn(run func(<-chan struct{})) *Starter_Start_Call {
49 | _c.Call.Return(run)
50 | return _c
51 | }
52 |
53 | type mockConstructorTestingTNewStarter interface {
54 | mock.TestingT
55 | Cleanup(func())
56 | }
57 |
58 | // NewStarter creates a new instance of Starter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
59 | func NewStarter(t mockConstructorTestingTNewStarter) *Starter {
60 | mock := &Starter{}
61 | mock.Mock.Test(t)
62 |
63 | t.Cleanup(func() { mock.AssertExpectations(t) })
64 |
65 | return mock
66 | }
67 |
--------------------------------------------------------------------------------
/internal/elastic/elastic.go:
--------------------------------------------------------------------------------
1 | package elastic
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 |
8 | "github.com/elastic/go-elasticsearch/v8"
9 | "github.com/pkg/errors"
10 | )
11 |
12 | type ElasticSearch interface {
13 | Search(ctx context.Context, query any, result any) error
14 | }
15 |
16 | type client struct {
17 | *elasticsearch.Client
18 | }
19 |
20 | func NewElasticSearch(addr string, username string, password string) (ElasticSearch, error) {
21 | cfg := elasticsearch.Config{
22 | Addresses: []string{addr},
23 | Username: username,
24 | Password: password,
25 | }
26 |
27 | es, err := elasticsearch.NewClient(cfg)
28 | if err != nil {
29 | return nil, err
30 | }
31 |
32 | return &client{es}, nil
33 | }
34 |
35 | func (client *client) Search(ctx context.Context, query any, result any) error {
36 | var queryBuf bytes.Buffer
37 | if err := json.NewEncoder(&queryBuf).Encode(query); err != nil {
38 | return errors.Wrap(err, "failed to json encode elasticsearch query")
39 | }
40 |
41 | res, err := client.Client.Search(
42 | client.Client.Search.WithContext(ctx),
43 | client.Client.Search.WithIndex(".ds-filebeat*"),
44 | client.Client.Search.WithBody(&queryBuf),
45 | client.Client.Search.WithTrackTotalHits(true),
46 | )
47 | if err != nil {
48 | return errors.Wrap(err, "failed to query elasticsearch")
49 | }
50 | defer res.Body.Close()
51 |
52 | if res.IsError() {
53 | var e map[string]interface{}
54 | if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
55 | return errors.Wrap(err, "failed to parse elasticsearch error response body")
56 | }
57 |
58 | err := errors.Errorf("[%s] %s: %s",
59 | res.Status(),
60 | e["error"].(map[string]interface{})["type"],
61 | e["error"].(map[string]interface{})["reason"],
62 | )
63 |
64 | return errors.Wrap(err, "failed to query elasticsearch")
65 | }
66 |
67 | if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
68 | return errors.Wrap(err, "failed to parse elasticsearch response body")
69 | }
70 |
71 | return nil
72 | }
73 |
--------------------------------------------------------------------------------
/front/src/components/PermanentBranchesInput.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo, useState } from 'react'
2 |
3 | import TableInput from '../components/TableInput'
4 | import { isLoading, isSuccess } from '../hooks/useHTTPRequest'
5 | import { usePermanentBranches } from '../hooks/usePermanentBranches'
6 |
7 | const labels = ['Name']
8 | const placeholders = ['main']
9 |
10 | const branchNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/
11 |
12 | interface Props {
13 | owner: string
14 | repo: string
15 | }
16 | function PermanentBranchesInput({ owner, repo }: Props) {
17 | const [res, onUpdate] = usePermanentBranches(owner, repo)
18 |
19 | const [branches, setBranches] = useState([])
20 | useEffect(() => {
21 | if (isSuccess(res) && !res.refreshing) {
22 | setBranches(res.body.map((r) => r.name))
23 | }
24 | }, [res])
25 |
26 | const values = useMemo(() => branches.map((b) => [b]), [branches])
27 |
28 | const onAdd = useCallback(
29 | ([branch]: string[]) => {
30 | if (!branch) {
31 | return false
32 | }
33 |
34 | if (!branchNameRegex.test(branch)) {
35 | return false
36 | }
37 |
38 | setBranches((branches) => [branch].concat(branches))
39 | return true
40 | },
41 | [setBranches]
42 | )
43 | const onRemove = useCallback(
44 | (i: number) => {
45 | setBranches((branches) => [
46 | ...branches.slice(0, i),
47 | ...branches.slice(i + 1),
48 | ])
49 | },
50 | [setBranches]
51 | )
52 | const onSave = useCallback(() => {
53 | onUpdate({ branches })
54 | }, [branches, onUpdate])
55 |
56 | return (
57 |
68 | )
69 | }
70 |
71 | export default PermanentBranchesInput
72 |
--------------------------------------------------------------------------------
/front/src/hooks/useLogs.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo, useState } from 'react'
2 |
3 | interface LogEntry {
4 | timestamp: string
5 | serviceId: string
6 | message: string
7 | }
8 |
9 | export interface LogData {
10 | [serviceId: string]: LogEntry[]
11 | }
12 |
13 | function useLogs(
14 | envId: string,
15 | kind: 'live' | 'build'
16 | ): [LogData, Error | null, () => void] {
17 | const [logData, setLogData] = useState({})
18 | const [error, setError] = useState(null)
19 | const [eventSource, setEventSource] = useState(null)
20 |
21 | useEffect(() => {
22 | if (!eventSource) {
23 | return
24 | }
25 |
26 | if (eventSource.url.includes(envId)) {
27 | return
28 | }
29 |
30 | eventSource.close()
31 | setEventSource(null)
32 | }, [envId, eventSource])
33 |
34 | useEffect(() => {
35 | if (!eventSource) {
36 | setEventSource(
37 | new EventSource(
38 | `${process.env.REACT_APP_ERGOMAKE_API}/v2/environments/${envId}/logs/${kind}`,
39 | { withCredentials: true }
40 | )
41 | )
42 | return
43 | }
44 |
45 | eventSource.addEventListener('log', (event: MessageEvent) => {
46 | const logEntry: LogEntry = JSON.parse(event.data)
47 | setLogData((prevLogData) => {
48 | const serviceId = logEntry.serviceId
49 | const updatedLogs = [...(prevLogData[serviceId] || []), logEntry]
50 |
51 | return { ...prevLogData, [serviceId]: updatedLogs }
52 | })
53 | })
54 |
55 | eventSource.addEventListener('error', (event: MessageEvent) => {
56 | setError(new Error(event.data))
57 | eventSource?.close()
58 | })
59 | }, [envId, eventSource, kind])
60 |
61 | const retry = useCallback(() => {
62 | eventSource?.close()
63 | setLogData({})
64 | setError(null)
65 | setEventSource(null)
66 | }, [eventSource, setLogData, setError, setEventSource])
67 |
68 | return useMemo(() => [logData, error, retry], [logData, error, retry])
69 | }
70 |
71 | export default useLogs
72 |
--------------------------------------------------------------------------------
/e2e/v2/health_test.go:
--------------------------------------------------------------------------------
1 | package v2
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "github.com/gavv/httpexpect/v2"
9 | "github.com/stretchr/testify/require"
10 |
11 | "github.com/ergomake/ergomake/e2e/testutils"
12 | "github.com/ergomake/ergomake/internal/api"
13 | "github.com/ergomake/ergomake/internal/cluster"
14 | environmentsMocks "github.com/ergomake/ergomake/mocks/environments"
15 | envvarsMocks "github.com/ergomake/ergomake/mocks/envvars"
16 | ghAppMocks "github.com/ergomake/ergomake/mocks/github/ghapp"
17 | ghlauncherMocks "github.com/ergomake/ergomake/mocks/github/ghlauncher"
18 | paymentMocks "github.com/ergomake/ergomake/mocks/payment"
19 | permanentbranchesMocks "github.com/ergomake/ergomake/mocks/permanentbranches"
20 | privregistryMocks "github.com/ergomake/ergomake/mocks/privregistry"
21 | servicelogsMocks "github.com/ergomake/ergomake/mocks/servicelogs"
22 | usersMocks "github.com/ergomake/ergomake/mocks/users"
23 | )
24 |
25 | func TestV2Health(t *testing.T) {
26 | testCases := []struct {
27 | name string
28 | status int
29 | }{
30 | {
31 | name: "returns ok",
32 | status: http.StatusOK,
33 | },
34 | }
35 |
36 | for _, tc := range testCases {
37 | t.Run(tc.name, func(t *testing.T) {
38 | clusterClient, err := cluster.NewK8sClient()
39 | require.NoError(t, err)
40 |
41 | db := testutils.CreateRandomDB(t)
42 |
43 | ghApp := ghAppMocks.NewGHAppClient(t)
44 | apiServer := api.NewServer(
45 | ghlauncherMocks.NewGHLauncher(t),
46 | privregistryMocks.NewPrivRegistryProvider(t),
47 | db,
48 | servicelogsMocks.NewLogStreamer(t),
49 | ghApp,
50 | clusterClient,
51 | envvarsMocks.NewEnvVarsProvider(t),
52 | environmentsMocks.NewEnvironmentsProvider(t),
53 | usersMocks.NewService(t),
54 | paymentMocks.NewPaymentProvider(t),
55 | permanentbranchesMocks.NewPermanentBranchesProvider(t),
56 | &api.Config{},
57 | )
58 | server := httptest.NewServer(apiServer)
59 |
60 | e := httpexpect.Default(t, server.URL)
61 | e.GET("/v2/health").Expect().Status(tc.status)
62 | })
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/internal/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "context"
5 | "os"
6 |
7 | "github.com/gin-contrib/requestid"
8 | "github.com/gin-gonic/gin"
9 | "github.com/rs/zerolog"
10 | "github.com/rs/zerolog/log"
11 | "github.com/rs/zerolog/pkgerrors"
12 | )
13 |
14 | var logger zerolog.Logger
15 |
16 | func Setup() *zerolog.Logger {
17 | strLevel := os.Getenv("LOG_LEVEL")
18 | logLevel, err := zerolog.ParseLevel(strLevel)
19 | if err != nil {
20 | logLevel = zerolog.InfoLevel
21 | }
22 |
23 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
24 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
25 | logger = zerolog.New(os.Stdout).
26 | Level(logLevel).
27 | With().
28 | Timestamp().
29 | Logger()
30 |
31 | if err != nil {
32 | logger.Err(err).Str("givenLevel", strLevel).Msg("fail to parse LOG_LEVEL, defaulted to info")
33 | }
34 |
35 | return &logger
36 | }
37 |
38 | func Ctx(ctx context.Context) *zerolog.Logger {
39 | ctxLogger := log.Ctx(ctx)
40 |
41 | // if logger not present in context, log.Ctx returns a disabled log
42 | if ctxLogger.GetLevel() == zerolog.Disabled {
43 | return Get()
44 | }
45 |
46 | return ctxLogger
47 | }
48 |
49 | func Get() *zerolog.Logger {
50 | return &logger
51 | }
52 |
53 | func Middleware(engine *gin.Engine) {
54 | engine.Use(
55 | requestid.New(),
56 | func(c *gin.Context) {
57 | l := *Get()
58 | l = l.With().
59 | Str("request_id", requestid.Get(c)).
60 | Logger()
61 |
62 | c.Request = c.Request.WithContext(l.WithContext(c.Request.Context()))
63 |
64 | req := c.Request
65 | res := c.Writer
66 |
67 | logger := logger.With().
68 | Str("method", req.Method).
69 | Str("path", req.URL.Path).
70 | Str("ip", c.ClientIP()).
71 | Logger()
72 |
73 | // Log request
74 | logger.Info().Msg("Received request")
75 |
76 | // Serve request
77 | c.Next()
78 |
79 | // Log response
80 | logger.Info().
81 | Int("status", res.Status()).
82 | Msg("Sent response")
83 | },
84 | )
85 | }
86 |
87 | func With(log *zerolog.Logger) zerolog.Context {
88 | l := *log
89 | return l.With()
90 | }
91 |
--------------------------------------------------------------------------------
/front/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom'
2 |
3 | import { RequireAuth, RequireNoAuth } from './components/RequireAuth'
4 | import { AuthErrorProvider } from './hooks/useAuthError'
5 | import { ThemeProvider } from './hooks/useTheme'
6 | import Environment from './pages/Environment'
7 | import Environments from './pages/Environments'
8 | import Login from './pages/Login'
9 | import NoInstallation from './pages/NoInstallation'
10 | import Projects from './pages/Projects'
11 | import PublicEnvironment from './pages/PublicEnvironment'
12 | import Purchase from './pages/Purchase'
13 |
14 | const router = createBrowserRouter([
15 | {
16 | path: '/login',
17 | element: (
18 |
19 |
20 |
21 | ),
22 | },
23 | {
24 | path: '/environments/:env',
25 | element: ,
26 | },
27 | {
28 | path: '/gh',
29 | element: (
30 |
31 | {(profile) => }
32 |
33 | ),
34 | },
35 | {
36 | path: '/gh/:owner',
37 | element: (
38 | {(profile) => }
39 | ),
40 | },
41 | {
42 | path: '/gh/:owner/repos/:repo',
43 | element: (
44 |
45 | {(profile) => }
46 |
47 | ),
48 | },
49 | {
50 | path: '/gh/:owner/repos/:repo/envs/:env',
51 | element: (
52 |
53 | {(profile) => }
54 |
55 | ),
56 | },
57 | {
58 | path: '/gh/:owner/purchase',
59 | element: (
60 | {(profile) => }
61 | ),
62 | },
63 | {
64 | path: '*',
65 | element: ,
66 | },
67 | ])
68 |
69 | function App() {
70 | return (
71 |
72 |
73 |
74 |
75 |
76 | )
77 | }
78 |
79 | export default App
80 |
--------------------------------------------------------------------------------
/internal/api/stripe/webhook.go:
--------------------------------------------------------------------------------
1 | package stripe
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "net/http"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/stripe/stripe-go/v74"
10 | "github.com/stripe/stripe-go/v74/webhook"
11 |
12 | "github.com/ergomake/ergomake/internal/logger"
13 | )
14 |
15 | func (stp *stripeRouter) webhook(c *gin.Context) {
16 | const MaxBodyBytes = int64(65536)
17 | c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxBodyBytes)
18 | body, err := ioutil.ReadAll(c.Request.Body)
19 | if err != nil {
20 | logger.Ctx(c).Err(err).Msg("error reading request body")
21 | c.JSON(http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable))
22 | return
23 | }
24 |
25 | event, err := webhook.ConstructEvent(body, c.GetHeader("Stripe-Signature"), stp.webhookSecret)
26 | if err != nil {
27 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
28 | return
29 | }
30 |
31 | log := logger.With(logger.Ctx(c)).Str("type", event.Type).Logger()
32 |
33 | if event.Type != "checkout.session.completed" {
34 | log.Info().Msg("stripe webhook ignored")
35 | c.JSON(http.StatusOK, http.StatusText(http.StatusOK))
36 | return
37 | }
38 |
39 | var checkoutSession stripe.CheckoutSession
40 | err = json.Unmarshal(event.Data.Raw, &checkoutSession)
41 | if err != nil {
42 | log.Err(err).Msg("fail to unmarshall checkout session")
43 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
44 | return
45 | }
46 |
47 | if checkoutSession.Mode != stripe.CheckoutSessionModeSubscription {
48 | log.Info().Str("mode", string(checkoutSession.Mode)).Msg("got a unexpected checkout session mode")
49 | c.JSON(http.StatusOK, http.StatusText(http.StatusOK))
50 | return
51 | }
52 |
53 | err = stp.paymentProvider.SaveSubscription(
54 | c,
55 | checkoutSession.ClientReferenceID,
56 | checkoutSession.Subscription.ID,
57 | )
58 |
59 | if err != nil {
60 | log.Err(err).Msg("fail to save subscription to database")
61 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
62 | return
63 | }
64 |
65 | c.JSON(http.StatusOK, http.StatusText(http.StatusOK))
66 | }
67 |
--------------------------------------------------------------------------------
/internal/servicelogs/query.go:
--------------------------------------------------------------------------------
1 | package servicelogs
2 |
3 | import "time"
4 |
5 | func getLogsQuery(serviceID string, namespace string, container string, offset int64) map[string]interface{} {
6 | return map[string]interface{}{
7 | "query": map[string]interface{}{
8 | "bool": map[string]interface{}{
9 | "filter": []interface{}{
10 | map[string]interface{}{
11 | "term": map[string]interface{}{
12 | "kubernetes.labels.preview_ergomake_dev/id": serviceID,
13 | },
14 | },
15 | map[string]interface{}{
16 | "term": map[string]interface{}{
17 | "kubernetes.namespace": namespace,
18 | },
19 | },
20 | map[string]interface{}{
21 | "term": map[string]interface{}{
22 | "container.id": container,
23 | },
24 | },
25 | map[string]interface{}{
26 | "range": map[string]interface{}{
27 | "log.offset": map[string]interface{}{"gt": offset},
28 | },
29 | },
30 | },
31 | },
32 | },
33 | "sort": []map[string]interface{}{{
34 | "log.offset": map[string]interface{}{
35 | "order": "asc",
36 | },
37 | }},
38 | "size": 100,
39 | }
40 | }
41 |
42 | func getNextContainerQuery(serviceID string, namespace string, timestamp *time.Time) map[string]interface{} {
43 | filter := []interface{}{
44 | map[string]interface{}{
45 | "term": map[string]interface{}{
46 | "kubernetes.labels.preview_ergomake_dev/id": serviceID,
47 | },
48 | },
49 | map[string]interface{}{
50 | "term": map[string]interface{}{
51 | "kubernetes.namespace": namespace,
52 | },
53 | },
54 | }
55 |
56 | if timestamp != nil {
57 | filter = append(filter, map[string]interface{}{
58 | "range": map[string]interface{}{
59 | "@timestamp": map[string]interface{}{"gt": timestamp},
60 | },
61 | })
62 | }
63 |
64 | return map[string]interface{}{
65 | "query": map[string]interface{}{
66 | "bool": map[string]interface{}{
67 | "filter": filter,
68 | },
69 | },
70 | "sort": []map[string]interface{}{{
71 | "@timestamp": map[string]interface{}{
72 | "order": "asc",
73 | },
74 | }},
75 | "_source": []string{"container.id", "kubernetes.container.name"},
76 | "size": 1,
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/internal/api/github/routes.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 |
6 | "github.com/ergomake/ergomake/internal/cluster"
7 | "github.com/ergomake/ergomake/internal/database"
8 | "github.com/ergomake/ergomake/internal/environments"
9 | "github.com/ergomake/ergomake/internal/envvars"
10 | "github.com/ergomake/ergomake/internal/github/ghapp"
11 | "github.com/ergomake/ergomake/internal/github/ghlauncher"
12 | "github.com/ergomake/ergomake/internal/payment"
13 | "github.com/ergomake/ergomake/internal/privregistry"
14 | )
15 |
16 | type githubRouter struct {
17 | ghLauncher ghlauncher.GHLauncher
18 | db *database.DB
19 | ghApp ghapp.GHAppClient
20 | clusterClient cluster.Client
21 | envVarsProvider envvars.EnvVarsProvider
22 | privRegistryProvider privregistry.PrivRegistryProvider
23 | environmentsProvider environments.EnvironmentsProvider
24 | paymentProvider payment.PaymentProvider
25 | webhookSecret string
26 | frontendURL string
27 | dockerhubPullSecretName string
28 | }
29 |
30 | func NewGithubRouter(
31 | ghLauncher ghlauncher.GHLauncher,
32 | db *database.DB,
33 | ghApp ghapp.GHAppClient,
34 | clusterClient cluster.Client,
35 | envVarsProvider envvars.EnvVarsProvider,
36 | privRegistryProvider privregistry.PrivRegistryProvider,
37 | environmentsProvider environments.EnvironmentsProvider,
38 | paymentProvider payment.PaymentProvider,
39 | webhookSecret string,
40 | frontendURL string,
41 | dockerhubPullSecretName string,
42 | ) *githubRouter {
43 | return &githubRouter{
44 | ghLauncher,
45 | db,
46 | ghApp,
47 | clusterClient,
48 | envVarsProvider,
49 | privRegistryProvider,
50 | environmentsProvider,
51 | paymentProvider,
52 | webhookSecret,
53 | frontendURL,
54 | dockerhubPullSecretName,
55 | }
56 | }
57 |
58 | func (ghr *githubRouter) AddRoutes(router *gin.RouterGroup) {
59 | router.POST("/webhook", ghr.webhook)
60 | router.POST("/marketplace/webhook", ghr.marketplaceWebhook)
61 | router.GET("/user/organizations", ghr.listUserOrganizations)
62 | router.GET("/owner/:owner/repos", ghr.listReposForOwner)
63 | router.POST("/owner/:owner/repos/:repo/configure", ghr.configureRepo)
64 | }
65 |
--------------------------------------------------------------------------------
/front/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/front/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | height: 100vh;
7 | }
8 |
9 | body {
10 | margin: 0;
11 | height: 100vh;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | #root {
17 | height: 100vh;
18 | }
19 |
20 | code {
21 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
22 | monospace;
23 | }
24 |
25 | :root {
26 | --tw-color-primary-50: 240 249 244;
27 | --tw-color-primary-100: 218 241 227;
28 | --tw-color-primary-200: 184 226 203;
29 | --tw-color-primary-300: 137 204 170;
30 | --tw-color-primary-400: 88 175 134;
31 | --tw-color-primary-500: 59 159 116;
32 | --tw-color-primary-600: 38 117 85;
33 | --tw-color-primary-700: 30 94 70;
34 | --tw-color-primary-800: 26 75 56;
35 | --tw-color-primary-900: 22 62 47;
36 | --tw-color-primary-1000: 6 15 11;
37 | --color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0f9ff */
38 | --color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0f2fe */
39 | --color-primary-200: rgb(var(--tw-color-primary-200)); /* #bae6fd */
40 | --color-primary-300: rgb(var(--tw-color-primary-300)); /* #7dd3fc */
41 | --color-primary-400: rgb(var(--tw-color-primary-400)); /* #38bdf8 */
42 | --color-primary-500: rgb(var(--tw-color-primary-500)); /* #0ea5e9 */
43 | --color-primary-600: rgb(var(--tw-color-primary-600)); /* #0284c7 */
44 | --color-primary-700: rgb(var(--tw-color-primary-700)); /* #0369a1 */
45 | --color-primary-800: rgb(var(--tw-color-primary-800)); /* #075985 */
46 | --color-primary-900: rgb(var(--tw-color-primary-900)); /* #0c4a6e */
47 | --color-primary-1000: rgb(var(--tw-color-primary-1000)); /* #060f0b */
48 | }
49 |
50 | @font-face {
51 | font-family: 'Inter';
52 | font-style: normal;
53 | font-weight: 100 900;
54 | src: url('../public/fonts/inter-var-latin.woff2') format('woff2');
55 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
56 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
57 | U+FEFF, U+FFFD;
58 | }
59 |
60 | @font-face {
61 | font-family: 'Ubuntu';
62 | font-style: normal;
63 | font-weight: 300 400 700;
64 | src: url('../public/fonts/Ubuntu-Regular.ttf') format('truetype');
65 | }
66 |
--------------------------------------------------------------------------------
/internal/api/github/push.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "context"
5 | "strings"
6 |
7 | "github.com/google/go-github/v52/github"
8 | "github.com/pkg/errors"
9 |
10 | "github.com/ergomake/ergomake/internal/environments"
11 | "github.com/ergomake/ergomake/internal/github/ghlauncher"
12 | "github.com/ergomake/ergomake/internal/logger"
13 | )
14 |
15 | func (r *githubRouter) handlePushEvent(githubDelivery string, event *github.PushEvent) {
16 | owner := event.GetRepo().GetOwner().GetLogin()
17 | repo := event.GetRepo()
18 | repoName := repo.GetName()
19 | branch := strings.TrimPrefix(event.GetRef(), "refs/heads/")
20 | sha := event.GetAfter()
21 | author := event.GetSender().GetLogin()
22 |
23 | logCtx := logger.With(logger.Get()).
24 | Str("githubDelivery", githubDelivery).
25 | Str("owner", owner).
26 | Str("repo", repoName).
27 | Str("author", author).
28 | Str("branch", branch).
29 | Str("SHA", sha).
30 | Str("event", "push").
31 | Logger()
32 | log := &logCtx
33 | ctx := log.WithContext(context.Background())
34 |
35 | if _, blocked := ownersBlockList[owner]; blocked {
36 | log.Warn().Msg("event ignored because owner is in block list")
37 | return
38 | }
39 |
40 | log.Info().Msg("got a push event from github")
41 |
42 | shouldDeploy, err := r.environmentsProvider.ShouldDeploy(ctx, owner, repoName, branch)
43 | if err != nil {
44 | log.Err(errors.Wrap(err, "fail to check if branch should be deployed")).Msg("fail to handle push event")
45 | return
46 | }
47 |
48 | if !shouldDeploy {
49 | return
50 | }
51 |
52 | terminateEnv := environments.TerminateEnvironmentRequest{
53 | Owner: owner,
54 | Repo: repoName,
55 | Branch: branch,
56 | PrNumber: nil,
57 | }
58 | err = r.environmentsProvider.TerminateEnvironment(ctx, terminateEnv)
59 | if err != nil {
60 | log.Err(err).Msg("fail to terminate environment")
61 | }
62 |
63 | launchEnv := ghlauncher.LaunchEnvironmentRequest{
64 | Owner: owner,
65 | BranchOwner: owner,
66 | Repo: repoName,
67 | Branch: branch,
68 | SHA: sha,
69 | PrNumber: nil,
70 | Author: author,
71 | IsPrivate: repo.GetPrivate(),
72 | }
73 |
74 | err = r.launchEnvironment(ctx, launchEnv)
75 | if err != nil {
76 | log.Err(err).Msg("fail to launch environment")
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/front/src/hooks/useTheme.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | useCallback,
4 | useContext,
5 | useEffect,
6 | useMemo,
7 | useState,
8 | } from 'react'
9 |
10 | type Theme = 'light' | 'dark'
11 | type UseTheme = [Theme, boolean, () => void]
12 |
13 | const loadTheme = (): Theme => {
14 | const lTheme = localStorage.getItem('theme')
15 | if (lTheme === 'light') {
16 | return 'light'
17 | }
18 |
19 | if (lTheme === 'dark') {
20 | return 'dark'
21 | }
22 |
23 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
24 | if (prefersDark) {
25 | return 'dark'
26 | }
27 |
28 | return 'light'
29 | }
30 |
31 | const saveTheme = (theme: Theme) => {
32 | localStorage.setItem('theme', theme)
33 | }
34 |
35 | export const ThemeContext = createContext([
36 | loadTheme(),
37 | false,
38 | () => {},
39 | ])
40 |
41 | export const useThemeProvider = (): UseTheme => {
42 | const [theme, setTheme] = useState(loadTheme())
43 | const [animating, setAnimating] = useState(false)
44 |
45 | useEffect(() => {
46 | switch (theme) {
47 | case 'dark':
48 | document.documentElement.classList.add('dark')
49 | saveTheme('dark')
50 | break
51 | case 'light':
52 | document.documentElement.classList.remove('dark')
53 | saveTheme('light')
54 | break
55 | }
56 | }, [theme])
57 |
58 | const toggle = useCallback(() => {
59 | if (animating) {
60 | return
61 | }
62 |
63 | setAnimating(true)
64 | setTimeout(() => {
65 | const newTheme = theme === 'dark' ? 'light' : 'dark'
66 | setTheme(newTheme)
67 | }, 500)
68 | }, [theme, animating, setAnimating, setTheme])
69 |
70 | useEffect(() => {
71 | if (animating) {
72 | const timer = setTimeout(() => {
73 | setAnimating(false)
74 | }, 1000)
75 |
76 | return () => {
77 | clearTimeout(timer)
78 | }
79 | }
80 | }, [animating])
81 |
82 | return useMemo(() => [theme, animating, toggle], [theme, animating, toggle])
83 | }
84 |
85 | export const useTheme = () => useContext(ThemeContext)
86 |
87 | export function ThemeProvider({ children }: { children: React.ReactNode }) {
88 | const themeValue = useThemeProvider()
89 |
90 | return (
91 | {children}
92 | )
93 | }
94 |
--------------------------------------------------------------------------------
/mocks/users/Service.go:
--------------------------------------------------------------------------------
1 | // Code generated by mockery v2.26.1. DO NOT EDIT.
2 |
3 | package mocks
4 |
5 | import (
6 | context "context"
7 |
8 | users "github.com/ergomake/ergomake/internal/users"
9 | mock "github.com/stretchr/testify/mock"
10 | )
11 |
12 | // Service is an autogenerated mock type for the Service type
13 | type Service struct {
14 | mock.Mock
15 | }
16 |
17 | type Service_Expecter struct {
18 | mock *mock.Mock
19 | }
20 |
21 | func (_m *Service) EXPECT() *Service_Expecter {
22 | return &Service_Expecter{mock: &_m.Mock}
23 | }
24 |
25 | // Save provides a mock function with given fields: ctx, user
26 | func (_m *Service) Save(ctx context.Context, user users.User) error {
27 | ret := _m.Called(ctx, user)
28 |
29 | var r0 error
30 | if rf, ok := ret.Get(0).(func(context.Context, users.User) error); ok {
31 | r0 = rf(ctx, user)
32 | } else {
33 | r0 = ret.Error(0)
34 | }
35 |
36 | return r0
37 | }
38 |
39 | // Service_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
40 | type Service_Save_Call struct {
41 | *mock.Call
42 | }
43 |
44 | // Save is a helper method to define mock.On call
45 | // - ctx context.Context
46 | // - user users.User
47 | func (_e *Service_Expecter) Save(ctx interface{}, user interface{}) *Service_Save_Call {
48 | return &Service_Save_Call{Call: _e.mock.On("Save", ctx, user)}
49 | }
50 |
51 | func (_c *Service_Save_Call) Run(run func(ctx context.Context, user users.User)) *Service_Save_Call {
52 | _c.Call.Run(func(args mock.Arguments) {
53 | run(args[0].(context.Context), args[1].(users.User))
54 | })
55 | return _c
56 | }
57 |
58 | func (_c *Service_Save_Call) Return(_a0 error) *Service_Save_Call {
59 | _c.Call.Return(_a0)
60 | return _c
61 | }
62 |
63 | func (_c *Service_Save_Call) RunAndReturn(run func(context.Context, users.User) error) *Service_Save_Call {
64 | _c.Call.Return(run)
65 | return _c
66 | }
67 |
68 | type mockConstructorTestingTNewService interface {
69 | mock.TestingT
70 | Cleanup(func())
71 | }
72 |
73 | // NewService creates a new instance of Service. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
74 | func NewService(t mockConstructorTestingTNewService) *Service {
75 | mock := &Service{}
76 | mock.Mock.Test(t)
77 |
78 | t.Cleanup(func() { mock.AssertExpectations(t) })
79 |
80 | return mock
81 | }
82 |
--------------------------------------------------------------------------------
/internal/api/environments/list.go:
--------------------------------------------------------------------------------
1 | package environments
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/google/go-github/v52/github"
8 |
9 | "github.com/ergomake/ergomake/internal/api/auth"
10 | "github.com/ergomake/ergomake/internal/database"
11 | "github.com/ergomake/ergomake/internal/logger"
12 | )
13 |
14 | func (er *environmentsRouter) list(c *gin.Context) {
15 | authData, ok := auth.GetAuthData(c)
16 | if !ok {
17 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
18 | return
19 | }
20 |
21 | owner := c.Query("owner")
22 | repo := c.Query("repo")
23 | if owner == "" || repo == "" {
24 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
25 | return
26 | }
27 |
28 | isAuthorized, err := auth.IsAuthorized(c, owner, authData)
29 | if err != nil {
30 | logger.Ctx(c).Err(err).Msg("fail to create registry")
31 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
32 | return
33 | }
34 |
35 | if !isAuthorized {
36 | c.JSON(http.StatusForbidden, http.StatusText(http.StatusForbidden))
37 | return
38 | }
39 |
40 | ownerEnvs, err := er.db.FindEnvironmentsByOwner(owner, database.FindEnvironmentsOptions{})
41 | if err != nil {
42 | logger.Ctx(c).Err(err).Msgf("fail to find environments for owner %s", owner)
43 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
44 | return
45 | }
46 |
47 | envs := make([]gin.H, 0)
48 | for _, env := range ownerEnvs {
49 | if env.Repo != repo {
50 | continue
51 | }
52 |
53 | source := "branch"
54 | if env.PullRequest.Valid {
55 | source = "pr"
56 | }
57 |
58 | var branch *string
59 | if env.Branch.Valid {
60 | branch = github.String(env.Branch.String)
61 | }
62 |
63 | services := make([]gin.H, len(env.Services))
64 | for i, service := range env.Services {
65 | services[i] = gin.H{
66 | "id": service.ID,
67 | "name": service.Name,
68 | "url": service.Url,
69 | "build": service.Build,
70 | }
71 | }
72 |
73 | envs = append(envs, gin.H{
74 | "id": env.ID,
75 | "branch": branch,
76 | "source": source,
77 | "status": env.Status,
78 | "createdAt": env.CreatedAt,
79 | "services": services,
80 | "degradedReason": env.DegradedReason,
81 | })
82 | }
83 |
84 | c.JSON(http.StatusOK, envs)
85 | }
86 |
--------------------------------------------------------------------------------
/internal/cluster/deploy.go:
--------------------------------------------------------------------------------
1 | package cluster
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/pkg/errors"
7 | appsv1 "k8s.io/api/apps/v1"
8 | corev1 "k8s.io/api/core/v1"
9 | networkingv1 "k8s.io/api/networking/v1"
10 | "k8s.io/apimachinery/pkg/runtime"
11 | )
12 |
13 | type ClusterEnv struct {
14 | Namespace string
15 | Objects []runtime.Object
16 | }
17 |
18 | func Deploy(ctx context.Context, client Client, env *ClusterEnv) (err error) {
19 | err = client.CreateNamespace(ctx, env.Namespace)
20 |
21 | if err != nil {
22 | return errors.Wrapf(err, "fail to create namespace %s", env.Namespace)
23 | }
24 |
25 | // this works like a rollback, it deletes the namespace when error
26 | defer func() {
27 | if err == nil {
28 | return
29 | }
30 |
31 | // TODO: what if we fail to delete the namespace?
32 | client.DeleteNamespace(ctx, env.Namespace)
33 | }()
34 |
35 | for _, obj := range env.Objects {
36 | switch obj := obj.(type) {
37 |
38 | case *corev1.Secret:
39 | err = client.CreateSecret(ctx, obj)
40 | if err != nil {
41 | return errors.Wrapf(err, "fail to create %s secret", obj.Name)
42 | }
43 | case *corev1.Service:
44 | err = client.CreateService(ctx, obj)
45 | if err != nil {
46 | return errors.Wrapf(err, "fail to create %s service", obj.Name)
47 | }
48 | case *appsv1.Deployment:
49 | err = client.CreateDeployment(ctx, obj)
50 | if err != nil {
51 | return errors.Wrapf(err, "fail to create %s deployment", obj.Name)
52 | }
53 | case *corev1.ConfigMap:
54 | err = client.CreateConfigMap(ctx, obj)
55 | if err != nil {
56 | return errors.Wrapf(err, "fail to create %s configmap", obj.Name)
57 | }
58 | case *networkingv1.Ingress:
59 | err = client.CreateIngress(ctx, obj)
60 | if err != nil {
61 | return errors.Wrapf(err, "fail to create %s ingress", obj.Name)
62 | }
63 |
64 | case *networkingv1.NetworkPolicy:
65 | // TODO: this is being ignored because the default policy disallows ingress from outside of the cluster
66 | // but, we probably should have a network policy
67 | //
68 | // _, err := clientset.NetworkingV1().NetworkPolicies(obj.GetNamespace()).Create(ctx, obj, metav1.CreateOptions{})
69 | // if err != nil {
70 | // return err
71 | // }
72 | continue
73 | // Add cases for other object types here
74 | default:
75 | return errors.Errorf("unknown object type: %T", obj)
76 | }
77 | }
78 |
79 | return nil
80 | }
81 |
--------------------------------------------------------------------------------
/front/src/pages/Login.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useLocation } from 'react-router-dom'
3 |
4 | import GitHubLogo from '../components/GitHubLogo'
5 | import Logo from '../components/Logo'
6 |
7 | const Link = ({ href, children }: { href: string; children: string }) => {
8 | return (
9 |
13 | {children}
14 |
15 | )
16 | }
17 |
18 | const Login = () => {
19 | const location = useLocation()
20 | const params = new URLSearchParams(location.search)
21 | const redirectUrl =
22 | params.get('redirectUrl') ??
23 | `${window.location.protocol}//${window.location.host}`
24 | const loginUrl = `${process.env.REACT_APP_ERGOMAKE_API}/v2/auth/login?redirectUrl=${redirectUrl}`
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
38 |
39 | Sign in with GitHub
40 |
41 |
42 | By proceeding, you agree to the{' '}
43 |
44 | Terms of Service
45 | {' '}
46 | and acknowledge you've read the{' '}
47 |
48 | Privacy Policy
49 |
50 | .
51 |
52 |
53 |
54 |
55 |
56 | )
57 | }
58 |
59 | export default Login
60 |
--------------------------------------------------------------------------------
/internal/api/github/pullrequest.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/google/go-github/v52/github"
7 |
8 | "github.com/ergomake/ergomake/internal/environments"
9 | "github.com/ergomake/ergomake/internal/github/ghlauncher"
10 | "github.com/ergomake/ergomake/internal/logger"
11 | )
12 |
13 | func (r *githubRouter) handlePullRequestEvent(githubDelivery string, event *github.PullRequestEvent) {
14 | action := event.GetAction()
15 |
16 | owner := event.GetRepo().GetOwner().GetLogin()
17 | repo := event.GetRepo()
18 | repoName := repo.GetName()
19 | branchOwner := event.GetPullRequest().GetHead().GetRepo().GetOwner().GetLogin()
20 | branch := event.GetPullRequest().GetHead().GetRef()
21 | sha := event.GetPullRequest().GetHead().GetSHA()
22 | prNumber := event.GetPullRequest().GetNumber()
23 | author := event.GetSender().GetLogin()
24 |
25 | logCtx := logger.With(logger.Get()).
26 | Str("githubDelivery", githubDelivery).
27 | Str("action", action).
28 | Str("owner", owner).
29 | Str("repo", repoName).
30 | Int("prNumber", prNumber).
31 | Str("author", author).
32 | Str("branch", branch).
33 | Str("SHA", sha).
34 | Logger()
35 | log := &logCtx
36 | ctx := log.WithContext(context.Background())
37 |
38 | if _, blocked := ownersBlockList[owner]; blocked {
39 | log.Warn().Msg("event ignored because owner is in block list")
40 | return
41 | }
42 |
43 | terminateEnv := environments.TerminateEnvironmentRequest{
44 | Owner: owner,
45 | Repo: repoName,
46 | Branch: branch,
47 | PrNumber: github.Int(prNumber),
48 | }
49 |
50 | log.Info().Msg("got a pull request event from github")
51 | switch action {
52 | case "opened", "reopened", "synchronize":
53 | err := r.terminateEnvironment(ctx, terminateEnv)
54 | if err != nil {
55 | log.Err(err).Msg("fail to terminate environment")
56 | }
57 |
58 | launchEnv := ghlauncher.LaunchEnvironmentRequest{
59 | Owner: owner,
60 | BranchOwner: branchOwner,
61 | Repo: repoName,
62 | Branch: branch,
63 | SHA: sha,
64 | PrNumber: &prNumber,
65 | Author: author,
66 | IsPrivate: repo.GetPrivate(),
67 | }
68 |
69 | err = r.launchEnvironment(ctx, launchEnv)
70 | if err != nil {
71 | log.Err(err).Msg("fail to launch environment")
72 | }
73 | case "closed":
74 | err := r.terminateEnvironment(ctx, terminateEnv)
75 | if err != nil {
76 | log.Err(err).Msg("fail to terminate environment")
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/front/src/pages/NoInstallation.tsx:
--------------------------------------------------------------------------------
1 | import { PlusIcon, UserPlusIcon } from '@heroicons/react/20/solid'
2 | import { useMemo, useState } from 'react'
3 | import { Navigate } from 'react-router-dom'
4 |
5 | import Layout, { installationUrl } from '../components/Layout'
6 | import PermissionsModal from '../components/PermissionsModal'
7 | import { orElse } from '../hooks/useHTTPRequest'
8 | import { useOwners } from '../hooks/useOwners'
9 | import { Profile } from '../hooks/useProfile'
10 |
11 | interface NoInstallationProps {
12 | profile: Profile
13 | }
14 |
15 | const NoInstallation = ({ profile }: NoInstallationProps) => {
16 | const [isPermissionsModalOpen, setIsPermissionsModalOpen] = useState(false)
17 |
18 | const ownersRes = useOwners()
19 | const owners = useMemo(() => orElse(ownersRes, []), [ownersRes])
20 |
21 | if (owners.length > 0 && owners[0] !== undefined) {
22 | return
23 | }
24 |
25 | return (
26 |
27 | setIsPermissionsModalOpen(false)}
30 | installationUrl={installationUrl}
31 | />
32 |
33 |
34 |
35 |
36 |
37 |
38 | No organizations available.
39 |
40 |
41 | Allow access to an organization to get started.
42 |
43 |
44 |
45 |
52 |
53 |
54 |
55 |
56 | )
57 | }
58 |
59 | export default NoInstallation
60 |
--------------------------------------------------------------------------------
/cmd/cli/privregistries/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 |
8 | "github.com/pkg/errors"
9 |
10 | "github.com/ergomake/ergomake/internal/database"
11 | "github.com/ergomake/ergomake/internal/env"
12 | "github.com/ergomake/ergomake/internal/privregistry"
13 | )
14 |
15 | type config struct {
16 | DatabaseURL string `split_words:"true"`
17 | PrivRegistriesSecret string `split_words:"true"`
18 | }
19 |
20 | func main() {
21 | var cfg config
22 | err := env.LoadEnv(&cfg)
23 | if err != nil {
24 | panic(errors.Wrap(err, "fail to load environment variables"))
25 | }
26 |
27 | db, err := database.Connect(cfg.DatabaseURL)
28 | if err != nil {
29 | panic(errors.Wrap(err, "fail to connect to the database"))
30 | }
31 | defer db.Close()
32 |
33 | privRegistryProvider := privregistry.NewDBPrivRegistryProvider(db, cfg.PrivRegistriesSecret)
34 |
35 | if len(os.Args) < 2 {
36 | printUsage()
37 | os.Exit(1)
38 | }
39 |
40 | command := os.Args[1]
41 | args := os.Args[2:]
42 |
43 | switch command {
44 | case "fetch-creds":
45 | if len(args) != 2 {
46 | fmt.Println("Invalid number of arguments for 'fetch-creds' command")
47 | printUsage()
48 | os.Exit(1)
49 | }
50 | owner := args[0]
51 | image := args[1]
52 | creds, err := privRegistryProvider.FetchCreds(context.Background(), owner, image)
53 | if err != nil {
54 | if errors.Is(err, privregistry.ErrRegistryNotFound) {
55 | fmt.Println("Private registry not found")
56 | os.Exit(1)
57 | }
58 | panic(errors.Wrap(err, "fail to fetch credentials from private registry"))
59 | }
60 | fmt.Printf("Registry URL: %s\nToken: %s\n", creds.URL, creds.Token)
61 | case "store-registry":
62 | if len(args) != 4 {
63 | fmt.Println("Invalid number of arguments for 'store-registry' command")
64 | printUsage()
65 | os.Exit(1)
66 | }
67 | owner := args[0]
68 | url := args[1]
69 | provider := args[2]
70 | credentials := args[3]
71 | err := privRegistryProvider.StoreRegistry(context.Background(), owner, url, provider, credentials)
72 | if err != nil {
73 | panic(errors.Wrap(err, "fail to store private registry"))
74 | }
75 | fmt.Println("Private registry stored successfully")
76 | default:
77 | fmt.Println("Invalid command")
78 | printUsage()
79 | os.Exit(1)
80 | }
81 | }
82 |
83 | func printUsage() {
84 | fmt.Printf("Usage: %s [arguments]\n", os.Args[0])
85 | fmt.Println("Commands:")
86 | fmt.Println(" fetch-creds Fetch credentials from a private registry")
87 | fmt.Println(" store-registry Store a private registry")
88 | }
89 |
--------------------------------------------------------------------------------
/internal/api/variables/upsert.go:
--------------------------------------------------------------------------------
1 | package variables
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 |
7 | "github.com/gin-gonic/gin"
8 |
9 | "github.com/ergomake/ergomake/internal/api/auth"
10 | "github.com/ergomake/ergomake/internal/envvars"
11 | "github.com/ergomake/ergomake/internal/logger"
12 | )
13 |
14 | func (vr *variablesRouter) upsert(c *gin.Context) {
15 | authData, ok := auth.GetAuthData(c)
16 | if !ok {
17 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
18 | return
19 | }
20 |
21 | owner := c.Param("owner")
22 | repo := c.Param("repo")
23 | if owner == "" || repo == "" {
24 | c.JSON(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
25 | return
26 | }
27 |
28 | isAuthorized, err := auth.IsAuthorized(c, owner, authData)
29 | if err != nil {
30 | logger.Ctx(c).Err(err).Msg("fail to check for authorization")
31 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
32 | return
33 | }
34 |
35 | if !isAuthorized {
36 | c.JSON(http.StatusForbidden, http.StatusText(http.StatusForbidden))
37 | return
38 | }
39 |
40 | var body []envvars.EnvVar
41 | if err := c.ShouldBindJSON(&body); err != nil {
42 | c.JSON(http.StatusBadRequest, gin.H{"reason": "malformed-payload"})
43 | return
44 | }
45 |
46 | existingList, err := vr.envVarsProvider.ListByRepo(c, owner, repo)
47 | if err != nil {
48 | logger.Ctx(c).Err(err).Msgf("fail to list variables for repo %s/%s", owner, repo)
49 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
50 | return
51 | }
52 |
53 | toKeep := make(map[string]bool)
54 | for _, v := range body {
55 | err := vr.envVarsProvider.Upsert(c, owner, repo, v.Name, v.Value, v.Branch)
56 | if err != nil {
57 | logger.Ctx(c).Err(err).Msgf("fail to upsert variable %s", v.Name)
58 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
59 | return
60 | }
61 |
62 | branch := ""
63 | if v.Branch != nil {
64 | branch = *v.Branch
65 | }
66 |
67 | toKeep[fmt.Sprintf("%s/%s", v.Name, branch)] = true
68 | }
69 |
70 | for _, v := range existingList {
71 | branch := ""
72 | if v.Branch != nil {
73 | branch = *v.Branch
74 | }
75 |
76 | key := fmt.Sprintf("%s/%s", v.Name, branch)
77 | if !toKeep[key] {
78 | err := vr.envVarsProvider.Delete(c, owner, repo, v.Name, v.Branch)
79 | if err != nil {
80 | logger.Ctx(c).Err(err).Msgf("fail to delete variable %s", v.Name)
81 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
82 | return
83 | }
84 | }
85 | }
86 |
87 | c.JSON(http.StatusOK, body)
88 | }
89 |
--------------------------------------------------------------------------------
/internal/cluster/client.go:
--------------------------------------------------------------------------------
1 | package cluster
2 |
3 | import (
4 | "context"
5 |
6 | kpack "github.com/pivotal/kpack/pkg/apis/build/v1alpha2"
7 | "github.com/pkg/errors"
8 | appsv1 "k8s.io/api/apps/v1"
9 | batchv1 "k8s.io/api/batch/v1"
10 | corev1 "k8s.io/api/core/v1"
11 | networkingv1 "k8s.io/api/networking/v1"
12 | "k8s.io/apimachinery/pkg/runtime/schema"
13 | "k8s.io/client-go/tools/cache"
14 | )
15 |
16 | var ErrIngressNotFound = errors.New("ingress not found")
17 |
18 | type WaitJobsResult struct {
19 | Failed []*batchv1.Job
20 | Succeeded []*batchv1.Job
21 | }
22 |
23 | type Starter interface {
24 | Start(stopCh <-chan struct{})
25 | }
26 |
27 | type Client interface {
28 | CreateNamespace(ctx context.Context, namespace string) error
29 | DeleteNamespace(ctx context.Context, namespace string) error
30 | CreateService(ctx context.Context, service *corev1.Service) error
31 | CreateDeployment(ctx context.Context, deployment *appsv1.Deployment) error
32 | CreateConfigMap(ctx context.Context, configMap *corev1.ConfigMap) error
33 | CreateIngress(ctx context.Context, ingress *networkingv1.Ingress) error
34 | CreateJob(ctx context.Context, job *batchv1.Job) (*batchv1.Job, error)
35 | CreateSecret(ctx context.Context, secret *corev1.Secret) error
36 | CreateServiceAccount(ctx context.Context, svcAcc *corev1.ServiceAccount) error
37 | GetPreviewNamespaces(ctx context.Context) ([]corev1.Namespace, error)
38 | GetIngress(ctx context.Context, namespace, name string) (*networkingv1.Ingress, error)
39 | GetIngressUrl(ctx context.Context, namespace string, serviceName string, protocol string) (string, error)
40 | UpdateIngress(ctx context.Context, ingress *networkingv1.Ingress) error
41 | GetDeployment(ctx context.Context, namespace string, deploymentName string) (*appsv1.Deployment, error)
42 | ScaleDeployment(ctx context.Context, namespace string, deploymentName string, replicas int32) error
43 | WaitJobs(ctx context.Context, jobs []*batchv1.Job) (*WaitJobsResult, error)
44 | WaitDeployments(ctx context.Context, namespace string) error
45 | GetJobLogs(ctx context.Context, job *batchv1.Job, size int64) (string, error)
46 | ListJobs(ctx context.Context, namespace string) ([]*batchv1.Job, error)
47 | AreServicesAlive(ctx context.Context, namespace string) (bool, error)
48 | WatchServiceLogs(ctx context.Context, namespace, name string, sinceSeconds int64) (<-chan string, <-chan error, error)
49 | ApplyKPackBuilds(ctx context.Context, builds []*kpack.Build) error
50 | WatchResource(ctx context.Context, gvr schema.GroupVersionResource, handler cache.ResourceEventHandlerFuncs) (Starter, error)
51 | CopySecret(ctx context.Context, fromNS, toNS, name string) (*corev1.Secret, error)
52 | }
53 |
--------------------------------------------------------------------------------
/mocks/elastic/ElasticSearch.go:
--------------------------------------------------------------------------------
1 | // Code generated by mockery v2.26.1. DO NOT EDIT.
2 |
3 | package mocks
4 |
5 | import (
6 | context "context"
7 |
8 | mock "github.com/stretchr/testify/mock"
9 | )
10 |
11 | // ElasticSearch is an autogenerated mock type for the ElasticSearch type
12 | type ElasticSearch struct {
13 | mock.Mock
14 | }
15 |
16 | type ElasticSearch_Expecter struct {
17 | mock *mock.Mock
18 | }
19 |
20 | func (_m *ElasticSearch) EXPECT() *ElasticSearch_Expecter {
21 | return &ElasticSearch_Expecter{mock: &_m.Mock}
22 | }
23 |
24 | // Search provides a mock function with given fields: ctx, query, result
25 | func (_m *ElasticSearch) Search(ctx context.Context, query interface{}, result interface{}) error {
26 | ret := _m.Called(ctx, query, result)
27 |
28 | var r0 error
29 | if rf, ok := ret.Get(0).(func(context.Context, interface{}, interface{}) error); ok {
30 | r0 = rf(ctx, query, result)
31 | } else {
32 | r0 = ret.Error(0)
33 | }
34 |
35 | return r0
36 | }
37 |
38 | // ElasticSearch_Search_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Search'
39 | type ElasticSearch_Search_Call struct {
40 | *mock.Call
41 | }
42 |
43 | // Search is a helper method to define mock.On call
44 | // - ctx context.Context
45 | // - query interface{}
46 | // - result interface{}
47 | func (_e *ElasticSearch_Expecter) Search(ctx interface{}, query interface{}, result interface{}) *ElasticSearch_Search_Call {
48 | return &ElasticSearch_Search_Call{Call: _e.mock.On("Search", ctx, query, result)}
49 | }
50 |
51 | func (_c *ElasticSearch_Search_Call) Run(run func(ctx context.Context, query interface{}, result interface{})) *ElasticSearch_Search_Call {
52 | _c.Call.Run(func(args mock.Arguments) {
53 | run(args[0].(context.Context), args[1].(interface{}), args[2].(interface{}))
54 | })
55 | return _c
56 | }
57 |
58 | func (_c *ElasticSearch_Search_Call) Return(_a0 error) *ElasticSearch_Search_Call {
59 | _c.Call.Return(_a0)
60 | return _c
61 | }
62 |
63 | func (_c *ElasticSearch_Search_Call) RunAndReturn(run func(context.Context, interface{}, interface{}) error) *ElasticSearch_Search_Call {
64 | _c.Call.Return(run)
65 | return _c
66 | }
67 |
68 | type mockConstructorTestingTNewElasticSearch interface {
69 | mock.TestingT
70 | Cleanup(func())
71 | }
72 |
73 | // NewElasticSearch creates a new instance of ElasticSearch. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
74 | func NewElasticSearch(t mockConstructorTestingTNewElasticSearch) *ElasticSearch {
75 | mock := &ElasticSearch{}
76 | mock.Mock.Test(t)
77 |
78 | t.Cleanup(func() { mock.AssertExpectations(t) })
79 |
80 | return mock
81 | }
82 |
--------------------------------------------------------------------------------
/front/src/components/EnvLink.tsx:
--------------------------------------------------------------------------------
1 | import { DocumentDuplicateIcon } from '@heroicons/react/24/outline'
2 | import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/solid'
3 | import classNames from 'classnames'
4 | import { useEffect, useState } from 'react'
5 | import { CopyToClipboard } from 'react-copy-to-clipboard'
6 |
7 | type EnvLinkProps = { link: string }
8 |
9 | function EnvLink({ link }: EnvLinkProps) {
10 | const [isCopied, setIsCopied] = useState(false)
11 | useEffect(() => {
12 | if (isCopied) {
13 | setTimeout(() => {
14 | setIsCopied(false)
15 | }, 2000)
16 | }
17 | }, [isCopied])
18 |
19 | return (
20 |
21 |
{
24 | setIsCopied(true)
25 | }}
26 | >
27 |
49 |
50 |
56 |
60 |
61 |
62 | )
63 | }
64 |
65 | export default EnvLink
66 |
--------------------------------------------------------------------------------
/internal/github/ghlauncher/comments.go:
--------------------------------------------------------------------------------
1 | package ghlauncher
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/ergomake/ergomake/internal/transformer"
8 | )
9 |
10 | func createSuccessComment(env *transformer.Environment, frontendEnvLink string) string {
11 | return fmt.Sprintf(`Hi 👋
12 |
13 | Here's a preview environment 🚀
14 |
15 | %s
16 |
17 | # Environment Summary 📑
18 |
19 | | Container | Source | URL |
20 | | - | - | - |
21 | %s
22 |
23 | Here are your environment's [logs](%s).
24 |
25 | For questions or comments, [join Discord](https://discord.gg/daGzchUGDt).
26 |
27 | [Click here](https://github.com/apps/ergomake) to disable Ergomake.`,
28 | getMainServiceUrl(env),
29 | getServiceTable(env),
30 | frontendEnvLink,
31 | )
32 | }
33 |
34 | func getMainServiceUrl(env *transformer.Environment) string {
35 | return getServiceUrl(env.FirstService())
36 | }
37 |
38 | func createFailureComment(frontendLink string, validationError *transformer.ProjectValidationError) string {
39 | reason := fmt.Sprintf(
40 | `You can see your environment build logs [here](%s). Please double-check your `+"`docker-compose.yml`"+` file is valid.`,
41 | frontendLink,
42 | )
43 |
44 | if validationError != nil {
45 | reason = validationError.Message
46 | }
47 |
48 | return fmt.Sprintf(`Hi 👋
49 |
50 | We couldn't create a preview environment for this pull-request 😥
51 |
52 | %s
53 |
54 | If you need help, email us at contact@getergomake.com or join [Discord](https://discord.gg/daGzchUGDt).
55 |
56 | [Click here](https://github.com/apps/ergomake) to disable Ergomake.`, reason)
57 | }
58 |
59 | func createLimitedComment() string {
60 | return `Hi there 👋
61 |
62 | You’ve just reached your simultaneous environments limit.
63 |
64 | Please talk to us at contact@ergomake.dev to bump your limits.
65 |
66 | Alternatively, you can close a PR with an existing environment, and reopen this one to get a preview.
67 |
68 | Thanks for using Ergomake!
69 |
70 | [Click here](https://github.com/apps/ergomake) to disable Ergomake.`
71 | }
72 |
73 | func getServiceTable(env *transformer.Environment) string {
74 | rows := make([]string, len(env.Services))
75 | for serviceName, serviceConfig := range env.Services {
76 | rows[serviceConfig.Index] = fmt.Sprintf("| %s | %s | %s |", serviceName, getSource(serviceConfig), getServiceUrl(serviceConfig))
77 | }
78 | return strings.Join(rows, "\n")
79 | }
80 |
81 | func getServiceUrl(svc transformer.EnvironmentService) string {
82 | if svc.Url == "" {
83 | return "[not exposed - internal service]"
84 | }
85 |
86 | return fmt.Sprintf("https://%s", svc.Url)
87 | }
88 |
89 | func getSource(svc transformer.EnvironmentService) string {
90 | if svc.Build != "" {
91 | return "Dockerfile"
92 | }
93 |
94 | return svc.Image
95 | }
96 |
--------------------------------------------------------------------------------
/internal/transformer/builder_test.go:
--------------------------------------------------------------------------------
1 | package transformer
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestGitCompose_computeRepoAndBuildPath(t *testing.T) {
10 | testCases := []struct {
11 | name string
12 | projectPath string
13 | composePath string
14 | buildPath string
15 | defaultRepo string
16 | want []string
17 | }{
18 | {
19 | name: "BuildPathInsideProjectPath",
20 | projectPath: "/parent/path/myproject/",
21 | composePath: "/parent/path/myproject/docker-compose.yml",
22 | buildPath: ".",
23 | defaultRepo: "defaultRepo",
24 | want: []string{"defaultRepo", "."},
25 | },
26 | {
27 | name: "BuildPathInsideProjectPathDeepCompose",
28 | projectPath: "/parent/path/myproject/",
29 | composePath: "/parent/path/myproject/.ergomake/docker-compose.yml",
30 | buildPath: "..",
31 | defaultRepo: "defaultRepo",
32 | want: []string{"defaultRepo", ".."},
33 | },
34 | {
35 | name: "BuildPathOutsideProjectPath",
36 | projectPath: "/parent/path/myproject/",
37 | composePath: "/parent/path/myproject/docker-compose.yml",
38 | buildPath: "../otherproject",
39 | defaultRepo: "defaultRepo",
40 | want: []string{"otherproject", "."},
41 | },
42 | {
43 | name: "BuildPathOutsideProjectPathDeepCompose",
44 | projectPath: "/parent/path/myproject/",
45 | composePath: "/parent/path/myproject/.ergomake/docker-compose.yml",
46 | buildPath: "../../otherproject",
47 | defaultRepo: "defaultRepo",
48 | want: []string{"otherproject", "."},
49 | },
50 | {
51 | name: "BuildPathOutsideProjectDeepPath",
52 | projectPath: "/parent/path/myproject/",
53 | composePath: "/parent/path/myproject/docker-compose.yml",
54 | buildPath: "../otherproject/build",
55 | defaultRepo: "defaultRepo",
56 | want: []string{"otherproject", "build"},
57 | },
58 | {
59 | name: "BuildPathOutsideProjectDeepPathDeepCompose",
60 | projectPath: "/parent/path/myproject/",
61 | composePath: "/parent/path/myproject/.ergomake/docker-compose.yml",
62 | buildPath: "../../otherproject/build",
63 | defaultRepo: "defaultRepo",
64 | want: []string{"otherproject", "build"},
65 | },
66 | }
67 |
68 | for _, tc := range testCases {
69 | tc := tc // Capture range variable for parallel execution
70 | t.Run(tc.name, func(t *testing.T) {
71 | t.Parallel()
72 |
73 | c := &gitCompose{
74 | projectPath: tc.projectPath,
75 | configFilePath: tc.composePath,
76 | }
77 |
78 | repo, buildPath := c.computeRepoAndBuildPath(tc.buildPath, tc.defaultRepo)
79 | assert.Equal(t, tc.want[0], repo)
80 | assert.Equal(t, tc.want[1], buildPath)
81 | })
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/front/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 |
34 |
35 |
42 |
43 |
50 |
51 |
52 |
53 |
57 |
58 | Ergomake
59 |
60 |
61 |
62 |
63 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/internal/api/auth/callback.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "encoding/base64"
5 | "fmt"
6 | "math"
7 | "net/http"
8 | "net/url"
9 | "strconv"
10 | "time"
11 |
12 | "github.com/gin-gonic/gin"
13 | "github.com/golang-jwt/jwt"
14 | "github.com/pkg/errors"
15 | "golang.org/x/oauth2"
16 |
17 | "github.com/ergomake/ergomake/internal/github/ghapp"
18 | "github.com/ergomake/ergomake/internal/logger"
19 | )
20 |
21 | const AuthTokenCookieName = "auth_token"
22 |
23 | type AuthData struct {
24 | jwt.StandardClaims
25 | GithubToken *oauth2.Token `json:"githubToken"`
26 | }
27 |
28 | func (ar *authRouter) callback(c *gin.Context) {
29 | log := logger.Ctx(c)
30 |
31 | bytesRedirectURL, err := base64.URLEncoding.DecodeString(c.Query("state"))
32 | if err != nil {
33 | c.String(http.StatusBadRequest, "invalid state")
34 | return
35 | }
36 |
37 | strRedirectURL := string(bytesRedirectURL)
38 | if strRedirectURL == "" {
39 | strRedirectURL = ar.frontendURL
40 | }
41 |
42 | installationID, err := strconv.ParseInt(c.Query("installation_id"), 10, 0)
43 | if err == nil {
44 | inst, err := ar.ghApp.GetInstallation(c, installationID)
45 | if err != nil && !errors.Is(err, ghapp.InstallationNotFoundError) {
46 | logger.Ctx(c).Err(err).Msg("fail to get installation")
47 | c.JSON(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
48 | return
49 | }
50 |
51 | strRedirectURL = fmt.Sprintf("%s/gh/%s", ar.frontendURL, inst.GetAccount().GetLogin())
52 | }
53 |
54 | redirectURL, err := url.Parse(strRedirectURL)
55 | if err != nil {
56 | c.String(http.StatusBadRequest, "invalid redirectUrl")
57 | return
58 | }
59 |
60 | queryParams := url.Values{}
61 |
62 | code := c.Query("code")
63 | githubToken, err := ar.oauthConfig.Exchange(c, code)
64 | if err != nil {
65 | queryParams.Set("error", "exchange")
66 | redirectURL.RawQuery = queryParams.Encode()
67 | c.Redirect(http.StatusTemporaryRedirect, redirectURL.String())
68 | return
69 | }
70 |
71 | expTime := time.Now().Add(time.Hour * 24)
72 | data := AuthData{
73 | StandardClaims: jwt.StandardClaims{
74 | ExpiresAt: expTime.Unix(),
75 | },
76 | GithubToken: githubToken,
77 | }
78 |
79 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, data)
80 | tokenStr, err := token.SignedString([]byte(ar.jwtSecret))
81 | if err != nil {
82 | log.Err(err).Msg("fail to sign jwt token")
83 | queryParams.Set("error", "exchange")
84 | redirectURL.RawQuery = queryParams.Encode()
85 | c.Redirect(http.StatusTemporaryRedirect, redirectURL.String())
86 | return
87 | }
88 |
89 | httpsOnly := ar.secure
90 | maxAge := int(math.Floor(time.Until(expTime).Seconds()))
91 | c.SetCookie(AuthTokenCookieName, tokenStr, maxAge, "/", "", httpsOnly, true)
92 |
93 | c.Redirect(http.StatusTemporaryRedirect, redirectURL.String())
94 | }
95 |
--------------------------------------------------------------------------------
/mocks/github/ghlauncher/GHLauncher.go:
--------------------------------------------------------------------------------
1 | // Code generated by mockery v2.26.1. DO NOT EDIT.
2 |
3 | package mocks
4 |
5 | import (
6 | context "context"
7 |
8 | ghlauncher "github.com/ergomake/ergomake/internal/github/ghlauncher"
9 | mock "github.com/stretchr/testify/mock"
10 | )
11 |
12 | // GHLauncher is an autogenerated mock type for the GHLauncher type
13 | type GHLauncher struct {
14 | mock.Mock
15 | }
16 |
17 | type GHLauncher_Expecter struct {
18 | mock *mock.Mock
19 | }
20 |
21 | func (_m *GHLauncher) EXPECT() *GHLauncher_Expecter {
22 | return &GHLauncher_Expecter{mock: &_m.Mock}
23 | }
24 |
25 | // LaunchEnvironment provides a mock function with given fields: ctx, req
26 | func (_m *GHLauncher) LaunchEnvironment(ctx context.Context, req ghlauncher.LaunchEnvironmentRequest) error {
27 | ret := _m.Called(ctx, req)
28 |
29 | var r0 error
30 | if rf, ok := ret.Get(0).(func(context.Context, ghlauncher.LaunchEnvironmentRequest) error); ok {
31 | r0 = rf(ctx, req)
32 | } else {
33 | r0 = ret.Error(0)
34 | }
35 |
36 | return r0
37 | }
38 |
39 | // GHLauncher_LaunchEnvironment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LaunchEnvironment'
40 | type GHLauncher_LaunchEnvironment_Call struct {
41 | *mock.Call
42 | }
43 |
44 | // LaunchEnvironment is a helper method to define mock.On call
45 | // - ctx context.Context
46 | // - req ghlauncher.LaunchEnvironmentRequest
47 | func (_e *GHLauncher_Expecter) LaunchEnvironment(ctx interface{}, req interface{}) *GHLauncher_LaunchEnvironment_Call {
48 | return &GHLauncher_LaunchEnvironment_Call{Call: _e.mock.On("LaunchEnvironment", ctx, req)}
49 | }
50 |
51 | func (_c *GHLauncher_LaunchEnvironment_Call) Run(run func(ctx context.Context, req ghlauncher.LaunchEnvironmentRequest)) *GHLauncher_LaunchEnvironment_Call {
52 | _c.Call.Run(func(args mock.Arguments) {
53 | run(args[0].(context.Context), args[1].(ghlauncher.LaunchEnvironmentRequest))
54 | })
55 | return _c
56 | }
57 |
58 | func (_c *GHLauncher_LaunchEnvironment_Call) Return(_a0 error) *GHLauncher_LaunchEnvironment_Call {
59 | _c.Call.Return(_a0)
60 | return _c
61 | }
62 |
63 | func (_c *GHLauncher_LaunchEnvironment_Call) RunAndReturn(run func(context.Context, ghlauncher.LaunchEnvironmentRequest) error) *GHLauncher_LaunchEnvironment_Call {
64 | _c.Call.Return(run)
65 | return _c
66 | }
67 |
68 | type mockConstructorTestingTNewGHLauncher interface {
69 | mock.TestingT
70 | Cleanup(func())
71 | }
72 |
73 | // NewGHLauncher creates a new instance of GHLauncher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
74 | func NewGHLauncher(t mockConstructorTestingTNewGHLauncher) *GHLauncher {
75 | mock := &GHLauncher{}
76 | mock.Mock.Test(t)
77 |
78 | t.Cleanup(func() { mock.AssertExpectations(t) })
79 |
80 | return mock
81 | }
82 |
--------------------------------------------------------------------------------
/front/src/components/List.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronRightIcon } from '@heroicons/react/20/solid'
2 | import classNames from 'classnames'
3 | import { useCallback } from 'react'
4 | import { Link } from 'react-router-dom'
5 |
6 | type ListItem = {
7 | name: string
8 | statusBall?: React.ReactNode
9 | descriptionLeft: string
10 | descriptionRight: string
11 | chevron?: React.ReactNode
12 | url?: string
13 | onClick?: (d: T) => void
14 | data: T
15 | }
16 | function Item(props: ListItem) {
17 | const { onClick, data } = props
18 | const onClickHandler = useCallback(() => {
19 | onClick?.(data)
20 | }, [onClick, data])
21 |
22 | const item = (
23 | <>
24 |
25 |
26 | {props.statusBall}
27 |
28 |
29 | {props.name}
30 |
31 |
32 |
33 |
{props.descriptionLeft}
34 |
37 |
{props.descriptionRight}
38 |
39 |
40 | {props.chevron ?? (
41 |
45 | )}
46 | >
47 | )
48 |
49 | const className = classNames(
50 | 'relative w-full flex items-center space-x-4 py-4 px-8 border-b border-neutral-200/70 dark:border-neutral-800/70',
51 | {
52 | 'hover:bg-gray-100 dark:hover:bg-neutral-950/50 hover:cursor-pointer':
53 | props.url !== undefined,
54 | },
55 | { 'hover:cursor-not-allowed': props.url === undefined }
56 | )
57 |
58 | if (props.url) {
59 | return (
60 |
61 | {item}
62 |
63 | )
64 | }
65 |
66 | if (props.onClick) {
67 | return (
68 |
71 | )
72 | }
73 |
74 | return {item}
75 | }
76 |
77 | type ListProps = {
78 | items: ListItem[]
79 | emptyState?: React.ReactNode
80 | }
81 | function List({ items, emptyState }: ListProps) {
82 | if (items.length === 0 && emptyState) {
83 | return <>{emptyState}>
84 | }
85 |
86 | return (
87 |
88 | {items.map((item, i) => (
89 | -
90 |
91 |
92 | ))}
93 |
94 | )
95 | }
96 |
97 | export default List
98 |
--------------------------------------------------------------------------------
/migrations/011_add_timezone_to_timestamps.sql:
--------------------------------------------------------------------------------
1 | -- +migrate Up
2 | ALTER TABLE environments
3 | ALTER COLUMN created_at TYPE timestamp with time zone,
4 | ALTER COLUMN updated_at TYPE timestamp with time zone,
5 | ALTER COLUMN deleted_at TYPE timestamp with time zone;
6 |
7 | ALTER TABLE env_vars
8 | ALTER COLUMN created_at TYPE timestamp with time zone,
9 | ALTER COLUMN updated_at TYPE timestamp with time zone;
10 |
11 | ALTER TABLE environment_limits
12 | ALTER COLUMN created_at TYPE timestamp with time zone,
13 | ALTER COLUMN updated_at TYPE timestamp with time zone,
14 | ALTER COLUMN deleted_at TYPE timestamp with time zone;
15 |
16 | ALTER TABLE marketplace_events
17 | ALTER COLUMN created_at TYPE timestamp with time zone,
18 | ALTER COLUMN updated_at TYPE timestamp with time zone,
19 | ALTER COLUMN deleted_at TYPE timestamp with time zone;
20 |
21 | ALTER TABLE private_registries
22 | ALTER COLUMN created_at TYPE timestamp with time zone,
23 | ALTER COLUMN updated_at TYPE timestamp with time zone,
24 | ALTER COLUMN deleted_at TYPE timestamp with time zone;
25 |
26 | ALTER TABLE services
27 | ALTER COLUMN created_at TYPE timestamp with time zone,
28 | ALTER COLUMN updated_at TYPE timestamp with time zone,
29 | ALTER COLUMN deleted_at TYPE timestamp with time zone;
30 |
31 | ALTER TABLE users
32 | ALTER COLUMN created_at TYPE timestamp with time zone,
33 | ALTER COLUMN updated_at TYPE timestamp with time zone,
34 | ALTER COLUMN deleted_at TYPE timestamp with time zone;
35 |
36 | -- +migrate Down
37 | ALTER TABLE environments
38 | ALTER COLUMN created_at TYPE timestamp without time zone,
39 | ALTER COLUMN updated_at TYPE timestamp without time zone,
40 | ALTER COLUMN deleted_at TYPE timestamp without time zone;
41 |
42 | ALTER TABLE env_vars
43 | ALTER COLUMN created_at TYPE timestamp without time zone,
44 | ALTER COLUMN updated_at TYPE timestamp without time zone,
45 | ALTER COLUMN deleted_at TYPE timestamp without time zone;
46 |
47 | ALTER TABLE environment_limits
48 | ALTER COLUMN created_at TYPE timestamp without time zone,
49 | ALTER COLUMN updated_at TYPE timestamp without time zone,
50 | ALTER COLUMN deleted_at TYPE timestamp without time zone;
51 |
52 | ALTER TABLE marketplace_events
53 | ALTER COLUMN created_at TYPE timestamp without time zone,
54 | ALTER COLUMN updated_at TYPE timestamp without time zone,
55 | ALTER COLUMN deleted_at TYPE timestamp without time zone;
56 |
57 | ALTER TABLE private_registries
58 | ALTER COLUMN created_at TYPE timestamp without time zone,
59 | ALTER COLUMN updated_at TYPE timestamp without time zone,
60 | ALTER COLUMN deleted_at TYPE timestamp without time zone;
61 |
62 | ALTER TABLE services
63 | ALTER COLUMN created_at TYPE timestamp without time zone,
64 | ALTER COLUMN updated_at TYPE timestamp without time zone,
65 | ALTER COLUMN deleted_at TYPE timestamp without time zone;
66 |
67 | ALTER TABLE users
68 | ALTER COLUMN created_at TYPE timestamp without time zone,
69 | ALTER COLUMN updated_at TYPE timestamp without time zone,
70 | ALTER COLUMN deleted_at TYPE timestamp without time zone;
71 |
--------------------------------------------------------------------------------
/mocks/servicelogs/LogStreamer.go:
--------------------------------------------------------------------------------
1 | // Code generated by mockery v2.26.1. DO NOT EDIT.
2 |
3 | package mocks
4 |
5 | import (
6 | context "context"
7 |
8 | database "github.com/ergomake/ergomake/internal/database"
9 | mock "github.com/stretchr/testify/mock"
10 |
11 | servicelogs "github.com/ergomake/ergomake/internal/servicelogs"
12 | )
13 |
14 | // LogStreamer is an autogenerated mock type for the LogStreamer type
15 | type LogStreamer struct {
16 | mock.Mock
17 | }
18 |
19 | type LogStreamer_Expecter struct {
20 | mock *mock.Mock
21 | }
22 |
23 | func (_m *LogStreamer) EXPECT() *LogStreamer_Expecter {
24 | return &LogStreamer_Expecter{mock: &_m.Mock}
25 | }
26 |
27 | // Stream provides a mock function with given fields: ctx, services, namespace, allowedContainers, logChan, errChan
28 | func (_m *LogStreamer) Stream(ctx context.Context, services []database.Service, namespace string, allowedContainers []string, logChan chan<- []servicelogs.LogEntry, errChan chan<- error) {
29 | _m.Called(ctx, services, namespace, allowedContainers, logChan, errChan)
30 | }
31 |
32 | // LogStreamer_Stream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stream'
33 | type LogStreamer_Stream_Call struct {
34 | *mock.Call
35 | }
36 |
37 | // Stream is a helper method to define mock.On call
38 | // - ctx context.Context
39 | // - services []database.Service
40 | // - namespace string
41 | // - allowedContainers []string
42 | // - logChan chan<- []servicelogs.LogEntry
43 | // - errChan chan<- error
44 | func (_e *LogStreamer_Expecter) Stream(ctx interface{}, services interface{}, namespace interface{}, allowedContainers interface{}, logChan interface{}, errChan interface{}) *LogStreamer_Stream_Call {
45 | return &LogStreamer_Stream_Call{Call: _e.mock.On("Stream", ctx, services, namespace, allowedContainers, logChan, errChan)}
46 | }
47 |
48 | func (_c *LogStreamer_Stream_Call) Run(run func(ctx context.Context, services []database.Service, namespace string, allowedContainers []string, logChan chan<- []servicelogs.LogEntry, errChan chan<- error)) *LogStreamer_Stream_Call {
49 | _c.Call.Run(func(args mock.Arguments) {
50 | run(args[0].(context.Context), args[1].([]database.Service), args[2].(string), args[3].([]string), args[4].(chan<- []servicelogs.LogEntry), args[5].(chan<- error))
51 | })
52 | return _c
53 | }
54 |
55 | func (_c *LogStreamer_Stream_Call) Return() *LogStreamer_Stream_Call {
56 | _c.Call.Return()
57 | return _c
58 | }
59 |
60 | func (_c *LogStreamer_Stream_Call) RunAndReturn(run func(context.Context, []database.Service, string, []string, chan<- []servicelogs.LogEntry, chan<- error)) *LogStreamer_Stream_Call {
61 | _c.Call.Return(run)
62 | return _c
63 | }
64 |
65 | type mockConstructorTestingTNewLogStreamer interface {
66 | mock.TestingT
67 | Cleanup(func())
68 | }
69 |
70 | // NewLogStreamer creates a new instance of LogStreamer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
71 | func NewLogStreamer(t mockConstructorTestingTNewLogStreamer) *LogStreamer {
72 | mock := &LogStreamer{}
73 | mock.Mock.Test(t)
74 |
75 | t.Cleanup(func() { mock.AssertExpectations(t) })
76 |
77 | return mock
78 | }
79 |
--------------------------------------------------------------------------------
/e2e/testutils/todoapp.go:
--------------------------------------------------------------------------------
1 | package testutils
2 |
3 | import (
4 | "fmt"
5 |
6 | playwright "github.com/playwright-community/playwright-go"
7 | )
8 |
9 | type Browser struct {
10 | page playwright.Page
11 | }
12 |
13 | func NewBrowser() (*Browser, error) {
14 | pw, err := playwright.Run()
15 | if err != nil {
16 | return nil, err
17 | }
18 |
19 | chromium, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
20 | Headless: playwright.Bool(false),
21 | })
22 | if err != nil {
23 | return nil, err
24 | }
25 |
26 | page, err := chromium.NewPage()
27 | if err != nil {
28 | return nil, err
29 | }
30 |
31 | b := &Browser{page: page}
32 | return b, nil
33 | }
34 |
35 | func (b *Browser) LoadURL(url string) (int, error) {
36 | r, err := b.page.Goto(url)
37 | if err != nil {
38 | return 0, err
39 | }
40 |
41 | _, err = b.page.WaitForSelector("body")
42 | if err != nil {
43 | return 0, err
44 | }
45 |
46 | return r.Status(), nil
47 | }
48 |
49 | func (b *Browser) clickButton(buttonText string) error {
50 | buttonSelector := "//button[contains(.,'" + buttonText + "')]"
51 |
52 | err := b.page.Click(buttonSelector)
53 | if err != nil {
54 | return err
55 | }
56 |
57 | return nil
58 | }
59 |
60 | func (b *Browser) fillInput(inputName string, inputValue string) error {
61 | selector := fmt.Sprintf("input[name='%s'], textarea[name='%s']", inputName, inputName)
62 |
63 | el, err := b.page.QuerySelector(selector)
64 | if err != nil {
65 | return err
66 | }
67 |
68 | if err := el.Focus(); err != nil {
69 | return err
70 | }
71 |
72 | if err := el.Type(inputValue); err != nil {
73 | return err
74 | }
75 |
76 | return nil
77 | }
78 |
79 | func (b *Browser) AddTodo(todoName string) error {
80 | if err := b.clickButton("Add Todo"); err != nil {
81 | return err
82 | }
83 |
84 | if err := b.fillInput("title", todoName); err != nil {
85 | return err
86 | }
87 |
88 | if err := b.fillInput("description", "my todo"); err != nil {
89 | return err
90 | }
91 |
92 | if err := b.fillInput("duedate", "01022023"); err != nil {
93 | return err
94 | }
95 |
96 | if err := b.clickButton("Save"); err != nil {
97 | return err
98 | }
99 |
100 | return nil
101 | }
102 |
103 | func (b *Browser) GetTodoItems() (int, error) {
104 | listSelector := "ul.list-group"
105 |
106 | _, err := b.page.WaitForSelector(listSelector)
107 | if err != nil {
108 | return 0, err
109 | }
110 |
111 | listChildren, err := b.page.QuerySelectorAll(listSelector + " > *")
112 | if err != nil {
113 | return 0, err
114 | }
115 |
116 | count := len(listChildren)
117 | return count, nil
118 | }
119 |
120 | func (b *Browser) Refresh() error {
121 | _, err := b.page.Reload()
122 | if err != nil {
123 | return err
124 | }
125 |
126 | _, err = b.page.WaitForSelector("body")
127 | if err != nil {
128 | return err
129 | }
130 |
131 | return nil
132 | }
133 |
134 | func (b *Browser) Close() error {
135 | return b.page.Close()
136 | }
137 |
138 | // Useful for debugging
139 | func (b *Browser) WaitSeconds(seconds int) error {
140 | ms := float64(seconds * 1000)
141 | b.page.WaitForTimeout(ms)
142 | return nil
143 | }
144 |
--------------------------------------------------------------------------------
/cmd/cli/envvars/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 |
8 | "github.com/pkg/errors"
9 | "k8s.io/utils/pointer"
10 |
11 | "github.com/ergomake/ergomake/internal/database"
12 | "github.com/ergomake/ergomake/internal/env"
13 | "github.com/ergomake/ergomake/internal/envvars"
14 | )
15 |
16 | type config struct {
17 | DatabaseURL string `split_words:"true"`
18 | EnvVarsSecret string `split_words:"true"`
19 | }
20 |
21 | func main() {
22 | var cfg config
23 | err := env.LoadEnv(&cfg)
24 | if err != nil {
25 | panic(errors.Wrap(err, "fail to load environment variables"))
26 | }
27 |
28 | db, err := database.Connect(cfg.DatabaseURL)
29 | if err != nil {
30 | panic(errors.Wrap(err, "fail to connect to the database"))
31 | }
32 | defer db.Close()
33 |
34 | envVarProvider := envvars.NewDBEnvVarProvider(db, cfg.EnvVarsSecret)
35 |
36 | if len(os.Args) < 2 {
37 | printUsage()
38 | os.Exit(1)
39 | }
40 |
41 | command := os.Args[1]
42 | args := os.Args[2:]
43 |
44 | switch command {
45 | case "upsert":
46 | if len(args) != 4 {
47 | fmt.Println("Invalid number of arguments for 'upsert' command")
48 | printUsage()
49 | os.Exit(1)
50 | }
51 | owner := args[0]
52 | repo := args[1]
53 | name := args[2]
54 | value := args[3]
55 | var branch *string
56 | if len(args) >= 4 {
57 | branch = pointer.String(args[4])
58 | }
59 | err := envVarProvider.Upsert(context.Background(), owner, repo, name, value, branch)
60 | if err != nil {
61 | panic(errors.Wrap(err, "fail to upsert environment variable"))
62 | }
63 | fmt.Println("Environment variable upserted successfully")
64 | case "delete":
65 | if len(args) != 3 {
66 | fmt.Println("Invalid number of arguments for 'delete' command")
67 | printUsage()
68 | os.Exit(1)
69 | }
70 | owner := args[0]
71 | repo := args[1]
72 | name := args[2]
73 | err := envVarProvider.Delete(context.Background(), owner, repo, name, nil)
74 | if err != nil {
75 | panic(errors.Wrap(err, "fail to delete environment variable"))
76 | }
77 | fmt.Println("Environment variable deleted successfully")
78 | case "list":
79 | if len(args) != 2 {
80 | fmt.Println("Invalid number of arguments for 'list' command")
81 | printUsage()
82 | os.Exit(1)
83 | }
84 | owner := args[0]
85 | repo := args[1]
86 | vars, err := envVarProvider.ListByRepo(context.Background(), owner, repo)
87 | if err != nil {
88 | panic(errors.Wrap(err, "fail to list environment variables"))
89 | }
90 | fmt.Println("Environment variables:")
91 | for _, v := range vars {
92 | fmt.Printf("%s: %s\n", v.Name, v.Value)
93 | }
94 | default:
95 | fmt.Println("Invalid command")
96 | printUsage()
97 | os.Exit(1)
98 | }
99 | }
100 |
101 | func printUsage() {
102 | fmt.Printf("Usage: %s [arguments]\n", os.Args[0])
103 | fmt.Println("Commands:")
104 | fmt.Println(" upsert Upsert an environment variable")
105 | fmt.Println(" delete Delete an environment variable")
106 | fmt.Println(" list List environment variables by repository")
107 | }
108 |
--------------------------------------------------------------------------------
/internal/api/github/user.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 |
8 | "github.com/ergomake/ergomake/internal/api/auth"
9 | "github.com/ergomake/ergomake/internal/github/ghoauth"
10 | "github.com/ergomake/ergomake/internal/logger"
11 | "github.com/ergomake/ergomake/internal/payment"
12 | )
13 |
14 | func (ghr *githubRouter) listUserOrganizations(c *gin.Context) {
15 | authData, ok := auth.GetAuthData(c)
16 | if !ok {
17 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
18 | return
19 | }
20 |
21 | client := ghoauth.FromToken(authData.GithubToken)
22 |
23 | user, res, err := client.GetUser(c)
24 | if err != nil {
25 | if res != nil && res.StatusCode == http.StatusUnauthorized {
26 | c.JSON(
27 | http.StatusUnauthorized,
28 | http.StatusText(http.StatusInternalServerError),
29 | )
30 | return
31 | }
32 |
33 | logger.Ctx(c).Err(err).
34 | Msg("fail to get authenticated user")
35 | c.JSON(
36 | http.StatusInternalServerError,
37 | http.StatusText(http.StatusInternalServerError),
38 | )
39 | return
40 | }
41 |
42 | result := []gin.H{}
43 | isUserInstalled, err := ghr.ghApp.IsOwnerInstalled(c, user.GetLogin())
44 | if err != nil {
45 | logger.Ctx(c).Err(err).
46 | Msg("fail to get check if user is installed")
47 | c.JSON(
48 | http.StatusInternalServerError,
49 | http.StatusText(http.StatusInternalServerError),
50 | )
51 | return
52 | }
53 |
54 | if isUserInstalled {
55 | paymentPlan, err := ghr.paymentProvider.GetOwnerPlan(c, user.GetLogin())
56 | if err != nil {
57 | logger.Ctx(c).Err(err).Str("user", user.GetLogin()).
58 | Msg("fail to get user payment plan")
59 | c.JSON(
60 | http.StatusInternalServerError,
61 | http.StatusText(http.StatusInternalServerError),
62 | )
63 | return
64 | }
65 |
66 | result = append(result, gin.H{
67 | "login": user.GetLogin(),
68 | "avatar": user.GetAvatarURL(),
69 | "isPaying": paymentPlan != payment.PaymentPlanFree,
70 | })
71 | }
72 |
73 | orgs, res, err := client.ListOrganizations(c)
74 | if err != nil {
75 | if res.StatusCode == http.StatusUnauthorized {
76 | c.JSON(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
77 | return
78 | }
79 |
80 | logger.Ctx(c).Err(err).
81 | Msg("fail to list organizations of authenticated user")
82 | c.JSON(
83 | http.StatusInternalServerError,
84 | http.StatusText(http.StatusInternalServerError),
85 | )
86 | return
87 | }
88 |
89 | for _, org := range orgs {
90 | paymentPlan, err := ghr.paymentProvider.GetOwnerPlan(c, org.GetLogin())
91 | if err != nil {
92 | logger.Ctx(c).Err(err).Str("owner", org.GetLogin()).
93 | Msg("fail to get owner payment plan")
94 | c.JSON(
95 | http.StatusInternalServerError,
96 | http.StatusText(http.StatusInternalServerError),
97 | )
98 | return
99 | }
100 |
101 | owner := gin.H{
102 | "login": org.GetLogin(),
103 | "avatar": org.GetAvatarURL(),
104 | "isPaying": paymentPlan != payment.PaymentPlanFree,
105 | }
106 | result = append(result, owner)
107 | }
108 |
109 | c.JSON(http.StatusOK, result)
110 | }
111 |
--------------------------------------------------------------------------------
/front/src/pages/Projects.tsx:
--------------------------------------------------------------------------------
1 | import { PlusIcon } from '@heroicons/react/20/solid'
2 | import { useCallback, useMemo, useState } from 'react'
3 | import { useParams } from 'react-router-dom'
4 |
5 | import ConfigureRepoModal from '../components/ConfigureRepoModal'
6 | import Layout, { installationUrl } from '../components/Layout'
7 | import RepositoryList from '../components/RepositoryList'
8 | import { orElse } from '../hooks/useHTTPRequest'
9 | import { useOwners } from '../hooks/useOwners'
10 | import { Profile } from '../hooks/useProfile'
11 | import { Repo } from '../hooks/useRepo'
12 | import { useReposByOwner } from '../hooks/useReposByOwner'
13 |
14 | interface Props {
15 | profile: Profile
16 | }
17 |
18 | const Projects = ({ profile }: Props) => {
19 | const [configuring, setConfiguring] = useState(null)
20 | const [configured, setConfigured] = useState>(new Set())
21 | const ownersRes = useOwners()
22 | const params = useParams<{ owner: string }>()
23 |
24 | const owners = useMemo(() => orElse(ownersRes, []), [ownersRes])
25 |
26 | const owner =
27 | owners.find((o) => o.login === params.owner)?.login ?? profile.username
28 |
29 | const pages = [
30 | { name: 'Repositories', href: `/gh/${owner}`, label: 'Projects' },
31 | ]
32 |
33 | const reposRes = useReposByOwner(params.owner ?? profile.username)
34 | const repos = useMemo(() => {
35 | const repos = orElse(reposRes, []).filter((r) => r.isInstalled)
36 |
37 | return repos
38 | .sort((a, b) => a.name.localeCompare(b.name))
39 | .map((r) => ({
40 | ...r,
41 | lastDeployedAt:
42 | r.lastDeployedAt ??
43 | (configured.has(r.name) ? new Date().toISOString() : null),
44 | }))
45 | }, [reposRes, configured])
46 |
47 | const onCloseConfiguring = useCallback(
48 | (success: boolean) => {
49 | if (configuring && success) {
50 | const repo = configuring.name
51 | setConfigured((c) => new Set([...Array.from(c), repo]))
52 | }
53 | setConfiguring(null)
54 | },
55 | [configuring, setConfiguring, setConfigured]
56 | )
57 |
58 | return (
59 |
60 |
61 |
74 |
75 |
76 |
77 | )
78 | }
79 |
80 | export default Projects
81 |
--------------------------------------------------------------------------------
/internal/environments/dbprovider_test.go:
--------------------------------------------------------------------------------
1 | package environments
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | "github.com/stretchr/testify/mock"
9 | "github.com/stretchr/testify/require"
10 |
11 | "github.com/ergomake/ergomake/e2e/testutils"
12 | "github.com/ergomake/ergomake/internal/database"
13 | "github.com/ergomake/ergomake/internal/payment"
14 | clusterMocks "github.com/ergomake/ergomake/mocks/cluster"
15 | paymentMocks "github.com/ergomake/ergomake/mocks/payment"
16 | permanentbranchesMocks "github.com/ergomake/ergomake/mocks/permanentbranches"
17 | )
18 |
19 | func TestDBEnvironmentsProvider_IsOwnerLimited(t *testing.T) {
20 | t.Parallel()
21 |
22 | setup := func(t *testing.T) *database.DB {
23 | db := testutils.CreateRandomDB(t)
24 |
25 | limitedEnv := database.Environment{
26 | Owner: "owner",
27 | Status: database.EnvLimited,
28 | }
29 | err := db.Save(&limitedEnv).Error
30 | require.NoError(t, err)
31 |
32 | nonLimitedEnv := database.Environment{
33 | Owner: "owner",
34 | Status: database.EnvSuccess,
35 | }
36 | err = db.Save(&nonLimitedEnv).Error
37 | require.NoError(t, err)
38 |
39 | otherOwnerEnv := database.Environment{
40 | Owner: "other-owner",
41 | Status: database.EnvSuccess,
42 | }
43 | err = db.Save(&otherOwnerEnv).Error
44 | require.NoError(t, err)
45 |
46 | return db
47 | }
48 |
49 | paymentProvider := paymentMocks.NewPaymentProvider(t)
50 | paymentProvider.EXPECT().GetOwnerPlan(mock.Anything, mock.Anything).Return(payment.PaymentPlanFree, nil)
51 |
52 | tt := []struct {
53 | name string
54 | limit int
55 | want bool
56 | ownerLimit int
57 | paymetProvider payment.PaymentProvider
58 | }{
59 | {
60 | name: "when non limited envs count lower than envLimitAmount",
61 | limit: 2,
62 | want: false,
63 | paymetProvider: paymentProvider,
64 | },
65 | {
66 | name: "when non limited envs count equal than envLimitAmount",
67 | limit: 1,
68 | want: true,
69 | paymetProvider: paymentProvider,
70 | },
71 | {
72 | name: "when non limited envs count greater than envLimitAmount",
73 | limit: 0,
74 | want: true,
75 | paymetProvider: paymentProvider,
76 | },
77 | {
78 | name: "when owner has specific configuration",
79 | limit: 2,
80 | ownerLimit: 1,
81 | want: true,
82 | paymetProvider: paymentMocks.NewPaymentProvider(t),
83 | },
84 | }
85 |
86 | for _, tc := range tt {
87 | tc := tc
88 | t.Run(tc.name, func(t *testing.T) {
89 | t.Parallel()
90 | db := setup(t)
91 |
92 | if tc.ownerLimit > 0 {
93 | err := db.Save(&environmentLimits{
94 | Owner: "owner",
95 | EnvLimit: tc.ownerLimit,
96 | }).Error
97 | require.NoError(t, err)
98 | }
99 |
100 | ep := NewDBEnvironmentsProvider(
101 | db,
102 | paymentProvider,
103 | tc.limit,
104 | permanentbranchesMocks.NewPermanentBranchesProvider(t),
105 | clusterMocks.NewClient(t),
106 | )
107 | limited, err := ep.IsOwnerLimited(context.Background(), "owner")
108 | require.NoError(t, err)
109 | assert.Equal(t, tc.want, limited)
110 | })
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/internal/transformer/transformer_test.go:
--------------------------------------------------------------------------------
1 | package transformer
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | const rawCompose = `
10 | # the string redis: appears first as a comment
11 | version: "3"
12 | # the string services: appears as comment also
13 | volumes:
14 | redis:
15 | services:
16 | # redis: also appears here
17 | mongo: # redis: also appears here
18 | image: mongo
19 | ports:
20 | - "27017:27017"
21 | redis: # make a comment here to increase confusion
22 | image: redis
23 | ports:
24 | - "6379:6379"
25 | volumes:
26 | - redis:/data
27 | volumes:
28 | # make it show mongo: again to test better ServicesOrder
29 | mongo:
30 | `
31 |
32 | func TestTransformerCompose_FirstService(t *testing.T) {
33 | compose := Environment{
34 | Services: map[string]EnvironmentService{
35 | "redis": {Image: "redis", Index: 1},
36 | "mongo": {Image: "mongo", Index: 0},
37 | },
38 | RawContent: rawCompose,
39 | }
40 |
41 | firstService := compose.FirstService()
42 |
43 | assert.Equal(t, compose.Services["mongo"], firstService)
44 | }
45 |
46 | func TestTransformerCompose_computeServicesIndexes(t *testing.T) {
47 | tt := []struct {
48 | name string
49 | compose Environment
50 | want map[string]EnvironmentService
51 | }{
52 | {
53 | name: "complicated compose",
54 | compose: Environment{
55 | Services: map[string]EnvironmentService{
56 | "redis": {Image: "redis"},
57 | "mongo": {Image: "mongo"},
58 | },
59 | RawContent: rawCompose,
60 | },
61 | want: map[string]EnvironmentService{
62 | "redis": {Image: "redis", Index: 1},
63 | "mongo": {Image: "mongo", Index: 0},
64 | },
65 | },
66 | {
67 | name: "call service something that can be a key like 'ports' or 'image'",
68 | compose: Environment{
69 | Services: map[string]EnvironmentService{
70 | "ports": {Image: "mongo"},
71 | "image": {Image: "redis"},
72 | },
73 | RawContent: `
74 | version: '3.8'
75 | services:
76 | image:
77 | image: redis
78 | ports:
79 | - '8000:8000'
80 |
81 | ports:
82 | image: mongo
83 | ports:
84 | - 27017`,
85 | },
86 | want: map[string]EnvironmentService{
87 | "ports": {Image: "mongo", Index: 1},
88 | "image": {Image: "redis", Index: 0},
89 | },
90 | },
91 | {
92 | name: "when first lines after services is are comments and empty lines",
93 | compose: Environment{
94 | Services: map[string]EnvironmentService{
95 | "ports": {Image: "mongo"},
96 | "image": {Image: "redis"},
97 | },
98 | RawContent: `
99 | version: '3.8'
100 | services:
101 |
102 | # a comment. the line above is empty, the line below has one leading space
103 |
104 | image:
105 | image: redis
106 | ports:
107 | - '8000:8000'
108 |
109 | ports:
110 | image: mongo
111 | ports:
112 | - 27017`,
113 | },
114 | want: map[string]EnvironmentService{
115 | "ports": {Image: "mongo", Index: 1},
116 | "image": {Image: "redis", Index: 0},
117 | },
118 | },
119 | }
120 |
121 | for _, tc := range tt {
122 | t.Run(tc.name, func(t *testing.T) {
123 | tc.compose.computeServicesIndexes()
124 | assert.Equal(t, tc.want, tc.compose.Services)
125 | })
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/front/src/components/Select.tsx:
--------------------------------------------------------------------------------
1 | import { Listbox, Transition } from '@headlessui/react'
2 | import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
3 | import classNames from 'classnames'
4 | import { Fragment } from 'react'
5 |
6 | type SelectOptions = {
7 | value: number
8 | options: Array<{ label: string; value: number }>
9 | onChange: (value: number) => void
10 | }
11 |
12 | const Select = ({ value, options, onChange }: SelectOptions) => {
13 | return (
14 |
15 | {({ open }) => (
16 |
17 |
18 |
19 | {options.find((o) => o.value === value)?.label ?? ''}
20 |
21 |
22 |
26 |
27 |
28 |
29 |
36 |
37 | {options.map((option) => (
38 |
41 | classNames(
42 | active
43 | ? 'bg-primary-600 text-white'
44 | : 'text-gray-900 dark:text-gray-300',
45 | 'relative cursor-default select-none py-2 pl-3 pr-9'
46 | )
47 | }
48 | value={option.value}
49 | >
50 | {({ selected, active }) => (
51 | <>
52 |
58 | {option.label}
59 |
60 |
61 | {selected ? (
62 |
68 |
69 |
70 | ) : null}
71 | >
72 | )}
73 |
74 | ))}
75 |
76 |
77 |
78 | )}
79 |
80 | )
81 | }
82 |
83 | export default Select
84 |
--------------------------------------------------------------------------------