├── 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 | GitHub Logo 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 |
17 | e 18 | m_ 19 |
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 |
9 | 10 | 11 | You're no longer authenticated.{' '} 12 | 16 | Sign in with GitHub. 17 | 18 | 19 |
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 |
24 | 25 | 26 |
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 |
28 |
29 |
37 | 48 |
49 |
50 | 56 | 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 | 35 | 36 | 37 |

{props.descriptionRight}

38 |
39 |
40 | {props.chevron ?? ( 41 |