├── examples ├── basic │ ├── go.sum │ ├── go.mod │ ├── .iop │ │ └── secrets │ ├── Dockerfile │ ├── main.go │ ├── iop.yml │ └── README.md ├── nextjs │ ├── src │ │ └── app │ │ │ ├── favicon.ico │ │ │ ├── up │ │ │ └── route.ts │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ ├── postcss.config.mjs │ ├── public │ │ ├── vercel.svg │ │ ├── window.svg │ │ ├── file.svg │ │ ├── globe.svg │ │ └── next.svg │ ├── next.config.ts │ ├── .dockerignore │ ├── docker-build.sh │ ├── eslint.config.mjs │ ├── .gitignore │ ├── iop.yml │ ├── package.json │ ├── tsconfig.json │ ├── Dockerfile │ └── README.md └── plausible │ ├── .iop │ └── secrets │ ├── iop.yml │ └── README.md ├── docs ├── .eslintrc.json ├── postcss.config.mjs ├── src │ ├── app │ │ ├── global.css │ │ ├── api │ │ │ └── search │ │ │ │ └── route.ts │ │ ├── (home) │ │ │ ├── layout.tsx │ │ │ └── [[...slug]] │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── layout.config.tsx │ ├── lib │ │ └── source.ts │ └── mdx-components.tsx ├── next.config.mjs ├── content │ └── docs │ │ ├── test.mdx │ │ ├── index.mdx │ │ └── installation.mdx ├── .gitignore ├── source.config.ts ├── package.json ├── tsconfig.json └── README.md ├── packages ├── proxy │ ├── lightform-proxy │ ├── internal │ │ ├── router │ │ │ └── interfaces.go │ │ ├── services │ │ │ └── health.go │ │ ├── events │ │ │ └── bus.go │ │ ├── storage │ │ │ └── memory.go │ │ ├── core │ │ │ ├── interfaces.go │ │ │ └── models.go │ │ ├── test │ │ │ ├── debug_blue_green_test.go │ │ │ ├── behavior_test.go │ │ │ ├── blue_green_test.go │ │ │ ├── realistic_blue_green_test.go │ │ │ └── controller_integration_test.go │ │ ├── health │ │ │ └── checker.go │ │ ├── deployment │ │ │ ├── controller_test.go │ │ │ └── controller_load_test.go │ │ └── cli │ │ │ └── http_cli.go │ ├── go.mod │ ├── publish.sh │ ├── Dockerfile │ └── go.sum ├── cli │ ├── tsconfig.json │ ├── package.json │ ├── src │ │ ├── utils │ │ │ ├── image-utils.ts │ │ │ ├── sslip.ts │ │ │ ├── release.ts │ │ │ ├── service-utils.ts │ │ │ ├── index.ts │ │ │ └── service-fingerprint.ts │ │ └── ssh │ │ │ └── utils.ts │ ├── tests │ │ ├── docker-logging.test.ts │ │ ├── image-tagging.test.ts │ │ ├── docker-service-aliases.test.ts │ │ ├── proxy-integration.test.ts │ │ ├── config.test.ts │ │ ├── proxy-commands.test.ts │ │ ├── types.test.ts │ │ ├── reserved-names.test.ts │ │ ├── init.test.ts │ │ └── service-utils.test.ts │ └── bun.lock └── README.md ├── .gitignore ├── .cursor └── rules │ └── always.mdc ├── .npmignore ├── package.json ├── .github ├── release.yml └── workflows │ └── test.yml ├── scripts ├── release.sh └── zero-deploy-test.sh ├── load.sh └── RELEASE.md /examples/basic/go.sum: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/basic/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/elitan/lightform-example 2 | 3 | go 1.22 4 | -------------------------------------------------------------------------------- /docs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/basic/.iop/secrets: -------------------------------------------------------------------------------- 1 | SECRET_VAR=my-secret-value 2 | POSTGRES_PASSWORD=postgres123 3 | -------------------------------------------------------------------------------- /packages/proxy/lightform-proxy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elitan/iop/HEAD/packages/proxy/lightform-proxy -------------------------------------------------------------------------------- /docs/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elitan/iop/HEAD/examples/nextjs/src/app/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs/src/app/up/route.ts: -------------------------------------------------------------------------------- 1 | export async function GET() { 2 | return new Response("OK", { status: 200 }); 3 | } 4 | -------------------------------------------------------------------------------- /docs/src/app/global.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import 'fumadocs-ui/css/neutral.css'; 3 | @import 'fumadocs-ui/css/preset.css'; 4 | -------------------------------------------------------------------------------- /examples/nextjs/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /examples/nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/src/app/api/search/route.ts: -------------------------------------------------------------------------------- 1 | import { source } from '@/lib/source'; 2 | import { createFromSource } from 'fumadocs-core/search/server'; 3 | 4 | export const { GET } = createFromSource(source); 5 | -------------------------------------------------------------------------------- /examples/nextjs/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | output: "standalone", 5 | /* config options here */ 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /docs/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { createMDX } from 'fumadocs-mdx/next'; 2 | 3 | const withMDX = createMDX(); 4 | 5 | /** @type {import('next').NextConfig} */ 6 | const config = { 7 | reactStrictMode: true, 8 | }; 9 | 10 | export default withMDX(config); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build outputs 5 | dist/ 6 | packages/*/dist/ 7 | 8 | # Environment files 9 | .env 10 | .env.local 11 | 12 | # OS files 13 | .DS_Store 14 | 15 | # IDE 16 | .vscode/ 17 | .idea/ 18 | 19 | # Logs 20 | *.log -------------------------------------------------------------------------------- /docs/src/lib/source.ts: -------------------------------------------------------------------------------- 1 | import { docs } from "@/.source"; 2 | import { loader } from "fumadocs-core/source"; 3 | 4 | // See https://fumadocs.vercel.app/docs/headless/source-api for more info 5 | export const source = loader({ 6 | // it assigns a URL to your pages 7 | baseUrl: "/", 8 | source: docs.toFumadocsSource(), 9 | }); 10 | -------------------------------------------------------------------------------- /examples/nextjs/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | .next 4 | node_modules 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | .git 9 | .gitignore 10 | README.md 11 | .env 12 | .env.local 13 | .env.development 14 | .env.staging 15 | .env.production 16 | .vercel 17 | coverage 18 | .nyc_output 19 | .DS_Store 20 | *.log -------------------------------------------------------------------------------- /packages/proxy/internal/router/interfaces.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "crypto/tls" 4 | 5 | // CertificateProvider is the interface that the router needs from cert management 6 | type CertificateProvider interface { 7 | GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) 8 | ServeHTTPChallenge(token string) (string, bool) 9 | } -------------------------------------------------------------------------------- /packages/proxy/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/elitan/iop/proxy 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/stretchr/testify v1.10.0 7 | golang.org/x/crypto v0.18.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /docs/content/docs/test.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Components 3 | description: Components 4 | --- 5 | 6 | ## Code Block 7 | 8 | ```js 9 | console.log('Hello World'); 10 | ``` 11 | 12 | ## Cards 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/src/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import defaultMdxComponents from 'fumadocs-ui/mdx'; 2 | import type { MDXComponents } from 'mdx/types'; 3 | 4 | // use this function to get MDX components, you will need it for rendering MDX 5 | export function getMDXComponents(components?: MDXComponents): MDXComponents { 6 | return { 7 | ...defaultMdxComponents, 8 | ...components, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /examples/nextjs/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # deps 2 | /node_modules 3 | 4 | # generated content 5 | .contentlayer 6 | .content-collections 7 | .source 8 | 9 | # test & build 10 | /coverage 11 | /.next/ 12 | /out/ 13 | /build 14 | *.tsbuildinfo 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | /.pnp 20 | .pnp.js 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # others 26 | .env*.local 27 | .vercel 28 | next-env.d.ts -------------------------------------------------------------------------------- /docs/src/app/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { DocsLayout } from 'fumadocs-ui/layouts/docs'; 2 | import type { ReactNode } from 'react'; 3 | import { baseOptions } from '@/app/layout.config'; 4 | import { source } from '@/lib/source'; 5 | 6 | export default function Layout({ children }: { children: ReactNode }) { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /examples/nextjs/docker-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Docker build and run script for Next.js app 4 | 5 | echo "Building Docker image..." 6 | docker build -t nextjs-app . 7 | 8 | echo "Running Docker container..." 9 | docker run -p 3000:3000 --name nextjs-container -d nextjs-app 10 | 11 | echo "Docker container is running on http://localhost:3000" 12 | echo "To stop the container, run: docker stop nextjs-container" 13 | echo "To remove the container, run: docker rm nextjs-container" -------------------------------------------------------------------------------- /examples/nextjs/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /.cursor/rules/always.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | - this project is using `bun` and not `npm` or some other package manager. 7 | - If you want to debug live, check out the ./DEBUG.md file in the root. 8 | 9 | ## CLI 10 | 11 | The cli code is in typescript and placed in the root folder with the source code in the ./src folder. 12 | 13 | ### Commands: 14 | 15 | - init 16 | - setup 17 | - deploy 18 | 19 | 20 | ## Proxy 21 | 22 | In the ./proxy folder. 23 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | test/ 4 | *.test.ts 5 | *.spec.ts 6 | 7 | # Development files 8 | .cursor/ 9 | .git/ 10 | .gitignore 11 | tsconfig.json 12 | bun.lock 13 | bun.lockb 14 | 15 | # Documentation not needed in package 16 | IMPLEMENTATION_*.md 17 | BLUE_GREEN_*.md 18 | ZERO_DOWNTIME_*.md 19 | PRD.md 20 | AGENTS.md 21 | todos 22 | *.sh 23 | 24 | # Example and proxy directories 25 | example/ 26 | 27 | # Node modules 28 | node_modules/ 29 | 30 | # OS files 31 | .DS_Store 32 | Thumbs.db -------------------------------------------------------------------------------- /docs/source.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | defineDocs, 4 | frontmatterSchema, 5 | metaSchema, 6 | } from 'fumadocs-mdx/config'; 7 | 8 | // You can customise Zod schemas for frontmatter and `meta.json` here 9 | // see https://fumadocs.vercel.app/docs/mdx/collections#define-docs 10 | export const docs = defineDocs({ 11 | docs: { 12 | schema: frontmatterSchema, 13 | }, 14 | meta: { 15 | schema: metaSchema, 16 | }, 17 | }); 18 | 19 | export default defineConfig({ 20 | mdxOptions: { 21 | // MDX options 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /docs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './global.css'; 2 | import { RootProvider } from 'fumadocs-ui/provider'; 3 | import { Inter } from 'next/font/google'; 4 | import type { ReactNode } from 'react'; 5 | 6 | const inter = Inter({ 7 | subsets: ['latin'], 8 | }); 9 | 10 | export default function Layout({ children }: { children: ReactNode }) { 11 | return ( 12 | 13 | 14 | {children} 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "lib": ["es2020"], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "outDir": "./dist", 13 | "rootDir": "./src", 14 | "declaration": true, 15 | "sourceMap": true, 16 | "types": ["node"] 17 | }, 18 | "include": ["src/**/*.ts"], 19 | "exclude": ["node_modules", "dist", "test"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | -------------------------------------------------------------------------------- /examples/basic/Dockerfile: -------------------------------------------------------------------------------- 1 | # Start from the official Golang image 2 | FROM golang:1.22-alpine 3 | 4 | # Accept build arguments from iop build.env 5 | ARG EXAMPLE_VAR 6 | 7 | # Set the Current Working Directory inside the container 8 | WORKDIR /app 9 | 10 | # Copy go.mod first for better caching 11 | COPY go.mod ./ 12 | 13 | # Copy source code 14 | COPY main.go ./ 15 | 16 | # Build the Go app (can use EXAMPLE_VAR during build) 17 | RUN echo "Building with EXAMPLE_VAR=${EXAMPLE_VAR}" && \ 18 | CGO_ENABLED=0 GOOS=linux go build -o main . 19 | 20 | # Expose port 80 to the outside world 21 | EXPOSE 80 22 | 23 | # Command to run the executable 24 | CMD ["./main"] -------------------------------------------------------------------------------- /examples/nextjs/.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /examples/nextjs/iop.yml: -------------------------------------------------------------------------------- 1 | name: nextjs 2 | 3 | ssh: 4 | username: iop 5 | 6 | # Unified services model - everything is a service! 7 | # Services with 'proxy' config get zero-downtime deployment automatically 8 | services: 9 | web: 10 | build: 11 | context: . 12 | dockerfile: Dockerfile 13 | args: 14 | - NODE_ENV 15 | - NEXT_PUBLIC_API_URL 16 | server: 157.180.25.101 17 | environment: 18 | plain: 19 | - NODE_ENV=production 20 | - NEXT_PUBLIC_API_URL=https://api.example.com 21 | proxy: # This triggers zero-downtime deployment 22 | #hosts: 23 | # - nextjs.example.myiop.cloud 24 | app_port: 3000 25 | -------------------------------------------------------------------------------- /examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "react": "^19.0.0", 13 | "react-dom": "^19.0.0", 14 | "next": "15.3.2" 15 | }, 16 | "devDependencies": { 17 | "typescript": "^5", 18 | "@types/node": "^20", 19 | "@types/react": "^19", 20 | "@types/react-dom": "^19", 21 | "@tailwindcss/postcss": "^4", 22 | "tailwindcss": "^4", 23 | "eslint": "^9", 24 | "eslint-config-next": "15.3.2", 25 | "@eslint/eslintrc": "^3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /docs/src/app/layout.config.tsx: -------------------------------------------------------------------------------- 1 | import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; 2 | 3 | /** 4 | * Shared layout configurations 5 | * 6 | * you can customise layouts individually from: 7 | * Home Layout: app/(home)/layout.tsx 8 | * Docs Layout: app/docs/layout.tsx 9 | */ 10 | export const baseOptions: BaseLayoutProps = { 11 | nav: { 12 | title: ( 13 | <> 14 | 20 | 21 | 22 | iop 23 | 24 | ), 25 | }, 26 | // see https://fumadocs.dev/docs/ui/navigation/links 27 | links: [], 28 | githubUrl: "https://github.com/elitan/iop", 29 | }; 30 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev --turbo", 8 | "start": "next start", 9 | "postinstall": "fumadocs-mdx" 10 | }, 11 | "dependencies": { 12 | "next": "15.3.2", 13 | "react": "^19.1.0", 14 | "react-dom": "^19.1.0", 15 | "fumadocs-ui": "15.4.2", 16 | "fumadocs-core": "15.4.2", 17 | "fumadocs-mdx": "11.6.6" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "22.15.21", 21 | "@types/react": "^19.1.5", 22 | "@types/react-dom": "^19.1.5", 23 | "typescript": "^5.8.3", 24 | "@types/mdx": "^2.0.13", 25 | "@tailwindcss/postcss": "^4.1.7", 26 | "tailwindcss": "^4.1.7", 27 | "postcss": "^8.5.3", 28 | "eslint": "^8", 29 | "eslint-config-next": "15.3.2" 30 | } 31 | } -------------------------------------------------------------------------------- /examples/nextjs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Create Next App", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | ) 9 | 10 | func helloHandler(w http.ResponseWriter, r *http.Request) { 11 | // Only handle exact root path 12 | if r.URL.Path != "/" { 13 | http.NotFound(w, r) 14 | return 15 | } 16 | fmt.Fprintf(w, "Hello World from %s", os.Getenv("EXAMPLE_VAR")) 17 | } 18 | 19 | func upHandler(w http.ResponseWriter, r *http.Request) { 20 | w.WriteHeader(http.StatusOK) 21 | fmt.Fprintf(w, "UP") 22 | } 23 | 24 | func main() { 25 | http.HandleFunc("/", helloHandler) 26 | http.HandleFunc("/up", upHandler) 27 | 28 | port := os.Getenv("PORT") 29 | if port == "" { 30 | port = "3000" 31 | } 32 | 33 | fmt.Printf("Starting server at port %s\n", port) 34 | if err := http.ListenAndServe(":"+port, nil); err != nil { 35 | log.Fatal(err) 36 | } 37 | } 38 | // Small change to trigger deployment 39 | // Change that affects build 40 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ESNext", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmit": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "moduleResolution": "bundler", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve", 21 | "incremental": true, 22 | "paths": { 23 | "@/.source": [ 24 | "./.source/index.ts" 25 | ], 26 | "@/*": [ 27 | "./src/*" 28 | ] 29 | }, 30 | "plugins": [ 31 | { 32 | "name": "next" 33 | } 34 | ] 35 | }, 36 | "include": [ 37 | "next-env.d.ts", 38 | "**/*.ts", 39 | "**/*.tsx", 40 | ".next/types/**/*.ts" 41 | ], 42 | "exclude": [ 43 | "node_modules" 44 | ] 45 | } -------------------------------------------------------------------------------- /examples/nextjs/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/proxy/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Configuration 5 | IMAGE_NAME="elitan/iop-proxy" 6 | VERSION="latest" 7 | PLATFORMS="linux/amd64,linux/arm64" 8 | 9 | # Display what we're doing 10 | echo "Building and publishing multi-platform image $IMAGE_NAME:$VERSION for platforms: $PLATFORMS..." 11 | 12 | # Create a new builder instance if it doesn't exist 13 | BUILDER_NAME="iop-multiplatform-builder" 14 | if ! docker buildx inspect $BUILDER_NAME > /dev/null 2>&1; then 15 | echo "Creating new buildx builder instance..." 16 | docker buildx create --name $BUILDER_NAME --driver docker-container --use 17 | else 18 | echo "Using existing buildx builder instance..." 19 | docker buildx use $BUILDER_NAME 20 | fi 21 | 22 | # Ensure the builder is running 23 | docker buildx inspect --bootstrap 24 | 25 | # Build the Docker image for multiple platforms using buildx 26 | echo "Building image with buildx..." 27 | docker buildx build --platform $PLATFORMS -t $IMAGE_NAME:$VERSION --push . 28 | 29 | echo "Done! Multi-platform image $IMAGE_NAME:$VERSION has been published." -------------------------------------------------------------------------------- /packages/proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM golang:1.21-alpine AS builder 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache git 6 | 7 | # Set working directory 8 | WORKDIR /build 9 | 10 | # Copy go mod files 11 | COPY go.mod go.sum ./ 12 | 13 | # Download dependencies 14 | RUN go mod download 15 | 16 | # Copy source code 17 | COPY . . 18 | 19 | # Build the binary 20 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o iop-proxy ./cmd/iop-proxy 21 | 22 | # Runtime stage 23 | FROM alpine:3.18 24 | 25 | # Install ca-certificates for HTTPS and curl for health checks 26 | RUN apk --no-cache add ca-certificates curl 27 | 28 | # Create directories 29 | RUN mkdir -p /var/lib/iop-proxy/certs 30 | 31 | # Copy binary from builder 32 | COPY --from=builder /build/iop-proxy /usr/local/bin/iop-proxy 33 | 34 | # Make binary executable 35 | RUN chmod +x /usr/local/bin/iop-proxy 36 | 37 | # Expose ports 38 | EXPOSE 80 443 39 | 40 | # Volume for persistent data 41 | VOLUME ["/var/lib/iop-proxy"] 42 | 43 | # Run the proxy 44 | ENTRYPOINT ["/usr/local/bin/iop-proxy"] -------------------------------------------------------------------------------- /packages/proxy/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= 8 | golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iop-monorepo", 3 | "private": true, 4 | "description": "Zero-downtime deployments for your own servers - Monorepo", 5 | "version": "0.1.2", 6 | "author": "Elitan", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/elitan/iop.git" 11 | }, 12 | "workspaces": [ 13 | "packages/*" 14 | ], 15 | "scripts": { 16 | "build": "bun run build:cli && bun run build:proxy", 17 | "build:cli": "cd packages/cli && bun run build", 18 | "build:proxy": "cd packages/proxy && go build -o dist/iop-proxy ./cmd/iop-proxy", 19 | "test": "bun run test:cli", 20 | "test:cli": "cd packages/cli && bun test", 21 | "start:cli": "cd packages/cli && bun run start", 22 | "publish:cli": "cd packages/cli && bun publish --access public", 23 | "release": "./scripts/release.sh patch", 24 | "release:patch": "./scripts/release.sh patch", 25 | "release:minor": "./scripts/release.sh minor", 26 | "release:major": "./scripts/release.sh major" 27 | }, 28 | "devDependencies": { 29 | "@types/bun": "latest", 30 | "typescript": "^5" 31 | }, 32 | "engines": { 33 | "node": ">=18" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/README.md: -------------------------------------------------------------------------------- 1 | # iop Packages 2 | 3 | This directory contains the core packages for the iop deployment system: 4 | 5 | ## Structure 6 | 7 | - **`cli/`** - TypeScript CLI for managing deployments 8 | 9 | - Zero-downtime deployments via SSH 10 | - Blue-green deployment strategy 11 | - Docker container management 12 | - Proxy configuration 13 | 14 | - **`proxy/`** - Go-based reverse proxy service 15 | 16 | - Automatic HTTPS with Let's Encrypt 17 | - Health checking 18 | - Dynamic routing 19 | - State persistence 20 | 21 | - **`shared/`** - Shared configurations and types (future use) 22 | 23 | ## Development 24 | 25 | ### CLI Package 26 | 27 | ```bash 28 | cd packages/cli 29 | bun install 30 | bun run build 31 | bun test 32 | ``` 33 | 34 | ### Proxy Package 35 | 36 | ```bash 37 | cd packages/proxy 38 | go mod download 39 | go build -o dist/iop-proxy ./cmd/iop-proxy 40 | go test ./... 41 | ``` 42 | 43 | ## Building 44 | 45 | From the root directory: 46 | 47 | ```bash 48 | # Build everything 49 | bun run build 50 | 51 | # Build specific packages 52 | bun run build:cli 53 | bun run build:proxy 54 | ``` 55 | 56 | ## Testing 57 | 58 | From the root directory: 59 | 60 | ```bash 61 | # Test everything 62 | bun test 63 | 64 | # Test specific packages 65 | bun test:cli 66 | ``` 67 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iop", 3 | "description": "Ship Docker Anywhere", 4 | "version": "0.2.17", 5 | "author": "elitan", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/elitan/iop.git", 10 | "directory": "packages/cli" 11 | }, 12 | "keywords": [ 13 | "deployment", 14 | "zero-downtime", 15 | "docker", 16 | "ssh", 17 | "cli" 18 | ], 19 | "bin": { 20 | "iop": "dist/index.js" 21 | }, 22 | "main": "./dist/index.js", 23 | "files": [ 24 | "dist/**/*", 25 | "README.md" 26 | ], 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "scripts": { 31 | "build": "tsc", 32 | "prepublishOnly": "cp ../../README.md . && bun run build", 33 | "start": "bun run src/index.ts", 34 | "test": "bun test" 35 | }, 36 | "dependencies": { 37 | "@types/cli-progress": "^3.11.6", 38 | "cli-progress": "^3.12.0", 39 | "js-yaml": "^4.1.0", 40 | "ssh2-promise": "^1.0.0", 41 | "zod": "^3.24.4" 42 | }, 43 | "devDependencies": { 44 | "@types/bun": "latest", 45 | "@types/js-yaml": "^4.0.5", 46 | "@types/node": "^20", 47 | "@types/ssh2": "^1.15.0", 48 | "typescript": "^5" 49 | }, 50 | "engines": { 51 | "node": ">=18" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/plausible/.iop/secrets: -------------------------------------------------------------------------------- 1 | # Plausible Configuration Secrets 2 | # Copy this file to .iop/secrets and fill in your actual values 3 | 4 | # Required: Your domain where Plausible will be accessible 5 | BASE_URL=https://analytics.eliasson.me 6 | 7 | # Required: Generate with: openssl rand -base64 48 8 | SECRET_KEY_BASE=z6MB+QyylMVJrNARaj85ciiD/oKT8+eb8SQQH2sORdGKFLeda4HwWNc4C5/TFMNx 9 | 10 | # Required: Generate with: openssl rand -base64 32 11 | TOTP_VAULT_KEY=WUEaNMb7+NUfw/nMKmVrW9zeTGmbF0mcKZPt4leZzkg= 12 | 13 | # Database password 14 | POSTGRES_PASSWORD=ashd0ahsd9a0dhj12oi3h1231 15 | DATABASE_URL=postgres://postgres:ashd0ahsd9a0dhj12oi3h1231@plausible_db:5432/plausible_db 16 | 17 | # Optional: Email configuration (uncomment and configure as needed) 18 | # MAILER_ADAPTER=Smtp 19 | # MAILER_EMAIL=hello@yourdomain.com 20 | # MAILER_NAME=Plausible 21 | # SMTP_HOST_ADDR=smtp.yourdomain.com 22 | # SMTP_HOST_PORT=587 23 | # SMTP_USER_NAME=hello@yourdomain.com 24 | # SMTP_USER_PWD=your-smtp-password 25 | # SMTP_HOST_SSL_ENABLED=true 26 | 27 | # Optional: Google OAuth (uncomment and configure as needed) 28 | # GOOGLE_CLIENT_ID=your-google-client-id 29 | # GOOGLE_CLIENT_SECRET=your-google-client-secret 30 | 31 | # Optional: IP Geolocation (uncomment and configure as needed) 32 | # MAXMIND_LICENSE_KEY=your-maxmind-license-key 33 | # MAXMIND_EDITION=GeoLite2-City -------------------------------------------------------------------------------- /examples/nextjs/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/proxy/internal/services/health.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // HealthService provides health checking functionality 11 | type HealthService struct { 12 | client *http.Client 13 | } 14 | 15 | // NewHealthService creates a new health service 16 | func NewHealthService() *HealthService { 17 | return &HealthService{ 18 | client: &http.Client{ 19 | Timeout: 5 * time.Second, 20 | Transport: &http.Transport{ 21 | MaxIdleConns: 10, 22 | MaxIdleConnsPerHost: 2, 23 | IdleConnTimeout: 30 * time.Second, 24 | }, 25 | }, 26 | } 27 | } 28 | 29 | // CheckHealth performs a health check against the given target and path 30 | func (h *HealthService) CheckHealth(ctx context.Context, target, healthPath string) error { 31 | url := fmt.Sprintf("http://%s%s", target, healthPath) 32 | 33 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 34 | if err != nil { 35 | return fmt.Errorf("failed to create request: %w", err) 36 | } 37 | 38 | resp, err := h.client.Do(req) 39 | if err != nil { 40 | return fmt.Errorf("health check request failed: %w", err) 41 | } 42 | defer resp.Body.Close() 43 | 44 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 45 | return fmt.Errorf("health check failed with status %d", resp.StatusCode) 46 | } 47 | 48 | return nil 49 | } -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # GitHub Release Configuration 2 | # Automatically generate release notes with categorized PRs 3 | # https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes 4 | 5 | changelog: 6 | exclude: 7 | labels: 8 | - chore 9 | - dependencies 10 | - ignore-for-release 11 | authors: 12 | - dependabot 13 | - github-actions 14 | categories: 15 | - title: 🚨 Breaking Changes 16 | labels: 17 | - breaking-change 18 | - breaking 19 | - major 20 | - title: 🚀 New Features 21 | labels: 22 | - feature 23 | - enhancement 24 | - new-feature 25 | - title: 🐛 Bug Fixes 26 | labels: 27 | - bug 28 | - bugfix 29 | - fix 30 | - title: 📚 Documentation 31 | labels: 32 | - documentation 33 | - docs 34 | - title: 🧹 Maintenance 35 | labels: 36 | - maintenance 37 | - refactor 38 | - cleanup 39 | - title: 🔧 Infrastructure 40 | labels: 41 | - infrastructure 42 | - ci 43 | - build 44 | - deployment 45 | - title: ⚡ Performance 46 | labels: 47 | - performance 48 | - optimization 49 | - title: 🔒 Security 50 | labels: 51 | - security 52 | - vulnerability 53 | - title: Other Changes 54 | labels: 55 | - "*" -------------------------------------------------------------------------------- /packages/proxy/internal/events/bus.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/elitan/iop/proxy/internal/core" 7 | ) 8 | 9 | // SimpleBus is a simple in-memory event bus 10 | type SimpleBus struct { 11 | mu sync.RWMutex 12 | subscribers []chan core.Event 13 | } 14 | 15 | // NewSimpleBus creates a new simple event bus 16 | func NewSimpleBus() *SimpleBus { 17 | return &SimpleBus{ 18 | subscribers: make([]chan core.Event, 0), 19 | } 20 | } 21 | 22 | // Publish publishes an event to all subscribers 23 | func (b *SimpleBus) Publish(event core.Event) { 24 | b.mu.RLock() 25 | defer b.mu.RUnlock() 26 | 27 | for _, ch := range b.subscribers { 28 | select { 29 | case ch <- event: 30 | // Event sent successfully 31 | default: 32 | // Channel is full, skip this subscriber 33 | // In production, you might want to log this 34 | } 35 | } 36 | } 37 | 38 | // Subscribe creates a new subscription channel 39 | func (b *SimpleBus) Subscribe() <-chan core.Event { 40 | b.mu.Lock() 41 | defer b.mu.Unlock() 42 | 43 | ch := make(chan core.Event, 100) // Buffered channel 44 | b.subscribers = append(b.subscribers, ch) 45 | return ch 46 | } 47 | 48 | // Unsubscribe removes a subscription channel 49 | func (b *SimpleBus) Unsubscribe(ch <-chan core.Event) { 50 | b.mu.Lock() 51 | defer b.mu.Unlock() 52 | 53 | for i, sub := range b.subscribers { 54 | if sub == ch { 55 | // Remove from slice 56 | b.subscribers = append(b.subscribers[:i], b.subscribers[i+1:]...) 57 | close(sub) 58 | break 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /packages/cli/src/utils/image-utils.ts: -------------------------------------------------------------------------------- 1 | import { ServiceEntry } from '../config/types'; 2 | 3 | /** 4 | * Checks if a service needs to be built locally (vs using a pre-built image) 5 | */ 6 | export function serviceNeedsBuilding(serviceEntry: ServiceEntry): boolean { 7 | // If it has a build config, it needs building 8 | if (serviceEntry.build) { 9 | return true; 10 | } 11 | 12 | // If it doesn't have an image field, it needs building 13 | if (!serviceEntry.image) { 14 | return true; 15 | } 16 | 17 | // Services with image field but no build config are pre-built 18 | return false; 19 | } 20 | 21 | /** 22 | * Gets the base image name for a service 23 | */ 24 | export function getServiceImageName(serviceEntry: ServiceEntry): string { 25 | // If image is specified, use it as the base name 26 | if (serviceEntry.image) { 27 | return serviceEntry.image; 28 | } 29 | 30 | // Otherwise, generate a name based on the service name 31 | return `${serviceEntry.name}`; 32 | } 33 | 34 | /** 35 | * Builds the full image name for built services, or returns original name for pre-built services 36 | */ 37 | export function buildServiceImageName(serviceEntry: ServiceEntry, releaseId?: string): string { 38 | const baseImageName = getServiceImageName(serviceEntry); 39 | 40 | // For services that need building, always use :latest 41 | if (serviceNeedsBuilding(serviceEntry)) { 42 | return `${baseImageName}:latest`; 43 | } 44 | // For pre-built services, use the image as-is (if it exists) 45 | return serviceEntry.image || baseImageName; 46 | } 47 | 48 | -------------------------------------------------------------------------------- /docs/src/app/(home)/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { source } from '@/lib/source'; 2 | import { 3 | DocsPage, 4 | DocsBody, 5 | DocsDescription, 6 | DocsTitle, 7 | } from 'fumadocs-ui/page'; 8 | import { notFound } from 'next/navigation'; 9 | import { createRelativeLink } from 'fumadocs-ui/mdx'; 10 | import { getMDXComponents } from '@/mdx-components'; 11 | 12 | export default async function Page(props: { 13 | params: Promise<{ slug?: string[] }>; 14 | }) { 15 | const params = await props.params; 16 | const page = source.getPage(params.slug); 17 | if (!page) notFound(); 18 | 19 | const MDXContent = page.data.body; 20 | 21 | return ( 22 | 23 | {page.data.title} 24 | {page.data.description} 25 | 26 | 32 | 33 | 34 | ); 35 | } 36 | 37 | export async function generateStaticParams() { 38 | return source.generateParams(); 39 | } 40 | 41 | export async function generateMetadata(props: { 42 | params: Promise<{ slug?: string[] }>; 43 | }) { 44 | const params = await props.params; 45 | const page = source.getPage(params.slug); 46 | if (!page) notFound(); 47 | 48 | return { 49 | title: page.data.title, 50 | description: page.data.description, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /packages/cli/src/utils/sslip.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import crypto from 'crypto'; 3 | 4 | /** 5 | * Validates if a string is a valid IPv4 address 6 | */ 7 | function isValidIPv4(ip: string): boolean { 8 | const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; 9 | return ipRegex.test(ip); 10 | } 11 | 12 | /** 13 | * Sanitizes a hostname or IP address for DNS usage 14 | */ 15 | function sanitizeHostForDns(host: string): string { 16 | return host.replace(/\./g, '-').replace(/[^a-zA-Z0-9-]/g, ''); 17 | } 18 | 19 | /** 20 | * Generates a deterministic hash for app.iop.run domain 21 | */ 22 | function generateDeterministicHash(projectName: string, appName: string, serverHost: string): string { 23 | const input = `${projectName}:${appName}:${serverHost}`; 24 | return crypto.createHash('sha256').update(input).digest('hex').substring(0, 8); 25 | } 26 | 27 | /** 28 | * Generates a deterministic app.iop.run domain for an app 29 | */ 30 | export function generateAppSslipDomain( 31 | projectName: string, 32 | appName: string, 33 | serverHost: string 34 | ): string { 35 | const hash = generateDeterministicHash(projectName, appName, serverHost); 36 | const sanitizedHost = sanitizeHostForDns(serverHost); 37 | 38 | return `${hash}-${appName}-iop-${sanitizedHost}.app.iop.run`; 39 | } 40 | 41 | /** 42 | * Checks if app.iop.run should be used for domain generation 43 | */ 44 | export function shouldUseSslip(hosts?: string[]): boolean { 45 | // Use app.iop.run if no custom hosts are specified 46 | return !hosts || hosts.length === 0; 47 | } -------------------------------------------------------------------------------- /packages/cli/tests/docker-logging.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'bun:test'; 2 | import { createProxyLoggingConfig, createServiceLoggingConfig, DockerClient } from '../src/docker'; 3 | import { ServiceEntry, IopSecrets } from '../src/config/types'; 4 | 5 | describe('Docker Logging Configuration', () => { 6 | it('should create proxy logging configuration with correct values', () => { 7 | const config = createProxyLoggingConfig(); 8 | 9 | expect(config.logDriver).toBe('json-file'); 10 | expect(config.logOpts['max-size']).toBe('5m'); 11 | expect(config.logOpts['max-file']).toBe('5'); 12 | }); 13 | 14 | it('should create service logging configuration with correct values', () => { 15 | const config = createServiceLoggingConfig(); 16 | 17 | expect(config.logDriver).toBe('json-file'); 18 | expect(config.logOpts['max-size']).toBe('10m'); 19 | expect(config.logOpts['max-file']).toBe('3'); 20 | }); 21 | 22 | it('should include logging configuration in service container options', () => { 23 | const serviceEntry: ServiceEntry = { 24 | name: 'test-service', 25 | image: 'nginx:latest' 26 | }; 27 | 28 | const secrets: IopSecrets = {}; 29 | const projectName = 'test-project'; 30 | 31 | const containerOptions = DockerClient.serviceToContainerOptions( 32 | serviceEntry, 33 | projectName, 34 | secrets 35 | ); 36 | 37 | expect(containerOptions.logDriver).toBe('json-file'); 38 | expect(containerOptions.logOpts).toBeDefined(); 39 | expect(containerOptions.logOpts!['max-size']).toBe('10m'); 40 | expect(containerOptions.logOpts!['max-file']).toBe('3'); 41 | }); 42 | }); -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # docs 2 | 3 | This is a Next.js application generated with 4 | [Create Fumadocs](https://github.com/fuma-nama/fumadocs). 5 | 6 | Run development server: 7 | 8 | ```bash 9 | npm run dev 10 | # or 11 | pnpm dev 12 | # or 13 | yarn dev 14 | ``` 15 | 16 | Open http://localhost:3000 with your browser to see the result. 17 | 18 | ## Explore 19 | 20 | In the project, you can see: 21 | 22 | - `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content. 23 | - `app/layout.config.tsx`: Shared options for layouts, optional but preferred to keep. 24 | 25 | | Route | Description | 26 | | ------------------------- | ------------------------------------------------------ | 27 | | `app/(home)` | The route group for your landing page and other pages. | 28 | | `app/docs` | The documentation layout and pages. | 29 | | `app/api/search/route.ts` | The Route Handler for search. | 30 | 31 | ### Fumadocs MDX 32 | 33 | A `source.config.ts` config file has been included, you can customise different options like frontmatter schema. 34 | 35 | Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details. 36 | 37 | ## Learn More 38 | 39 | To learn more about Next.js and Fumadocs, take a look at the following 40 | resources: 41 | 42 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js 43 | features and API. 44 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 45 | - [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs 46 | -------------------------------------------------------------------------------- /examples/basic/iop.yml: -------------------------------------------------------------------------------- 1 | name: basic 2 | 3 | ssh: 4 | username: iop 5 | 6 | # Unified services model - everything is a service! 7 | # Services with 'proxy' config get zero-downtime deployment automatically 8 | # Infrastructure services get stop-start deployment 9 | services: 10 | # HTTP services (get zero-downtime blue-green deployment) 11 | web1: 12 | build: 13 | context: . 14 | dockerfile: Dockerfile 15 | args: 16 | - EXAMPLE_VAR 17 | server: 65.21.180.49 18 | replicas: 1 19 | environment: 20 | plain: 21 | - EXAMPLE_VAR=web1 22 | secret: 23 | - SECRET_VAR 24 | - POSTGRES_PASSWORD 25 | proxy: # This triggers zero-downtime deployment 26 | app_port: 3000 27 | health_check: 28 | path: /up 29 | 30 | web2: 31 | build: 32 | context: . 33 | dockerfile: Dockerfile 34 | args: 35 | - EXAMPLE_VAR 36 | server: 65.21.180.49 37 | replicas: 1 38 | environment: 39 | plain: 40 | - EXAMPLE_VAR=web2 41 | secret: 42 | - SECRET_VAR 43 | - POSTGRES_PASSWORD 44 | proxy: # This triggers zero-downtime deployment 45 | app_port: 3000 46 | health_check: 47 | path: /up 48 | 49 | # Infrastructure services (get stop-start deployment) 50 | db: 51 | image: postgres:17 52 | server: 65.21.180.49 53 | ports: 54 | - "5433:5432" # Port mapping indicates infrastructure service 55 | environment: 56 | plain: 57 | - POSTGRES_USER=postgres 58 | - POSTGRES_DB=postgres 59 | - POSTGRES_TIMEZONE=Europe/Moscow 60 | secret: 61 | - POSTGRES_PASSWORD 62 | volumes: 63 | - ./pgdata:/var/lib/postgresql/data 64 | -------------------------------------------------------------------------------- /packages/proxy/internal/storage/memory.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/elitan/iop/proxy/internal/core" 8 | ) 9 | 10 | // MemoryStore is a simple in-memory deployment store 11 | type MemoryStore struct { 12 | mu sync.RWMutex 13 | deployments map[string]*core.Deployment 14 | } 15 | 16 | // NewMemoryStore creates a new in-memory store 17 | func NewMemoryStore() *MemoryStore { 18 | return &MemoryStore{ 19 | deployments: make(map[string]*core.Deployment), 20 | } 21 | } 22 | 23 | // GetDeployment retrieves a deployment by hostname 24 | func (s *MemoryStore) GetDeployment(hostname string) (*core.Deployment, error) { 25 | s.mu.RLock() 26 | defer s.mu.RUnlock() 27 | 28 | deployment, exists := s.deployments[hostname] 29 | if !exists { 30 | return nil, fmt.Errorf("deployment not found for hostname: %s", hostname) 31 | } 32 | 33 | // Return a copy to avoid race conditions 34 | deploymentCopy := *deployment 35 | return &deploymentCopy, nil 36 | } 37 | 38 | // SaveDeployment saves a deployment 39 | func (s *MemoryStore) SaveDeployment(deployment *core.Deployment) error { 40 | s.mu.Lock() 41 | defer s.mu.Unlock() 42 | 43 | // Create a copy to store 44 | deploymentCopy := *deployment 45 | s.deployments[deployment.Hostname] = &deploymentCopy 46 | return nil 47 | } 48 | 49 | // ListDeployments returns all deployments 50 | func (s *MemoryStore) ListDeployments() ([]*core.Deployment, error) { 51 | s.mu.RLock() 52 | defer s.mu.RUnlock() 53 | 54 | deployments := make([]*core.Deployment, 0, len(s.deployments)) 55 | for _, deployment := range s.deployments { 56 | // Return copies 57 | deploymentCopy := *deployment 58 | deployments = append(deployments, &deploymentCopy) 59 | } 60 | 61 | return deployments, nil 62 | } 63 | 64 | // DeleteDeployment removes a deployment 65 | func (s *MemoryStore) DeleteDeployment(hostname string) error { 66 | s.mu.Lock() 67 | defer s.mu.Unlock() 68 | 69 | delete(s.deployments, hostname) 70 | return nil 71 | } -------------------------------------------------------------------------------- /packages/cli/src/utils/release.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import { promisify } from "util"; 3 | 4 | const execAsync = promisify(exec); 5 | 6 | export async function generateReleaseId(): Promise { 7 | try { 8 | // Check for uncommitted changes first 9 | // Disabling this check for testing 10 | /* 11 | const { stdout: statusOutput } = await execAsync("git status --porcelain"); 12 | if (statusOutput.trim() !== "") { 13 | console.error("Error: Uncommitted changes detected in the repository."); 14 | console.error("Please commit or stash your changes before deploying."); 15 | throw new Error("Uncommitted Git changes detected. Halting deployment."); 16 | } 17 | */ 18 | 19 | // If clean, proceed to get the SHA 20 | const { stdout: shaStdout } = await execAsync("git rev-parse --short HEAD"); 21 | const sha = shaStdout.trim(); 22 | if (sha) { 23 | return sha; 24 | } 25 | // This part should ideally not be reached if git rev-parse fails after a clean status, but as a safeguard: 26 | throw new Error( 27 | "Failed to retrieve Git SHA even though repository is clean." 28 | ); 29 | } catch (error) { 30 | if (error instanceof Error) { 31 | if (error.message.startsWith("Uncommitted Git changes")) { 32 | throw error; // Re-throw to halt deployment 33 | } 34 | console.warn( 35 | "Failed to get Git SHA or check repository status, falling back to timestamp for release ID:", 36 | error.message 37 | ); 38 | } else { 39 | // Handle non-Error objects thrown 40 | console.warn( 41 | "An unexpected error type was caught while generating release ID, falling back to timestamp:", 42 | error 43 | ); 44 | } 45 | } 46 | // Fallback to timestamp if Git checks fail for other reasons (e.g., not a git repo) 47 | const timestampId = Date.now().toString(); 48 | console.log(`Using timestamp for release ID: ${timestampId}`); 49 | return timestampId; 50 | } 51 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic Go + iop Deployment Example 2 | 3 | This is a simple Go web application that demonstrates zero-downtime deployments using [iop](https://github.com/elitan/iop), including both an app and a PostgreSQL database service. 4 | 5 | ## 🚀 Quick Start with iop 6 | 7 | ### 1. Development Setup 8 | 9 | Install Go dependencies and run locally: 10 | 11 | ```bash 12 | go mod tidy 13 | go run main.go 14 | ``` 15 | 16 | The server will start on port 3000. Visit [http://localhost:3000](http://localhost:3000) to see "Hello World 2". 17 | 18 | ### 2. Configure Your Server 19 | 20 | **Important**: If you cloned this example, you need to update the server configuration: 21 | 22 | 1. Edit `iop.yml` and replace `157.180.47.213` with your actual server IP or domain 23 | 2. Update any domain references in `.iop/secrets` if applicable 24 | 25 | ### 3. Deploy with iop 26 | 27 | This example includes a complete iop configuration for zero-downtime deployment: 28 | 29 | ```bash 30 | iop 31 | ``` 32 | 33 | ## 🆚 Why This Example? 34 | 35 | This basic example demonstrates: 36 | 37 | - **Simple Go web server** - Minimal HTTP server with health checks 38 | - **Multi-stage Docker builds** - Optimized for Go applications 39 | - **Database integration** - PostgreSQL as a supporting service 40 | - **Environment management** - Both plain and secret variables 41 | - **Zero-downtime deployments** - Blue-green deployment for the web app 42 | - **Service persistence** - Database with persistent volumes 43 | 44 | Perfect for understanding iop's core concepts with a minimal setup! 45 | 46 | ## 📚 Learn More 47 | 48 | ### Go Resources 49 | 50 | - [Go Documentation](https://golang.org/doc/) - Official Go documentation 51 | - [Go Web Programming](https://golang.org/doc/articles/wiki/) - Building web applications with Go 52 | 53 | ### iop Resources 54 | 55 | - [iop Documentation](https://github.com/elitan/iop) - Zero-downtime Docker deployments 56 | - [iop Examples](https://github.com/elitan/iop/tree/main/examples) - More deployment examples 57 | -------------------------------------------------------------------------------- /packages/cli/src/utils/service-utils.ts: -------------------------------------------------------------------------------- 1 | import { ServiceEntry } from "../config/types"; 2 | 3 | /** 4 | * Determines if a service requires zero-downtime deployment based on its characteristics 5 | */ 6 | export function requiresZeroDowntimeDeployment(service: ServiceEntry): boolean { 7 | // Only services with proxy configuration get zero-downtime deployment 8 | // All other services (including those with health_check or exposed ports) get stop-start 9 | return Boolean(service.proxy); 10 | } 11 | 12 | /** 13 | * Determines deployment strategy for a service 14 | */ 15 | export function getDeploymentStrategy(service: ServiceEntry): 'zero-downtime' | 'stop-start' { 16 | return requiresZeroDowntimeDeployment(service) ? 'zero-downtime' : 'stop-start'; 17 | } 18 | 19 | /** 20 | * Gets the effective port for proxy configuration 21 | */ 22 | export function getServiceProxyPort(service: ServiceEntry): number | undefined { 23 | // Explicit proxy port takes precedence 24 | if (service.proxy?.app_port) { 25 | return service.proxy.app_port; 26 | } 27 | 28 | // Infer from exposed ports 29 | if (service.ports) { 30 | for (const port of service.ports) { 31 | if (!port.includes(':') && !isNaN(parseInt(port))) { 32 | return parseInt(port); // "3000" -> 3000 33 | } 34 | if (port.startsWith(':') && !isNaN(parseInt(port.substring(1)))) { 35 | return parseInt(port.substring(1)); // ":3000" -> 3000 36 | } 37 | } 38 | } 39 | 40 | return undefined; 41 | } 42 | 43 | /** 44 | * Checks if a port configuration indicates infrastructure service 45 | */ 46 | export function isInfrastructurePort(port: string): boolean { 47 | // Common infrastructure ports that are typically mapped, not exposed 48 | const infraPorts = ['5432', '3306', '6379', '27017', '9200', '5672']; 49 | 50 | if (port.includes(':')) { 51 | const parts = port.split(':'); 52 | const containerPort = parts[parts.length - 1]; 53 | return infraPorts.includes(containerPort); 54 | } 55 | 56 | return infraPorts.includes(port); 57 | } -------------------------------------------------------------------------------- /packages/proxy/internal/core/interfaces.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | ) 7 | 8 | // RouteProvider provides routing information 9 | type RouteProvider interface { 10 | GetRoute(hostname string) (*Route, error) 11 | UpdateRoute(hostname string, target string, healthy bool) error 12 | } 13 | 14 | // DeploymentStore manages deployment persistence 15 | type DeploymentStore interface { 16 | GetDeployment(hostname string) (*Deployment, error) 17 | SaveDeployment(deployment *Deployment) error 18 | ListDeployments() ([]*Deployment, error) 19 | DeleteDeployment(hostname string) error 20 | } 21 | 22 | // HealthChecker checks container health 23 | type HealthChecker interface { 24 | CheckHealth(ctx context.Context, target, healthPath string) error 25 | } 26 | 27 | // CertificateProvider manages TLS certificates 28 | type CertificateProvider interface { 29 | GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) 30 | ServeHTTPChallenge(token string) (keyAuth string, found bool) 31 | EnsureCertificate(hostname string) error 32 | } 33 | 34 | // EventBus publishes and subscribes to events 35 | type EventBus interface { 36 | Publish(event Event) 37 | Subscribe() <-chan Event 38 | Unsubscribe(ch <-chan Event) 39 | } 40 | 41 | // DeploymentController orchestrates deployments 42 | type DeploymentController interface { 43 | Deploy(ctx context.Context, hostname, target, project, app string) error 44 | GetStatus(hostname string) (*Deployment, error) 45 | } 46 | 47 | // ProxyRouter routes HTTP requests 48 | type ProxyRouter interface { 49 | ServeHTTP(w ResponseWriter, r *Request) 50 | UpdateRoute(hostname string, target string, healthy bool) 51 | } 52 | 53 | // Simplified HTTP interfaces to avoid circular dependencies 54 | type ResponseWriter interface { 55 | Header() map[string][]string 56 | Write([]byte) (int, error) 57 | WriteHeader(statusCode int) 58 | } 59 | 60 | type Request interface { 61 | GetHost() string 62 | GetPath() string 63 | GetMethod() string 64 | GetHeader(key string) string 65 | SetHeader(key, value string) 66 | IsTLS() bool 67 | } -------------------------------------------------------------------------------- /examples/nextjs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | WORKDIR /app 8 | 9 | # Install dependencies based on the preferred package manager 10 | COPY package.json bun.lock* ./ 11 | # Since the project uses bun, let's install bun first 12 | RUN npm install -g bun 13 | RUN bun install --frozen-lockfile 14 | 15 | # Rebuild the source code only when needed 16 | FROM base AS builder 17 | WORKDIR /app 18 | COPY --from=deps /app/node_modules ./node_modules 19 | COPY . . 20 | 21 | # Install bun in builder stage 22 | RUN npm install -g bun 23 | 24 | # Next.js collects completely anonymous telemetry data about general usage. 25 | # Learn more here: https://nextjs.org/telemetry 26 | # Uncomment the following line in case you want to disable telemetry during the build. 27 | ENV NEXT_TELEMETRY_DISABLED=1 28 | 29 | RUN bun run build 30 | 31 | # Production image, copy all the files and run next 32 | FROM base AS runner 33 | WORKDIR /app 34 | 35 | ENV NODE_ENV=production 36 | # Uncomment the following line in case you want to disable telemetry during runtime. 37 | ENV NEXT_TELEMETRY_DISABLED=1 38 | 39 | RUN addgroup --system --gid 1001 nodejs 40 | RUN adduser --system --uid 1001 nextjs 41 | 42 | COPY --from=builder /app/public ./public 43 | 44 | # Set the correct permission for prerender cache 45 | RUN mkdir .next 46 | RUN chown nextjs:nodejs .next 47 | 48 | # Automatically leverage output traces to reduce image size 49 | # https://nextjs.org/docs/advanced-features/output-file-tracing 50 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 51 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 52 | 53 | USER nextjs 54 | 55 | EXPOSE 3000 56 | 57 | ENV PORT=3000 58 | 59 | # server.js is created by next build from the standalone output 60 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 61 | CMD HOSTNAME="0.0.0.0" node server.js -------------------------------------------------------------------------------- /examples/plausible/iop.yml: -------------------------------------------------------------------------------- 1 | name: plausible 2 | 3 | ssh: 4 | username: iop 5 | 6 | # Unified services model - everything is a service! 7 | # Services with 'proxy' config get zero-downtime deployment automatically 8 | services: 9 | # HTTP service (gets zero-downtime deployment) 10 | plausible: 11 | image: ghcr.io/plausible/community-edition:v3.0.1 12 | server: 65.21.181.70 13 | command: sh -c "/entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" 14 | environment: 15 | plain: 16 | - TZ=UTC 17 | - TMPDIR=/var/lib/plausible/tmp 18 | - HTTP_PORT=3000 19 | - CLICKHOUSE_DATABASE_URL=http://plausible_events_db:8123/plausible_events_db 20 | - DATABASE_URL=postgres://postgres:postgres@plausible_db:5432/plausible_db 21 | - DISABLE_REGISTRATION=true 22 | - ENABLE_EMAIL_VERIFICATION=false 23 | secret: 24 | - BASE_URL 25 | - SECRET_KEY_BASE 26 | - TOTP_VAULT_KEY 27 | - DATABASE_URL 28 | proxy: # This triggers zero-downtime deployment 29 | hosts: 30 | - analytics.eliasson.me 31 | app_port: 3000 32 | health_check: 33 | path: /api/health 34 | 35 | # Infrastructure services (get stop-start deployment) 36 | plausible_db: 37 | image: postgres:16-alpine 38 | server: 65.21.181.70 39 | environment: 40 | secret: 41 | - POSTGRES_PASSWORD 42 | plain: 43 | - POSTGRES_USER=postgres 44 | - POSTGRES_DB=plausible_db 45 | volumes: 46 | - plausible-db-data:/var/lib/postgresql/data 47 | 48 | plausible_events_db: 49 | image: clickhouse/clickhouse-server:24.12-alpine 50 | server: 65.21.181.70 51 | environment: 52 | plain: 53 | - CLICKHOUSE_SKIP_USER_SETUP=1 54 | volumes: 55 | - ./event-data:/var/lib/clickhouse 56 | - ./event-logs:/var/log/clickhouse-server 57 | - ./clickhouse/logs.xml:/etc/clickhouse-server/config.d/logs.xml:ro 58 | - ./clickhouse/ipv4-only.xml:/etc/clickhouse-server/config.d/ipv4-only.xml:ro 59 | - ./clickhouse/low-resources.xml:/etc/clickhouse-server/config.d/low-resources.xml:ro 60 | -------------------------------------------------------------------------------- /examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | # Next.js + iop Deployment Example 2 | 3 | This is a [Next.js](https://nextjs.org) project that demonstrates zero-downtime deployments using [iop](https://github.com/elitan/iop). 4 | 5 | ## 🚀 Quick Start with iop 6 | 7 | ### 1. Development Setup 8 | 9 | First, run the development server: 10 | 11 | ```bash 12 | npm run dev 13 | # or 14 | yarn dev 15 | # or 16 | pnpm dev 17 | # or 18 | bun dev 19 | ``` 20 | 21 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 22 | 23 | ### 2. Configure Your Server 24 | 25 | **Important**: If you cloned this example, you need to update the server configuration: 26 | 27 | 1. Edit `iop.yml` and replace `157.180.25.101` with your actual server IP or domain 28 | 2. Update any domain references if you're using custom hosts 29 | 30 | ### 3. Deploy with `iop` 31 | 32 | This example includes a complete `iop` configuration for zero-downtime deployment: 33 | 34 | ```bash 35 | iop 36 | ``` 37 | 38 | ## 🐳 Docker Configuration 39 | 40 | The included `Dockerfile` is optimized for Next.js deployments with iop: 41 | 42 | ### Features 43 | 44 | - **Multi-stage build**: Optimized for production with minimal image size 45 | - **Standalone output**: Uses Next.js standalone mode for efficient containerization 46 | - **Security**: Runs as non-root user 47 | - **Cache optimization**: Leverages Docker layer caching for faster builds 48 | 49 | ## 📚 Learn More 50 | 51 | ### Next.js Resources 52 | 53 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API 54 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial 55 | 56 | ### iop Resources 57 | 58 | - [iop Documentation](https://github.com/elitan/iop) - zero-downtime Docker deployments 59 | - [iop Examples](https://github.com/elitan/iop/tree/main/examples) - more deployment examples 60 | 61 | ## 🆚 Why iop vs Vercel? 62 | 63 | - **Your own servers** - full control, no vendor lock-in 64 | - **Cost-effective** - pay only for your servers, not per deployment 65 | - **No cold starts** - your containers are always running 66 | - **Zero-downtime deployments** - blue-green deployments out of the box 67 | - **Automatic SSL** - Let's Encrypt certificates managed automatically 68 | 69 | --- 70 | 71 | **This example demonstrates how easy it is to deploy Next.js applications with zero downtime using iop! 🚀** 72 | -------------------------------------------------------------------------------- /packages/cli/tests/image-tagging.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, mock } from 'bun:test'; 2 | import { DockerClient } from '../src/docker'; 3 | 4 | describe('Docker Image Tagging', () => { 5 | describe('build with multiple tags', () => { 6 | it('should create both release-specific and :latest tags during build', async () => { 7 | // Mock DockerClient.build to capture the tags parameter 8 | const mockBuild = mock(); 9 | DockerClient.build = mockBuild; 10 | mockBuild.mockResolvedValue(undefined); 11 | 12 | // This simulates what happens in buildOrTagServiceImage 13 | const imageNameWithRelease = 'basic-web1:d21ade7'; 14 | const baseImageName = 'basic-web1'; 15 | const latestTag = `${baseImageName}:latest`; 16 | 17 | await DockerClient.build({ 18 | context: '.', 19 | dockerfile: 'Dockerfile', 20 | tags: [imageNameWithRelease, latestTag], 21 | buildArgs: {}, 22 | platform: 'linux/amd64', 23 | verbose: false, 24 | }); 25 | 26 | // Verify both tags were provided to build 27 | expect(mockBuild).toHaveBeenCalledWith({ 28 | context: '.', 29 | dockerfile: 'Dockerfile', 30 | tags: ['basic-web1:d21ade7', 'basic-web1:latest'], 31 | buildArgs: {}, 32 | platform: 'linux/amd64', 33 | verbose: false, 34 | }); 35 | }); 36 | }); 37 | 38 | describe('tag existing images with multiple tags', () => { 39 | it('should create both release-specific and :latest tags when tagging existing images', async () => { 40 | // Mock DockerClient.tag to capture multiple calls 41 | const mockTag = mock(); 42 | DockerClient.tag = mockTag; 43 | mockTag.mockResolvedValue(undefined); 44 | 45 | // This simulates what happens for pre-built services 46 | const baseImageName = 'nginx:1.21'; 47 | const imageNameWithRelease = 'nginx:d21ade7'; 48 | const latestTag = 'nginx:latest'; 49 | 50 | await DockerClient.tag(baseImageName, imageNameWithRelease, false); 51 | await DockerClient.tag(baseImageName, latestTag, false); 52 | 53 | // Verify both tagging operations occurred 54 | expect(mockTag).toHaveBeenCalledTimes(2); 55 | expect(mockTag).toHaveBeenNthCalledWith(1, baseImageName, imageNameWithRelease, false); 56 | expect(mockTag).toHaveBeenNthCalledWith(2, baseImageName, latestTag, false); 57 | }); 58 | }); 59 | }); -------------------------------------------------------------------------------- /packages/proxy/internal/core/models.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "time" 4 | 5 | // Deployment represents a blue-green deployment 6 | type Deployment struct { 7 | ID string 8 | Hostname string 9 | Blue Container 10 | Green Container 11 | Active Color 12 | UpdatedAt time.Time 13 | } 14 | 15 | // Container represents a deployed container 16 | type Container struct { 17 | ID string 18 | Target string // "localhost:3001" 19 | HealthPath string // "/health" 20 | HealthState HealthState 21 | StartedAt time.Time 22 | } 23 | 24 | // Color represents blue or green in deployments 25 | type Color string 26 | 27 | const ( 28 | Blue Color = "blue" 29 | Green Color = "green" 30 | ) 31 | 32 | // HealthState represents container health 33 | type HealthState string 34 | 35 | const ( 36 | HealthUnknown HealthState = "unknown" 37 | HealthChecking HealthState = "checking" 38 | HealthHealthy HealthState = "healthy" 39 | HealthUnhealthy HealthState = "unhealthy" 40 | HealthStopped HealthState = "stopped" 41 | ) 42 | 43 | // Route represents the active routing configuration 44 | type Route struct { 45 | Hostname string 46 | Target string 47 | Healthy bool 48 | } 49 | 50 | // Event represents a deployment event 51 | type Event interface { 52 | EventTime() time.Time 53 | } 54 | 55 | // BaseEvent provides common event fields 56 | type BaseEvent struct { 57 | Timestamp time.Time 58 | Hostname string 59 | } 60 | 61 | func (e BaseEvent) EventTime() time.Time { 62 | return e.Timestamp 63 | } 64 | 65 | // DeploymentStarted indicates a new deployment has begun 66 | type DeploymentStarted struct { 67 | BaseEvent 68 | DeploymentID string 69 | Color Color 70 | Target string 71 | } 72 | 73 | // HealthCheckPassed indicates a container passed health checks 74 | type HealthCheckPassed struct { 75 | BaseEvent 76 | DeploymentID string 77 | Color Color 78 | } 79 | 80 | // TrafficSwitched indicates traffic was switched to a new container 81 | type TrafficSwitched struct { 82 | BaseEvent 83 | DeploymentID string 84 | FromColor Color 85 | ToColor Color 86 | FromTarget string 87 | ToTarget string 88 | } 89 | 90 | // DeploymentCompleted indicates a deployment finished successfully 91 | type DeploymentCompleted struct { 92 | BaseEvent 93 | DeploymentID string 94 | Color Color 95 | } 96 | 97 | // DeploymentFailed indicates a deployment failed 98 | type DeploymentFailed struct { 99 | BaseEvent 100 | DeploymentID string 101 | Color Color 102 | Error string 103 | } -------------------------------------------------------------------------------- /packages/proxy/internal/test/debug_blue_green_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/elitan/iop/proxy/internal/router" 12 | "github.com/elitan/iop/proxy/internal/state" 13 | ) 14 | 15 | // TestDebugBlueGreen helps us understand what's happening 16 | func TestDebugBlueGreen(t *testing.T) { 17 | stateFile := "test-debug.json" 18 | defer os.Remove(stateFile) 19 | 20 | st := state.NewState(stateFile) 21 | rt := router.NewFixedRouter(st, nil) 22 | 23 | // Blue backend 24 | blue := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | w.Write([]byte("blue")) 26 | })) 27 | defer blue.Close() 28 | blueAddr := blue.Listener.Addr().String() 29 | t.Logf("Blue backend: %s", blueAddr) 30 | 31 | // Green backend 32 | green := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | w.Write([]byte("green")) 34 | })) 35 | defer green.Close() 36 | greenAddr := green.Listener.Addr().String() 37 | t.Logf("Green backend: %s", greenAddr) 38 | 39 | // Deploy blue 40 | err := st.DeployHost("test.com", blueAddr, "test", "web", "/health", false) 41 | if err != nil { 42 | t.Fatalf("Failed to deploy: %v", err) 43 | } 44 | st.UpdateHealthStatus("test.com", true) 45 | 46 | // Check initial state 47 | host1, _, _ := st.GetHost("test.com") 48 | t.Logf("Initial target: %s", host1.Target) 49 | 50 | // Make a request to blue 51 | req := httptest.NewRequest("GET", "/", nil) 52 | req.Host = "test.com" 53 | w := httptest.NewRecorder() 54 | rt.ServeHTTP(w, req) 55 | t.Logf("Response 1: %s", w.Body.String()) 56 | 57 | // Switch to green 58 | err = st.SwitchTarget("test.com", greenAddr) 59 | if err != nil { 60 | t.Fatalf("Failed to switch: %v", err) 61 | } 62 | 63 | // Check state after switch 64 | host2, _, _ := st.GetHost("test.com") 65 | t.Logf("Target after switch: %s", host2.Target) 66 | 67 | // Make a request after switch 68 | w2 := httptest.NewRecorder() 69 | rt.ServeHTTP(w2, req) 70 | t.Logf("Response 2: %s", w2.Body.String()) 71 | 72 | // Make multiple requests to see behavior 73 | var wg sync.WaitGroup 74 | for i := 0; i < 5; i++ { 75 | wg.Add(1) 76 | go func(n int) { 77 | defer wg.Done() 78 | time.Sleep(time.Duration(n*10) * time.Millisecond) 79 | 80 | req := httptest.NewRequest("GET", "/", nil) 81 | req.Host = "test.com" 82 | w := httptest.NewRecorder() 83 | rt.ServeHTTP(w, req) 84 | 85 | host, _, _ := st.GetHost("test.com") 86 | t.Logf("Request %d: response=%s, state_target=%s", n, w.Body.String(), host.Target) 87 | }(i) 88 | } 89 | wg.Wait() 90 | } -------------------------------------------------------------------------------- /packages/proxy/internal/health/checker.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/elitan/iop/proxy/internal/state" 11 | ) 12 | 13 | type Checker struct { 14 | state *state.State 15 | client *http.Client 16 | } 17 | 18 | // NewChecker creates a new health checker 19 | func NewChecker(st *state.State) *Checker { 20 | return &Checker{ 21 | state: st, 22 | client: &http.Client{ 23 | Timeout: 5 * time.Second, 24 | Transport: &http.Transport{ 25 | MaxIdleConns: 100, 26 | MaxIdleConnsPerHost: 10, 27 | IdleConnTimeout: 90 * time.Second, 28 | }, 29 | }, 30 | } 31 | } 32 | 33 | // Start begins the health checking loop 34 | func (c *Checker) Start(ctx context.Context) { 35 | log.Println("[HEALTH] Starting health checker") 36 | 37 | // Initial health check for all hosts 38 | c.checkAllHosts() 39 | 40 | // Start periodic health checks 41 | ticker := time.NewTicker(30 * time.Second) 42 | defer ticker.Stop() 43 | 44 | for { 45 | select { 46 | case <-ticker.C: 47 | c.checkAllHosts() 48 | case <-ctx.Done(): 49 | log.Println("[HEALTH] Stopping health checker") 50 | return 51 | } 52 | } 53 | } 54 | 55 | // CheckHost performs a health check on a specific host 56 | func (c *Checker) CheckHost(hostname string) error { 57 | host, _, err := c.state.GetHost(hostname) 58 | if err != nil { 59 | return fmt.Errorf("host not found: %w", err) 60 | } 61 | 62 | // Build health check URL 63 | url := fmt.Sprintf("http://%s%s", host.Target, host.HealthPath) 64 | 65 | // Perform health check 66 | start := time.Now() 67 | resp, err := c.client.Get(url) 68 | duration := time.Since(start) 69 | 70 | if err != nil { 71 | log.Printf("[HEALTH] [%s] Check failed: %v", hostname, err) 72 | c.state.UpdateHealthStatus(hostname, false) 73 | return err 74 | } 75 | defer resp.Body.Close() 76 | 77 | // Check status code 78 | healthy := resp.StatusCode >= 200 && resp.StatusCode < 300 79 | c.state.UpdateHealthStatus(hostname, healthy) 80 | 81 | if healthy { 82 | log.Printf("[HEALTH] [%s] Check passed: %d OK (%dms)", hostname, resp.StatusCode, duration.Milliseconds()) 83 | } else { 84 | log.Printf("[HEALTH] [%s] Check failed: %d (%dms)", hostname, resp.StatusCode, duration.Milliseconds()) 85 | } 86 | 87 | return nil 88 | } 89 | 90 | // checkAllHosts performs health checks on all configured hosts 91 | func (c *Checker) checkAllHosts() { 92 | hosts := c.state.GetAllHosts() 93 | 94 | for hostname := range hosts { 95 | go func(h string) { 96 | if err := c.CheckHost(h); err != nil { 97 | // Error already logged in CheckHost 98 | } 99 | }(hostname) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [main, develop] 8 | 9 | jobs: 10 | test-proxy: 11 | name: Test Go Proxy 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: "1.21" 21 | 22 | - name: Cache Go modules 23 | uses: actions/cache@v3 24 | with: 25 | path: | 26 | ~/.cache/go-build 27 | ~/go/pkg/mod 28 | key: ${{ runner.os }}-go-${{ hashFiles('packages/proxy/go.sum') }} 29 | restore-keys: | 30 | ${{ runner.os }}-go- 31 | 32 | - name: Install Go dependencies 33 | working-directory: ./packages/proxy 34 | run: go mod download 35 | 36 | - name: Run Go tests 37 | working-directory: ./packages/proxy 38 | run: go test -v ./... 39 | 40 | - name: Run Go tests with race detection 41 | working-directory: ./packages/proxy 42 | run: go test -v -race ./... 43 | 44 | test-cli: 45 | name: Test Bun CLI 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | 51 | - name: Set up Bun 52 | uses: oven-sh/setup-bun@v1 53 | with: 54 | bun-version: latest 55 | 56 | - name: Cache Bun dependencies 57 | uses: actions/cache@v3 58 | with: 59 | path: | 60 | ~/.bun/install/cache 61 | packages/cli/node_modules 62 | key: ${{ runner.os }}-bun-${{ hashFiles('packages/cli/bun.lock') }} 63 | restore-keys: | 64 | ${{ runner.os }}-bun- 65 | 66 | - name: Install Bun dependencies 67 | working-directory: ./packages/cli 68 | run: bun install 69 | 70 | - name: Run Bun tests 71 | working-directory: ./packages/cli 72 | run: bun test 73 | 74 | - name: Build CLI 75 | working-directory: ./packages/cli 76 | run: bun run build 77 | 78 | - name: Test CLI binary 79 | run: | 80 | if [ -f "packages/cli/dist/index.js" ]; then 81 | echo "CLI built successfully" 82 | node packages/cli/dist/index.js --help || echo "CLI help command executed" 83 | else 84 | echo "Build failed - packages/cli/dist/index.js not found" 85 | exit 1 86 | fi 87 | 88 | test-matrix: 89 | name: Cross-platform tests 90 | runs-on: ${{ matrix.os }} 91 | strategy: 92 | matrix: 93 | os: [ubuntu-latest, macos-latest] 94 | 95 | steps: 96 | - uses: actions/checkout@v4 97 | 98 | - name: Set up Go 99 | uses: actions/setup-go@v4 100 | with: 101 | go-version: "1.21" 102 | 103 | - name: Set up Bun 104 | uses: oven-sh/setup-bun@v1 105 | with: 106 | bun-version: latest 107 | 108 | - name: Install Go dependencies 109 | working-directory: ./packages/proxy 110 | run: go mod download 111 | 112 | - name: Install Bun dependencies 113 | working-directory: ./packages/cli 114 | run: bun install 115 | 116 | - name: Test Go proxy (basic) 117 | working-directory: ./packages/proxy 118 | run: go test ./... 119 | 120 | - name: Test Bun CLI 121 | working-directory: ./packages/cli 122 | run: bun test 123 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # iop Release Script 4 | # Handles version bumping, npm publishing, and GitHub releases with auto-generated notes 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | # Check if we're in the right directory 16 | if [[ ! -f "packages/cli/package.json" ]]; then 17 | echo -e "${RED}Error: Must be run from the repository root${NC}" 18 | exit 1 19 | fi 20 | 21 | # Check if gh CLI is installed 22 | if ! command -v gh &> /dev/null; then 23 | echo -e "${RED}Error: GitHub CLI (gh) is required but not installed${NC}" 24 | echo -e "${YELLOW}Install with: brew install gh${NC}" 25 | exit 1 26 | fi 27 | 28 | # Check if logged into GitHub 29 | if ! gh auth status &> /dev/null; then 30 | echo -e "${RED}Error: Not logged into GitHub CLI${NC}" 31 | echo -e "${YELLOW}Run: gh auth login${NC}" 32 | exit 1 33 | fi 34 | 35 | # Get the version type (patch, minor, major) 36 | VERSION_TYPE=${1:-patch} 37 | 38 | if [[ ! "$VERSION_TYPE" =~ ^(patch|minor|major)$ ]]; then 39 | echo -e "${RED}Error: Version type must be patch, minor, or major${NC}" 40 | echo "Usage: $0 [patch|minor|major]" 41 | exit 1 42 | fi 43 | 44 | echo -e "${BLUE}🚀 Starting iop release process...${NC}" 45 | 46 | # Change to CLI directory 47 | cd packages/cli 48 | 49 | # Get current version 50 | CURRENT_VERSION=$(node -p "require('./package.json').version") 51 | echo -e "${YELLOW}Current version: ${CURRENT_VERSION}${NC}" 52 | 53 | # Check for uncommitted changes 54 | if [[ -n $(git status --porcelain) ]]; then 55 | echo -e "${RED}Error: You have uncommitted changes. Please commit or stash them first.${NC}" 56 | exit 1 57 | fi 58 | 59 | # Ensure we're on main branch 60 | CURRENT_BRANCH=$(git branch --show-current) 61 | if [[ "$CURRENT_BRANCH" != "main" ]]; then 62 | echo -e "${RED}Error: You must be on the main branch to create a release${NC}" 63 | echo -e "${YELLOW}Current branch: ${CURRENT_BRANCH}${NC}" 64 | exit 1 65 | fi 66 | 67 | # Pull latest changes 68 | echo -e "${YELLOW}Pulling latest changes...${NC}" 69 | git pull origin main 70 | 71 | # Bump version 72 | echo -e "${YELLOW}Bumping ${VERSION_TYPE} version...${NC}" 73 | npm version $VERSION_TYPE --no-git-tag-version --silent > /dev/null 74 | NEW_VERSION=$(node -p "require('./package.json').version") 75 | echo -e "${GREEN}New version: ${NEW_VERSION}${NC}" 76 | 77 | # Build the package 78 | echo -e "${YELLOW}Building package...${NC}" 79 | bun run build 80 | 81 | # Commit version bump 82 | cd ../.. 83 | git add packages/cli/package.json package-lock.json 84 | git commit -m "chore: bump version to v${NEW_VERSION}" 85 | 86 | # Create and push tag 87 | echo -e "${YELLOW}Creating and pushing tag...${NC}" 88 | git tag "v${NEW_VERSION}" 89 | git push origin main --tags 90 | 91 | # Publish to npm 92 | echo -e "${YELLOW}Publishing to npm...${NC}" 93 | cd packages/cli 94 | npm publish 95 | cd ../.. 96 | 97 | # Create GitHub release with auto-generated notes 98 | echo -e "${YELLOW}Creating GitHub release with auto-generated notes...${NC}" 99 | gh release create "v${NEW_VERSION}" \ 100 | --title "v${NEW_VERSION}" \ 101 | --generate-notes \ 102 | --latest 103 | 104 | echo 105 | echo -e "${GREEN}✅ Release v${NEW_VERSION} completed successfully!${NC}" 106 | echo -e "${GREEN}📦 npm: https://www.npmjs.com/package/iop/v/${NEW_VERSION}${NC}" 107 | echo -e "${GREEN}🐙 GitHub: https://github.com/elitan/iop/releases/tag/v${NEW_VERSION}${NC}" 108 | echo 109 | echo -e "${BLUE}🎉 The release notes were auto-generated based on PRs and commits!${NC}" 110 | echo -e "${BLUE}💡 Use labels on PRs (feature, bug, breaking-change, etc.) for better categorization${NC}" -------------------------------------------------------------------------------- /load.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # --- Configuration --- 4 | LOAD_BALANCER_URL="https://test.eliasson.me" # IMPORTANT: Replace with your actual URL 5 | TEST_DURATION="10s" # How long each test should run (e.g., 10 seconds) 6 | CONCURRENCY=50 # Number of concurrent connections 7 | NUMBER_OF_REQUESTS=0 # If 0, hey will run for TEST_DURATION. If set, it will run for this many requests. 8 | OUTPUT_FILE="hey_results.json" 9 | LOG_FILE="load_test.log" 10 | 11 | # --- Functions --- 12 | 13 | log() { 14 | echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE" 15 | } 16 | 17 | run_hey_test() { 18 | local target_url=$1 19 | log "Starting hey test on $target_url..." 20 | log "Duration: $TEST_DURATION, Concurrency: $CONCURRENCY" 21 | 22 | if [ "$NUMBER_OF_REQUESTS" -gt 0 ]; then 23 | hey_command="hey -n $NUMBER_OF_REQUESTS -c $CONCURRENCY -o json \"$target_url\"" 24 | else 25 | hey_command="hey -z $TEST_DURATION -c $CONCURRENCY -o json \"$target_url\"" 26 | fi 27 | 28 | log "Executing: $hey_command" 29 | eval "$hey_command" > "$OUTPUT_FILE" 2>> "$LOG_FILE" 30 | 31 | if [ $? -eq 0 ]; then 32 | log "hey test completed successfully. Results saved to $OUTPUT_FILE." 33 | else 34 | log "Error: hey test failed. Check $LOG_FILE for details." 35 | return 1 36 | fi 37 | } 38 | 39 | parse_hey_results() { 40 | if [ ! -f "$OUTPUT_FILE" ]; then 41 | log "Error: Output file $OUTPUT_FILE not found." 42 | return 1 43 | fi 44 | 45 | log "Parsing hey results from $OUTPUT_FILE..." 46 | 47 | # Use jq to extract key metrics 48 | local total_requests=$(jq '.totalRequests' "$OUTPUT_FILE") 49 | local total_duration=$(jq '.totalDuration' "$OUTPUT_FILE") 50 | local requests_per_second=$(jq '.rps' "$OUTPUT_FILE") 51 | local p90_latency_ms=$(jq '.latency.p90 / 1000000' "$OUTPUT_FILE") # Convert ns to ms 52 | local p99_latency_ms=$(jq '.latency.p99 / 1000000' "$OUTPUT_FILE") # Convert ns to ms 53 | local http_errors=$(jq '.errorDist."200"' "$OUTPUT_FILE") # Example: Count 200 OK responses 54 | 55 | # Calculate actual HTTP errors (status codes other than 2xx normally) 56 | local total_responses=$(jq '.totalRequests' "$OUTPUT_FILE") 57 | local success_2xx_responses=$(jq '[.statusCodeDist | to_entries[] | select(.key | startswith("2")) | .value] | add' "$OUTPUT_FILE") 58 | local non_2xx_responses=$((total_responses - success_2xx_responses)) 59 | 60 | log "--- Test Results for $LOAD_BALANCER_URL ---" 61 | log "Total Requests: $total_requests" 62 | log "Total Duration: $(printf "%.2f" $(echo "$total_duration / 1000000000" | bc -l)) seconds" # Convert ns to seconds 63 | log "Requests per Second (RPS): $(printf "%.2f" $requests_per_second)" 64 | log "P90 Latency: $(printf "%.2f" $p90_latency_ms) ms" 65 | log "P99 Latency: $(printf "%.2f" $p99_latency_ms) ms" 66 | log "Non-2xx HTTP Responses: $non_2xx_responses (out of $total_responses total)" 67 | log "-------------------------------------------" 68 | 69 | # You can add more parsing here as needed 70 | } 71 | 72 | # --- Main Script Execution --- 73 | 74 | # Check for `hey` and `jq` 75 | if ! command -v hey &> /dev/null; then 76 | log "Error: 'hey' command not found. Please install it (e.g., go install github.com/rakyll/hey@latest)." 77 | exit 1 78 | fi 79 | if ! command -v jq &> /dev/null; then 80 | log "Error: 'jq' command not found. Please install it (e.g., sudo apt-get install jq or brew install jq)." 81 | exit 1 82 | fi 83 | 84 | # Clean up previous output 85 | rm -f "$OUTPUT_FILE" "$LOG_FILE" 86 | 87 | # Run the test 88 | run_hey_test "$LOAD_BALANCER_URL" 89 | 90 | # Parse and display results 91 | if [ $? -eq 0 ]; then 92 | parse_hey_results 93 | else 94 | log "Test failed, skipping result parsing." 95 | fi 96 | 97 | log "Script finished." -------------------------------------------------------------------------------- /packages/cli/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // General utilities will go here 2 | 3 | export * from "./release"; 4 | // Add other utils exports here as they are created 5 | 6 | export function getProjectNetworkName(projectName: string): string { 7 | if (!projectName || projectName.trim() === "") { 8 | throw new Error( 9 | "Project name cannot be empty when generating network name." 10 | ); 11 | } 12 | // Sanitize project name to be DNS-friendly for network naming 13 | const sanitizedProjectName = projectName 14 | .toLowerCase() 15 | .replace(/[^a-z0-9-]/g, "-"); 16 | return `${sanitizedProjectName}-network`; 17 | } 18 | 19 | export * from "./port-checker"; 20 | export * from "./config-validator"; 21 | 22 | /** 23 | * Sanitizes a string to be safe for use as a folder name 24 | */ 25 | export function sanitizeFolderName(name: string): string { 26 | return name 27 | .replace(/[^a-zA-Z0-9\-_]/g, "-") // Replace invalid chars with hyphens 28 | .replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens 29 | .replace(/-+/g, "-") // Collapse multiple hyphens 30 | .toLowerCase(); 31 | } 32 | 33 | /** 34 | * Processes volume mappings to ensure project isolation and proper path resolution 35 | * @param volumes Array of volume mappings (e.g., ["mydata:/data", "./local:/app"]) 36 | * @param projectName Project name for prefixing 37 | * @returns Processed volume mappings with project prefixes and resolved paths 38 | */ 39 | export function processVolumes( 40 | volumes: string[] | undefined, 41 | projectName: string 42 | ): string[] { 43 | if (!volumes || volumes.length === 0) { 44 | return []; 45 | } 46 | 47 | const sanitizedProjectName = sanitizeFolderName(projectName); 48 | 49 | return volumes.map((volume) => { 50 | const [source, destination, ...rest] = volume.split(":"); 51 | 52 | if (!destination) { 53 | throw new Error( 54 | `Invalid volume mapping: "${volume}". Expected format: "source:destination" or "source:destination:options"` 55 | ); 56 | } 57 | 58 | let processedSource: string; 59 | 60 | if ( 61 | source.startsWith("./") || 62 | source.startsWith("../") || 63 | !source.startsWith("/") 64 | ) { 65 | // Relative path or local directory - convert to project-specific bind mount 66 | const relativePath = source.startsWith("./") ? source.slice(2) : source; 67 | processedSource = `~/.iop/projects/${sanitizedProjectName}/${relativePath}`; 68 | } else if (source.startsWith("/")) { 69 | // Absolute path - use as-is (but warn user) 70 | processedSource = source; 71 | } else { 72 | // Named volume - prefix with project name 73 | const sanitizedVolumeName = sanitizeFolderName(source); 74 | processedSource = `${sanitizedProjectName}-${sanitizedVolumeName}`; 75 | } 76 | 77 | // Reconstruct the volume mapping 78 | const processedVolume = 79 | rest.length > 0 80 | ? `${processedSource}:${destination}:${rest.join(":")}` 81 | : `${processedSource}:${destination}`; 82 | 83 | return processedVolume; 84 | }); 85 | } 86 | 87 | /** 88 | * Creates necessary project directories on the remote server 89 | * @param sshClient SSH client connection 90 | * @param projectName Project name 91 | */ 92 | export async function ensureProjectDirectories( 93 | sshClient: any, 94 | projectName: string 95 | ): Promise { 96 | const sanitizedProjectName = sanitizeFolderName(projectName); 97 | const projectDir = `~/.iop/projects/${sanitizedProjectName}`; 98 | 99 | try { 100 | await sshClient.exec(`mkdir -p "${projectDir}"`); 101 | } catch (error) { 102 | throw new Error( 103 | `Failed to create project directory ${projectDir}: ${error}` 104 | ); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /packages/proxy/internal/test/behavior_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "testing" 8 | 9 | "github.com/elitan/iop/proxy/internal/router" 10 | "github.com/elitan/iop/proxy/internal/state" 11 | ) 12 | 13 | // TestCurrentProxyBehavior documents the behavior we need to preserve 14 | func TestCurrentProxyBehavior(t *testing.T) { 15 | // Create a temporary state file 16 | stateFile := "test-state.json" 17 | defer func() { 18 | // Cleanup 19 | _ = os.Remove(stateFile) 20 | }() 21 | 22 | t.Run("basic routing", func(t *testing.T) { 23 | // Setup 24 | st := state.NewState(stateFile) 25 | 26 | // Create a test backend 27 | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | w.Write([]byte("hello from backend")) 29 | })) 30 | defer backend.Close() 31 | 32 | // Deploy a host 33 | err := st.DeployHost("test.example.com", backend.Listener.Addr().String(), "test-project", "web", "/health", false) 34 | if err != nil { 35 | t.Fatalf("Failed to deploy host: %v", err) 36 | } 37 | 38 | // Mark as healthy 39 | err = st.UpdateHealthStatus("test.example.com", true) 40 | if err != nil { 41 | t.Fatalf("Failed to update health status: %v", err) 42 | } 43 | 44 | // Create router with mock cert manager 45 | rt := router.NewRouter(st, nil) // We'll need to handle nil cert manager 46 | 47 | // Test routing 48 | req := httptest.NewRequest("GET", "/", nil) 49 | req.Host = "test.example.com" 50 | w := httptest.NewRecorder() 51 | 52 | rt.ServeHTTP(w, req) 53 | 54 | if w.Code != http.StatusOK { 55 | t.Errorf("Expected 200, got %d", w.Code) 56 | } 57 | 58 | if w.Body.String() != "hello from backend" { 59 | t.Errorf("Expected 'hello from backend', got %s", w.Body.String()) 60 | } 61 | }) 62 | 63 | t.Run("unhealthy backend returns 503", func(t *testing.T) { 64 | st := state.NewState(stateFile) 65 | 66 | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 67 | w.Write([]byte("should not see this")) 68 | })) 69 | defer backend.Close() 70 | 71 | // Deploy and mark as unhealthy 72 | st.DeployHost("unhealthy.example.com", backend.Listener.Addr().String(), "test-project", "web", "/health", false) 73 | st.UpdateHealthStatus("unhealthy.example.com", false) 74 | 75 | rt := router.NewRouter(st, nil) 76 | 77 | req := httptest.NewRequest("GET", "/", nil) 78 | req.Host = "unhealthy.example.com" 79 | w := httptest.NewRecorder() 80 | 81 | rt.ServeHTTP(w, req) 82 | 83 | if w.Code != http.StatusServiceUnavailable { 84 | t.Errorf("Expected 503, got %d", w.Code) 85 | } 86 | }) 87 | 88 | t.Run("switch target changes routing", func(t *testing.T) { 89 | st := state.NewState(stateFile) 90 | 91 | // Two backends 92 | blue := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 93 | w.Write([]byte("blue")) 94 | })) 95 | defer blue.Close() 96 | 97 | green := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 98 | w.Write([]byte("green")) 99 | })) 100 | defer green.Close() 101 | 102 | // Start with blue 103 | st.DeployHost("switch.example.com", blue.Listener.Addr().String(), "test-project", "web", "/health", false) 104 | st.UpdateHealthStatus("switch.example.com", true) 105 | 106 | rt := router.NewRouter(st, nil) 107 | 108 | // Test blue 109 | req := httptest.NewRequest("GET", "/", nil) 110 | req.Host = "switch.example.com" 111 | w := httptest.NewRecorder() 112 | rt.ServeHTTP(w, req) 113 | 114 | if w.Body.String() != "blue" { 115 | t.Errorf("Expected 'blue', got %s", w.Body.String()) 116 | } 117 | 118 | // Switch to green 119 | err := st.SwitchTarget("switch.example.com", green.Listener.Addr().String()) 120 | if err != nil { 121 | t.Fatalf("Failed to switch target: %v", err) 122 | } 123 | 124 | // Test green 125 | w = httptest.NewRecorder() 126 | rt.ServeHTTP(w, req) 127 | 128 | if w.Body.String() != "green" { 129 | t.Errorf("Expected 'green' after switch, got %s", w.Body.String()) 130 | } 131 | }) 132 | } -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | This document outlines the automated release process for iop. 4 | 5 | ## 🚀 Quick Release 6 | 7 | ```bash 8 | # Patch release (0.2.5 → 0.2.6) 9 | bun run release 10 | 11 | # Minor release (0.2.5 → 0.3.0) 12 | bun run release:minor 13 | 14 | # Major release (0.2.5 → 1.0.0) 15 | bun run release:major 16 | ``` 17 | 18 | ## 🤖 What Happens Automatically 19 | 20 | The release script (`scripts/release.sh`) performs these steps: 21 | 22 | 1. **Validation** 23 | - ✅ Checks you're on `main` branch 24 | - ✅ Ensures no uncommitted changes 25 | - ✅ Verifies GitHub CLI is installed and authenticated 26 | 27 | 2. **Version Management** 28 | - ✅ Pulls latest changes from `main` 29 | - ✅ Bumps package version in `package.json` 30 | - ✅ Commits version bump with conventional commit message 31 | - ✅ Creates and pushes Git tag 32 | 33 | 3. **Publishing** 34 | - ✅ Builds TypeScript CLI package 35 | - ✅ Publishes to npm registry 36 | - ✅ Creates GitHub release with **auto-generated notes** 37 | 38 | ## 📝 Auto-Generated Release Notes 39 | 40 | GitHub automatically generates release notes based on: 41 | 42 | - **Merged Pull Requests** with proper categorization 43 | - **Contributors** with first-time contributor recognition 44 | - **Commit history** between releases 45 | - **Custom categories** defined in `.github/release.yml` 46 | 47 | ### PR Label Categories 48 | 49 | Use these labels on PRs for automatic categorization: 50 | 51 | | Label | Category | Example | 52 | |-------|----------|---------| 53 | | `breaking-change`, `breaking`, `major` | 🚨 Breaking Changes | API changes | 54 | | `feature`, `enhancement`, `new-feature` | 🚀 New Features | New commands | 55 | | `bug`, `bugfix`, `fix` | 🐛 Bug Fixes | Fix deployment issues | 56 | | `documentation`, `docs` | 📚 Documentation | README updates | 57 | | `maintenance`, `refactor`, `cleanup` | 🧹 Maintenance | Code refactoring | 58 | | `infrastructure`, `ci`, `build` | 🔧 Infrastructure | CI improvements | 59 | | `performance`, `optimization` | ⚡ Performance | Speed improvements | 60 | | `security`, `vulnerability` | 🔒 Security | Security fixes | 61 | 62 | ### Excluded from Release Notes 63 | 64 | - PRs labeled with: `chore`, `dependencies`, `ignore-for-release` 65 | - Commits from: `dependabot`, `github-actions` 66 | 67 | ## 📋 Manual Release Checklist 68 | 69 | When doing releases manually or debugging issues: 70 | 71 | 1. **Pre-Release** 72 | - [ ] All PRs properly labeled 73 | - [ ] Tests passing in CI 74 | - [ ] On `main` branch with clean working directory 75 | 76 | 2. **Release** 77 | - [ ] Run appropriate release command 78 | - [ ] Verify npm package published 79 | - [ ] Check GitHub release created 80 | - [ ] Verify release notes look good 81 | 82 | 3. **Post-Release** 83 | - [ ] Update any dependent projects 84 | - [ ] Announce release if significant 85 | - [ ] Close related issues/milestones 86 | 87 | ## 🔍 Troubleshooting 88 | 89 | ### "Not logged into GitHub CLI" 90 | ```bash 91 | gh auth login 92 | ``` 93 | 94 | ### "You have uncommitted changes" 95 | ```bash 96 | git status 97 | git add . && git commit -m "your message" 98 | # or 99 | git stash 100 | ``` 101 | 102 | ### "Must be on main branch" 103 | ```bash 104 | git checkout main 105 | git pull origin main 106 | ``` 107 | 108 | ### Release failed after npm publish 109 | If the script fails after npm publish but before GitHub release: 110 | ```bash 111 | # Manually create the GitHub release 112 | gh release create v0.2.6 --generate-notes --latest 113 | ``` 114 | 115 | ## 🎯 Best Practices 116 | 117 | 1. **Use descriptive PR titles** - they become changelog entries 118 | 2. **Label PRs appropriately** - enables proper categorization 119 | 3. **Keep commits atomic** - easier to understand changes 120 | 4. **Test before releasing** - run `bun run build` and `bun run test` 121 | 5. **Follow semantic versioning**: 122 | - `patch`: Bug fixes, small improvements 123 | - `minor`: New features, non-breaking changes 124 | - `major`: Breaking changes 125 | 126 | ## 🔗 Links 127 | 128 | - [GitHub Release Notes Documentation](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) 129 | - [Conventional Commits](https://www.conventionalcommits.org/) 130 | - [Semantic Versioning](https://semver.org/) -------------------------------------------------------------------------------- /docs/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Welcome to iop 3 | description: Zero-downtime Docker deployments to your own servers 4 | --- 5 | 6 | # iop: Ship Docker Anywhere ⚡ 7 | 8 | iop is a zero-downtime Docker deployment tool that lets you deploy any Docker application to your own servers with automatic HTTPS and no configuration complexity. 9 | 10 | ## Core Features 11 | 12 | - ✅ **Zero-downtime blue-green deployments** - Apps deploy with no service interruption 13 | - ✅ **Registry-free deployment** - Build locally, transfer via SSH (no Docker registry needed) 14 | - ✅ **Automatic server setup** - Fresh server bootstrap with security hardening 15 | - ✅ **Auto-SSL with Let's Encrypt** - HTTPS certificates managed automatically 16 | - ✅ **Multi-server support** - Deploy across multiple servers with load balancing 17 | - ✅ **Git-based releases** - Easy rollbacks by checking out previous commits 18 | - ✅ **Health checks and automatic rollbacks** - Built-in reliability 19 | 20 | ## Quick Start 21 | 22 | Get started with iop in just a few steps: 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ## Philosophy 32 | 33 | **No rollback commands needed** - just checkout the git commit you want and run `iop` again. 34 | 35 | ## Why Choose iop? 36 | 37 | ### vs Kamal (37signals) 38 | 39 | - **TypeScript/Bun** instead of Ruby for better performance 40 | - **Registry-free deployments** - no need for external Docker registries 41 | - **Automatic server hardening** - security built-in from day one 42 | 43 | ### vs Vercel/Netlify 44 | 45 | - **Your own servers** - full control, no vendor lock-in 46 | - **Any Docker app** - not limited to specific frameworks 47 | - **Cost-effective** - pay only for your servers, not per deployment 48 | - **No cold starts** - your containers are always running 49 | 50 | ### vs Docker Compose 51 | 52 | - **Zero-downtime deployments** - compose restarts cause downtime 53 | - **Multi-server support** - deploy across multiple machines 54 | - **Automatic SSL** and reverse proxy included 55 | - **Git-based releases** and rollback capabilities 56 | 57 | ## Key Concepts 58 | 59 | ### Apps vs Services 60 | 61 | iop distinguishes between two types of deployments: 62 | 63 | - **Apps** - User-facing applications that get **zero-downtime blue-green deployments** 64 | - **Services** - Infrastructure components (databases, caches) that get **direct replacement** 65 | 66 | ### Deployment Architecture 67 | 68 | ```mermaid 69 | graph LR 70 | A[Local Build] --> B[SSH Transfer] 71 | B --> C[Blue-Green Deploy] 72 | C --> D[Health Check] 73 | D --> E[Traffic Switch] 74 | E --> F[Cleanup Old] 75 | ``` 76 | 77 | 1. **Local Build** - Docker images built on your machine 78 | 2. **SSH Transfer** - Images compressed and sent via SSH (no registry) 79 | 3. **Blue-Green Deploy** - New version starts alongside old version 80 | 4. **Health Check** - New version validated before traffic switch 81 | 5. **Traffic Switch** - Atomic switch via network aliases 82 | 6. **Cleanup** - Old version containers removed 83 | 84 | ## Example Deployment 85 | 86 | ```bash 87 | ❯ iop 88 | Using Git SHA for release ID: 9d8209a 89 | Starting deployment with release 9d8209a 90 | 91 | [✓] Configuration loaded (0ms) 92 | [✓] Git status verified (3ms) 93 | [✓] Infrastructure ready (1.2s) 94 | [✓] Building Images (3.3s) 95 | └─ web → my-app/web:9d8209a 96 | [✓] Deploying to Servers (4.2s) 97 | └─ 157.180.25.101 98 | ├─ [✓] Loading image (2.5s) 99 | ├─ [✓] Zero-downtime deployment (1.4s) 100 | └─ [✓] Configuring proxy (319ms) 101 | [✓] Deployment completed successfully in 8.8s 102 | 103 | Your app is live at: 104 | └─ https://test.eliasson.me 105 | ``` 106 | 107 | ## Commands Overview 108 | 109 | - **`iop init`** - Initialize iop.yml config and secrets file 110 | - **`iop`** - Deploy apps and services (default command, auto-sets up infrastructure) 111 | - **`iop status`** - Check deployment status across all servers 112 | - **`iop proxy`** - Manage the reverse proxy (status, update, logs, delete-host) 113 | 114 | ## Get Started 115 | 116 | Ready to deploy? Follow our [Quick Start guide](/quick-start) to deploy your first application with iop. -------------------------------------------------------------------------------- /packages/cli/tests/docker-service-aliases.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { DockerClient } from "../src/docker"; 3 | import type { ServiceEntry, IopSecrets } from "../src/config/types"; 4 | 5 | describe("Docker service network aliases", () => { 6 | test("serviceToContainerOptions should include service name as network alias", () => { 7 | // Arrange 8 | const service: ServiceEntry = { 9 | name: "db", 10 | image: "postgres:15", 11 | server: "server.example.com", 12 | environment: { 13 | plain: ["POSTGRES_DB=testdb"], 14 | secret: ["POSTGRES_PASSWORD"], 15 | }, 16 | ports: ["5432:5432"], 17 | volumes: ["postgres_data:/var/lib/postgresql/data"], 18 | }; 19 | 20 | const projectName = "test-project"; 21 | const secrets: IopSecrets = { 22 | POSTGRES_PASSWORD: "secret123", 23 | }; 24 | 25 | // Act 26 | const options = DockerClient.serviceToContainerOptions( 27 | service, 28 | projectName, 29 | secrets 30 | ); 31 | 32 | // Assert 33 | expect(options.name).toBe("test-project-db"); 34 | expect(options.image).toBe("postgres:15"); 35 | expect(options.network).toBe("test-project-network"); 36 | expect(options.networkAliases).toBeDefined(); 37 | expect(options.networkAliases).toContain("db"); 38 | expect(options.networkAliases?.length).toBe(1); 39 | expect(options.ports).toContain("5432:5432"); 40 | expect(options.volumes).toContain( 41 | "~/.iop/projects/test-project/postgres_data:/var/lib/postgresql/data" 42 | ); 43 | expect(options.labels).toMatchObject({ 44 | "iop.managed": "true", 45 | "iop.project": "test-project", 46 | "iop.type": "service", 47 | "iop.service": "db", 48 | }); 49 | }); 50 | 51 | test("serviceToContainerOptions should work with different service names", () => { 52 | // Arrange 53 | const service: ServiceEntry = { 54 | name: "redis-cache", 55 | image: "redis:7", 56 | server: "server.example.com", 57 | }; 58 | 59 | const projectName = "my-app"; 60 | const secrets: IopSecrets = {}; 61 | 62 | // Act 63 | const options = DockerClient.serviceToContainerOptions( 64 | service, 65 | projectName, 66 | secrets 67 | ); 68 | 69 | // Assert 70 | expect(options.name).toBe("my-app-redis-cache"); 71 | expect(options.networkAliases).toContain("redis-cache"); 72 | }); 73 | 74 | test("serviceToContainerOptions should handle services without environment variables", () => { 75 | // Arrange 76 | const service: ServiceEntry = { 77 | name: "nginx", 78 | image: "nginx:latest", 79 | server: "server.example.com", 80 | ports: ["80:80"], 81 | }; 82 | 83 | const projectName = "web-project"; 84 | const secrets: IopSecrets = {}; 85 | 86 | // Act 87 | const options = DockerClient.serviceToContainerOptions( 88 | service, 89 | projectName, 90 | secrets 91 | ); 92 | 93 | // Assert 94 | expect(options.name).toBe("web-project-nginx"); 95 | expect(options.networkAliases).toContain("nginx"); 96 | expect(options.envVars).toBeDefined(); 97 | expect(Object.keys(options.envVars!).length).toBe(0); 98 | }); 99 | 100 | test("serviceToContainerOptions should include secret environment variables", () => { 101 | // Arrange 102 | const service: ServiceEntry = { 103 | name: "app-service", 104 | image: "myapp:latest", 105 | server: "server.example.com", 106 | environment: { 107 | plain: ["NODE_ENV=production"], 108 | secret: ["DATABASE_URL", "API_SECRET"], 109 | }, 110 | }; 111 | 112 | const projectName = "production-app"; 113 | const secrets: IopSecrets = { 114 | DATABASE_URL: "postgres://user:pass@db:5432/myapp", 115 | API_SECRET: "super-secret-key", 116 | }; 117 | 118 | // Act 119 | const options = DockerClient.serviceToContainerOptions( 120 | service, 121 | projectName, 122 | secrets 123 | ); 124 | 125 | // Assert 126 | expect(options.networkAliases).toContain("app-service"); 127 | expect(options.envVars!["NODE_ENV"]).toBe("production"); 128 | expect(options.envVars!["DATABASE_URL"]).toBe( 129 | "postgres://user:pass@db:5432/myapp" 130 | ); 131 | expect(options.envVars!["API_SECRET"]).toBe("super-secret-key"); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |
7 | Next.js logo 15 |
    16 |
  1. 17 | Get started by editing{" "} 18 | 19 | src/app/page.tsx 20 | 21 | . 22 |
  2. 23 |
  3. 24 | Save and see your changes instantly. 25 |
  4. 26 |
27 | 28 |
29 | 35 | Vercel logomark 42 | Deploy now 43 | 44 | 50 | Read our docs 51 | 52 |
53 |
54 | 101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /packages/cli/src/ssh/utils.ts: -------------------------------------------------------------------------------- 1 | import { IopConfig, IopSecrets } from "../config/types"; 2 | import { SSHClientOptions } from "./index"; 3 | import * as fs from "fs"; 4 | import * as os from "os"; 5 | 6 | /** 7 | * Get SSH credentials for connecting to a server. 8 | * This function handles credentials consistently across all commands. 9 | */ 10 | export async function getSSHCredentials( 11 | serverHostname: string, 12 | config: IopConfig, 13 | secrets: IopSecrets, 14 | verbose: boolean = false 15 | ): Promise> { 16 | const sshUser = config.ssh?.username || "root"; // Default to root, though setup warns against it 17 | const sshPort = config.ssh?.port || 22; 18 | const sshOptions: Partial = { 19 | username: sshUser, 20 | host: serverHostname, 21 | port: sshPort, 22 | verbose: verbose, 23 | }; 24 | 25 | // Check for server-specific key path in secrets 26 | const serverSpecificKeyEnvVar = `SSH_KEY_${serverHostname 27 | .replace(/\./g, "_") 28 | .toUpperCase()}`; 29 | const serverSpecificKeyPath = secrets[serverSpecificKeyEnvVar]; 30 | if (serverSpecificKeyPath) { 31 | if (verbose) { 32 | console.log( 33 | `[${serverHostname}] Attempting SSH with server-specific key from secrets (${serverSpecificKeyEnvVar}): ${serverSpecificKeyPath}` 34 | ); 35 | } 36 | sshOptions.identity = serverSpecificKeyPath; 37 | return sshOptions; 38 | } 39 | 40 | // Check for key_file in config 41 | const configKeyFile = config.ssh?.key_file; 42 | if (configKeyFile) { 43 | // Expand ~ to home directory 44 | const expandedPath = configKeyFile.replace(/^~/, os.homedir()); 45 | if (fs.existsSync(expandedPath)) { 46 | if (verbose) { 47 | console.log( 48 | `[${serverHostname}] Attempting SSH with key file from config: ${expandedPath}` 49 | ); 50 | } 51 | sshOptions.identity = expandedPath; 52 | return sshOptions; 53 | } else if (verbose) { 54 | console.log( 55 | `[${serverHostname}] Config key_file ${expandedPath} does not exist, skipping...` 56 | ); 57 | } 58 | } 59 | 60 | 61 | // Check for server-specific password in secrets 62 | const serverSpecificPasswordEnvVar = `SSH_PASSWORD_${serverHostname 63 | .replace(/\./g, "_") 64 | .toUpperCase()}`; 65 | const serverSpecificPassword = secrets[serverSpecificPasswordEnvVar]; 66 | if (serverSpecificPassword) { 67 | if (verbose) { 68 | console.log( 69 | `[${serverHostname}] Attempting SSH with server-specific password from secrets (${serverSpecificPasswordEnvVar}).` 70 | ); 71 | } 72 | sshOptions.password = serverSpecificPassword; 73 | return sshOptions; 74 | } 75 | 76 | // Check for default password in secrets 77 | const defaultPassword = secrets.DEFAULT_SSH_PASSWORD; 78 | if (defaultPassword) { 79 | if (verbose) { 80 | console.log( 81 | `[${serverHostname}] Attempting SSH with default password from secrets (DEFAULT_SSH_PASSWORD).` 82 | ); 83 | } 84 | sshOptions.password = defaultPassword; 85 | return sshOptions; 86 | } 87 | 88 | // Try to find common SSH key files in the user's home directory 89 | const homeDir = os.homedir(); 90 | if (verbose) { 91 | console.log(`[${serverHostname}] Home directory resolved as: ${homeDir}`); 92 | } 93 | 94 | // Check for common SSH key files 95 | try { 96 | const keyPaths = [ 97 | `${homeDir}/.ssh/id_rsa`, 98 | `${homeDir}/.ssh/id_ed25519`, 99 | `${homeDir}/.ssh/id_ecdsa`, 100 | `${homeDir}/.ssh/id_dsa`, 101 | ]; 102 | 103 | for (const keyPath of keyPaths) { 104 | if (fs.existsSync(keyPath)) { 105 | sshOptions.identity = keyPath; 106 | if (verbose) { 107 | console.log( 108 | `[${serverHostname}] Explicitly using SSH key at: ${keyPath}` 109 | ); 110 | } 111 | break; // Stop after finding the first existing key 112 | } 113 | } 114 | 115 | if (!sshOptions.identity && verbose) { 116 | console.log( 117 | `[${serverHostname}] No SSH keys found at standard locations: ${keyPaths.join( 118 | ", " 119 | )}` 120 | ); 121 | } 122 | } catch (error) { 123 | if (verbose) { 124 | console.error( 125 | `[${serverHostname}] Error checking for default SSH keys:`, 126 | error 127 | ); 128 | } 129 | } 130 | 131 | if (verbose) { 132 | console.log( 133 | `[${serverHostname}] No specific SSH key or password found in iop secrets. Attempting agent-based authentication or found key file.` 134 | ); 135 | } 136 | 137 | return sshOptions; 138 | } 139 | -------------------------------------------------------------------------------- /packages/cli/tests/proxy-integration.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "bun:test"; 2 | import { exec } from "child_process"; 3 | import { promisify } from "util"; 4 | 5 | const execAsync = promisify(exec); 6 | 7 | describe("Proxy Integration Tests", () => { 8 | const testTargets = { 9 | gmail: { 10 | domain: "test.eliasson.me", 11 | expectedContent: "Hello World 2", 12 | projectAlias: "gmail-web", 13 | }, 14 | nextjs: { 15 | domain: "nextjs.example.myiop.cloud", 16 | expectedContent: "", 17 | projectAlias: "iop-example-nextjs-web", 18 | }, 19 | }; 20 | 21 | test.skip("External HTTPS access should work for both projects", async () => { 22 | // Test Gmail project 23 | const { stdout: gmailResponse } = await execAsync( 24 | `curl -s https://${testTargets.gmail.domain}` 25 | ); 26 | expect(gmailResponse.trim()).toBe(testTargets.gmail.expectedContent); 27 | 28 | // Test NextJS project 29 | const { stdout: nextjsResponse } = await execAsync( 30 | `curl -s https://${testTargets.nextjs.domain}` 31 | ); 32 | expect(nextjsResponse).toContain(testTargets.nextjs.expectedContent); 33 | }); 34 | 35 | test.skip("Proxy should use project-specific DNS targets", async () => { 36 | try { 37 | const { stdout, stderr } = await execAsync( 38 | `ssh iop@157.180.25.101 "docker exec iop-proxy /usr/local/bin/iop-proxy list"` 39 | ); 40 | // Combine stdout and stderr since proxy output might go to stderr 41 | const proxyConfig = stdout + stderr; 42 | 43 | // Verify Gmail route configuration 44 | expect(proxyConfig).toContain(`Host: ${testTargets.gmail.domain}`); 45 | expect(proxyConfig).toContain( 46 | `Target: ${testTargets.gmail.projectAlias}:3000` 47 | ); 48 | expect(proxyConfig).toContain(`Project: gmail`); 49 | 50 | // Verify NextJS route configuration 51 | expect(proxyConfig).toContain(`Host: ${testTargets.nextjs.domain}`); 52 | expect(proxyConfig).toContain( 53 | `Target: ${testTargets.nextjs.projectAlias}:3000` 54 | ); 55 | expect(proxyConfig).toContain(`Project: iop-example-nextjs`); 56 | } catch (error) { 57 | // Handle SSH execution error by checking stderr 58 | const errorOutput = String(error); 59 | expect(errorOutput).toContain(`Host: ${testTargets.gmail.domain}`); 60 | } 61 | }); 62 | 63 | test.skip("Internal DNS resolution should work via project-specific aliases", async () => { 64 | // Test Gmail project-specific alias from proxy 65 | const { stdout: gmailInternal } = await execAsync( 66 | `ssh iop@157.180.25.101 "docker exec iop-proxy curl -s http://${testTargets.gmail.projectAlias}:3000"` 67 | ); 68 | expect(gmailInternal.trim()).toBe(testTargets.gmail.expectedContent); 69 | 70 | // Test NextJS project-specific alias from proxy 71 | const { stdout: nextjsInternal } = await execAsync( 72 | `ssh iop@157.180.25.101 "docker exec iop-proxy curl -s http://${testTargets.nextjs.projectAlias}:3000"` 73 | ); 74 | expect(nextjsInternal).toContain(testTargets.nextjs.expectedContent); 75 | }); 76 | 77 | test.skip("Both projects should be healthy", async () => { 78 | try { 79 | const { stdout, stderr } = await execAsync( 80 | `ssh iop@157.180.25.101 "docker exec iop-proxy /usr/local/bin/iop-proxy list"` 81 | ); 82 | const proxyConfig = stdout + stderr; 83 | 84 | // Check that both projects show as healthy 85 | const lines = proxyConfig.split("\n"); 86 | const gmailHealthLine = lines.find((line) => 87 | line.includes(testTargets.gmail.domain) 88 | ); 89 | const nextjsHealthLine = lines.find((line) => 90 | line.includes(testTargets.nextjs.domain) 91 | ); 92 | 93 | // Check if we found the lines and if they're healthy 94 | expect(gmailHealthLine || "").toContain("✅ Healthy"); 95 | expect(nextjsHealthLine || "").toContain("✅ Healthy"); 96 | } catch (error) { 97 | // Handle SSH execution error by checking stderr contains health info 98 | const errorOutput = String(error); 99 | expect(errorOutput).toContain("✅ Healthy"); 100 | } 101 | }); 102 | 103 | test.skip("Projects should return different content (proving isolation)", async () => { 104 | const { stdout: gmailResponse } = await execAsync( 105 | `curl -s https://${testTargets.gmail.domain}` 106 | ); 107 | const { stdout: nextjsResponse } = await execAsync( 108 | `curl -s https://${testTargets.nextjs.domain}` 109 | ); 110 | 111 | // They should be different 112 | expect(gmailResponse).not.toEqual(nextjsResponse); 113 | 114 | // Each should contain their expected content 115 | expect(gmailResponse.trim()).toBe(testTargets.gmail.expectedContent); 116 | expect(nextjsResponse).toContain(testTargets.nextjs.expectedContent); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /packages/cli/tests/config.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | test, 5 | beforeEach, 6 | afterEach, 7 | mock, 8 | spyOn, 9 | } from "bun:test"; 10 | import { loadConfig, loadSecrets } from "../src/config"; 11 | import fs from "node:fs/promises"; 12 | import { rmSync, existsSync } from "node:fs"; 13 | import { join } from "node:path"; 14 | import { mkdir } from "node:fs/promises"; 15 | 16 | // Test in a temporary directory 17 | const TEST_DIR = "./test-config-tmp"; 18 | 19 | describe("config module", () => { 20 | beforeEach(async () => { 21 | // Create a fresh test directory 22 | try { 23 | rmSync(TEST_DIR, { recursive: true, force: true }); 24 | } catch (error) { 25 | // Ignore if directory doesn't exist 26 | } 27 | 28 | await mkdir(TEST_DIR, { recursive: true }); 29 | await mkdir(join(TEST_DIR, ".iop"), { recursive: true }); 30 | process.chdir(TEST_DIR); 31 | }); 32 | 33 | afterEach(() => { 34 | // Clean up and go back to original directory 35 | process.chdir(".."); 36 | try { 37 | rmSync(TEST_DIR, { recursive: true, force: true }); 38 | } catch (error) { 39 | console.error(`Failed to clean up test directory: ${error}`); 40 | } 41 | }); 42 | 43 | describe("loadConfig", () => { 44 | test("should load a valid config file", async () => { 45 | // Create a valid config file 46 | const validConfig = ` 47 | name: test-project 48 | services: 49 | web-app: 50 | image: test/webapp:latest 51 | server: server1.example.com 52 | ports: 53 | - "80:8080" 54 | volumes: 55 | - data:/app/data 56 | environment: 57 | plain: 58 | - NODE_ENV=production 59 | secret: 60 | - API_KEY 61 | `; 62 | await Bun.write("iop.yml", validConfig); 63 | 64 | // Load the config 65 | const config = await loadConfig(); 66 | 67 | // Verify the loaded config 68 | expect(config.name).toBe("test-project"); 69 | expect(config.services).toBeDefined(); 70 | expect(config.services!["web-app"]).not.toBeUndefined(); 71 | expect(config.services!["web-app"].image).toBe("test/webapp:latest"); 72 | expect(config.services!["web-app"].server).toBe("server1.example.com"); 73 | expect(config.services!["web-app"].ports).toContain("80:8080"); 74 | expect(config.services!["web-app"].volumes).toContain("data:/app/data"); 75 | expect(config.services!["web-app"].environment?.plain).toContain( 76 | "NODE_ENV=production" 77 | ); 78 | expect(config.services!["web-app"].environment?.secret).toContain( 79 | "API_KEY" 80 | ); 81 | }); 82 | 83 | test("should throw an error for invalid config", async () => { 84 | // Create an invalid config file (missing required 'image' field) 85 | const invalidConfig = ` 86 | name: test-project 87 | services: 88 | web-app: 89 | # Missing required 'image' field 90 | server: server1.example.com 91 | `; 92 | await Bun.write("iop.yml", invalidConfig); 93 | 94 | // Expect loadConfig to throw 95 | await expect(loadConfig()).rejects.toThrow(); 96 | }); 97 | 98 | test("should throw an error if config file doesn't exist", async () => { 99 | // Don't create a config file 100 | await expect(loadConfig()).rejects.toThrow(); 101 | }); 102 | }); 103 | 104 | describe("loadSecrets", () => { 105 | test("should load secrets file correctly", async () => { 106 | // Create a secrets file 107 | const secretsContent = ` 108 | # This is a comment 109 | API_KEY=1234567890 110 | DATABASE_URL=postgres://user:pass@host:5432/db 111 | SECRET_WITH_EQUALS=value=with=equals 112 | `; 113 | await Bun.write(join(".iop", "secrets"), secretsContent); 114 | 115 | // Load secrets 116 | const secrets = await loadSecrets(); 117 | 118 | // Verify the loaded secrets 119 | expect(secrets.API_KEY).toBe("1234567890"); 120 | expect(secrets.DATABASE_URL).toBe("postgres://user:pass@host:5432/db"); 121 | expect(secrets.SECRET_WITH_EQUALS).toBe("value=with=equals"); 122 | expect(Object.keys(secrets).length).toBe(3); // Comments should be ignored 123 | }); 124 | 125 | test("should return empty object when secrets file doesn't exist", async () => { 126 | // No secrets file exists 127 | const secrets = await loadSecrets(); 128 | 129 | // Should return an empty object 130 | expect(Object.keys(secrets).length).toBe(0); 131 | }); 132 | 133 | test("should handle quoted values", async () => { 134 | // Create a secrets file with quoted values 135 | const secretsContent = ` 136 | QUOTED_VALUE_1="this is a quoted value" 137 | QUOTED_VALUE_2='another quoted value' 138 | `; 139 | await Bun.write(join(".iop", "secrets"), secretsContent); 140 | 141 | // Load secrets 142 | const secrets = await loadSecrets(); 143 | 144 | // Verify quotes are stripped 145 | expect(secrets.QUOTED_VALUE_1).toBe("this is a quoted value"); 146 | expect(secrets.QUOTED_VALUE_2).toBe("another quoted value"); 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /packages/proxy/internal/deployment/controller_test.go: -------------------------------------------------------------------------------- 1 | package deployment 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/elitan/iop/proxy/internal/core" 11 | "github.com/elitan/iop/proxy/internal/events" 12 | "github.com/elitan/iop/proxy/internal/storage" 13 | ) 14 | 15 | // mockHealthChecker always returns success or failure based on configuration 16 | type mockHealthChecker struct { 17 | shouldPass bool 18 | } 19 | 20 | func (m *mockHealthChecker) CheckHealth(ctx context.Context, target, healthPath string) error { 21 | if m.shouldPass { 22 | return nil 23 | } 24 | return fmt.Errorf("health check failed") 25 | } 26 | 27 | // mockProxyUpdater captures route updates 28 | type mockProxyUpdater struct { 29 | mu sync.Mutex 30 | routes map[string]mockRoute 31 | } 32 | 33 | type mockRoute struct { 34 | target string 35 | healthy bool 36 | } 37 | 38 | func newMockProxyUpdater() *mockProxyUpdater { 39 | return &mockProxyUpdater{ 40 | routes: make(map[string]mockRoute), 41 | } 42 | } 43 | 44 | func (m *mockProxyUpdater) UpdateRoute(hostname, target string, healthy bool) { 45 | m.mu.Lock() 46 | defer m.mu.Unlock() 47 | m.routes[hostname] = mockRoute{target: target, healthy: healthy} 48 | } 49 | 50 | func (m *mockProxyUpdater) GetRoute(hostname string) mockRoute { 51 | m.mu.Lock() 52 | defer m.mu.Unlock() 53 | return m.routes[hostname] 54 | } 55 | 56 | func TestController(t *testing.T) { 57 | // Setup 58 | store := storage.NewMemoryStore() 59 | eventBus := events.NewSimpleBus() 60 | healthService := &mockHealthChecker{shouldPass: true} 61 | proxyUpdater := newMockProxyUpdater() 62 | 63 | controller := NewController(store, proxyUpdater, healthService, eventBus) 64 | 65 | t.Run("successful deployment with immediate cleanup", func(t *testing.T) { 66 | ctx := context.Background() 67 | 68 | // Deploy first version (blue) 69 | err := controller.Deploy(ctx, "myapp.com", "myimage:v1", "myproject", "webapp") 70 | if err != nil { 71 | t.Fatalf("First deployment failed: %v", err) 72 | } 73 | 74 | // Wait for health check and traffic switch 75 | time.Sleep(100 * time.Millisecond) 76 | 77 | // Check deployment status 78 | deployment, err := controller.GetStatus("myapp.com") 79 | if err != nil { 80 | t.Fatalf("Failed to get deployment status: %v", err) 81 | } 82 | 83 | if deployment.Hostname != "myapp.com" { 84 | t.Errorf("Expected hostname myapp.com, got %s", deployment.Hostname) 85 | } 86 | 87 | // Check that traffic was routed correctly 88 | if proxyUpdater.GetRoute("myapp.com").target == "" { 89 | t.Error("Expected route to be set for myapp.com") 90 | } 91 | 92 | // Deploy second version (green) - should immediately clean up blue 93 | err = controller.Deploy(ctx, "myapp.com", "myimage:v2", "myproject", "webapp") 94 | if err != nil { 95 | t.Fatalf("Second deployment failed: %v", err) 96 | } 97 | 98 | // Wait for health check and traffic switch 99 | time.Sleep(100 * time.Millisecond) 100 | 101 | // Check final deployment status 102 | deployment, err = controller.GetStatus("myapp.com") 103 | if err != nil { 104 | t.Fatalf("Failed to get final deployment status: %v", err) 105 | } 106 | 107 | // Check that the active container is healthy 108 | var activeContainer core.Container 109 | if deployment.Active == core.Blue { 110 | activeContainer = deployment.Blue 111 | } else { 112 | activeContainer = deployment.Green 113 | } 114 | 115 | if activeContainer.HealthState != core.HealthHealthy { 116 | t.Errorf("Expected active container to be healthy, got %s", activeContainer.HealthState) 117 | } 118 | 119 | // Check that the inactive container was cleaned up (target should be empty) 120 | var inactiveContainer core.Container 121 | if deployment.Active == core.Blue { 122 | inactiveContainer = deployment.Green 123 | } else { 124 | inactiveContainer = deployment.Blue 125 | } 126 | 127 | if inactiveContainer.Target != "" && inactiveContainer.HealthState != core.HealthStopped { 128 | t.Errorf("Expected inactive container to be cleaned up, got target=%s, health=%s", 129 | inactiveContainer.Target, inactiveContainer.HealthState) 130 | } 131 | 132 | t.Log("Deployment with immediate cleanup completed successfully!") 133 | }) 134 | 135 | t.Run("container naming convention", func(t *testing.T) { 136 | controller := NewController(store, proxyUpdater, healthService, eventBus) 137 | 138 | // Test container name generation 139 | blueName := controller.generateContainerName("myapp.com", core.Blue) 140 | greenName := controller.generateContainerName("myapp.com", core.Green) 141 | 142 | expectedBlue := "myapp-com-blue" 143 | expectedGreen := "myapp-com-green" 144 | 145 | if blueName != expectedBlue { 146 | t.Errorf("Expected blue container name %s, got %s", expectedBlue, blueName) 147 | } 148 | 149 | if greenName != expectedGreen { 150 | t.Errorf("Expected green container name %s, got %s", expectedGreen, greenName) 151 | } 152 | 153 | // Test target extraction 154 | containerName := controller.extractContainerName("myapp-com-blue:3000") 155 | if containerName != "myapp-com-blue" { 156 | t.Errorf("Expected container name myapp-com-blue, got %s", containerName) 157 | } 158 | }) 159 | } -------------------------------------------------------------------------------- /packages/cli/tests/proxy-commands.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "bun:test"; 2 | 3 | describe("Proxy Command Argument Parsing Tests", () => { 4 | test("should parse proxy arguments correctly", () => { 5 | // Simple unit test for argument parsing logic 6 | function parseProxyArgs(args: string[]) { 7 | const verboseFlag = args.includes("--verbose"); 8 | 9 | let host: string | undefined; 10 | let lines: number | undefined; 11 | 12 | const cleanArgs: string[] = []; 13 | 14 | for (let i = 0; i < args.length; i++) { 15 | if (args[i] === "--verbose") { 16 | continue; 17 | } else if (args[i] === "--host" && i + 1 < args.length) { 18 | host = args[i + 1]; 19 | i++; // Skip the next argument since it's the host value 20 | } else if (args[i] === "--lines" && i + 1 < args.length) { 21 | lines = parseInt(args[i + 1], 10); 22 | i++; // Skip the next argument since it's the lines value 23 | } else { 24 | cleanArgs.push(args[i]); 25 | } 26 | } 27 | 28 | const subcommand = cleanArgs[0] || ""; 29 | 30 | return { 31 | subcommand, 32 | verboseFlag, 33 | host, 34 | lines, 35 | }; 36 | } 37 | 38 | // Test delete-host command parsing 39 | const deleteHostArgs = parseProxyArgs(["delete-host", "--host", "api.example.com", "--verbose"]); 40 | expect(deleteHostArgs.subcommand).toBe("delete-host"); 41 | expect(deleteHostArgs.host).toBe("api.example.com"); 42 | expect(deleteHostArgs.verboseFlag).toBe(true); 43 | 44 | // Test logs command parsing 45 | const logsArgs = parseProxyArgs(["logs", "--lines", "100"]); 46 | expect(logsArgs.subcommand).toBe("logs"); 47 | expect(logsArgs.lines).toBe(100); 48 | expect(logsArgs.verboseFlag).toBe(false); 49 | 50 | // Test status command (no extra args) 51 | const statusArgs = parseProxyArgs(["status"]); 52 | expect(statusArgs.subcommand).toBe("status"); 53 | expect(statusArgs.host).toBeUndefined(); 54 | expect(statusArgs.lines).toBeUndefined(); 55 | expect(statusArgs.verboseFlag).toBe(false); 56 | }); 57 | 58 | test("should handle invalid line numbers", () => { 59 | function parseProxyArgs(args: string[]) { 60 | const verboseFlag = args.includes("--verbose"); 61 | 62 | let host: string | undefined; 63 | let lines: number | undefined; 64 | 65 | const cleanArgs: string[] = []; 66 | 67 | for (let i = 0; i < args.length; i++) { 68 | if (args[i] === "--verbose") { 69 | continue; 70 | } else if (args[i] === "--host" && i + 1 < args.length) { 71 | host = args[i + 1]; 72 | i++; // Skip the next argument since it's the host value 73 | } else if (args[i] === "--lines" && i + 1 < args.length) { 74 | lines = parseInt(args[i + 1], 10); 75 | i++; // Skip the next argument since it's the lines value 76 | } else { 77 | cleanArgs.push(args[i]); 78 | } 79 | } 80 | 81 | const subcommand = cleanArgs[0] || ""; 82 | 83 | return { 84 | subcommand, 85 | verboseFlag, 86 | host, 87 | lines, 88 | }; 89 | } 90 | 91 | const invalidLinesArgs = parseProxyArgs(["logs", "--lines", "invalid"]); 92 | expect(invalidLinesArgs.lines).toBeNaN(); 93 | }); 94 | 95 | test("should validate command support", () => { 96 | const validCommands = ["status", "update", "delete-host", "logs"]; 97 | 98 | expect(validCommands.includes("status")).toBe(true); 99 | expect(validCommands.includes("update")).toBe(true); 100 | expect(validCommands.includes("delete-host")).toBe(true); 101 | expect(validCommands.includes("logs")).toBe(true); 102 | expect(validCommands.includes("unknown")).toBe(false); 103 | }); 104 | 105 | test("should construct correct docker commands", () => { 106 | const IOP_PROXY_NAME = "iop-proxy"; 107 | 108 | // Test delete-host command construction 109 | const deleteHostCmd = `docker exec ${IOP_PROXY_NAME} /usr/local/bin/iop-proxy delete-host api.example.com`; 110 | expect(deleteHostCmd).toBe("docker exec iop-proxy /usr/local/bin/iop-proxy delete-host api.example.com"); 111 | 112 | // Test logs command construction 113 | const logsCmd = `docker logs --tail 50 ${IOP_PROXY_NAME}`; 114 | expect(logsCmd).toBe("docker logs --tail 50 iop-proxy"); 115 | 116 | const customLogsCmd = `docker logs --tail 100 ${IOP_PROXY_NAME}`; 117 | expect(customLogsCmd).toBe("docker logs --tail 100 iop-proxy"); 118 | }); 119 | 120 | test("should handle missing required arguments", () => { 121 | // Test that delete-host requires --host flag 122 | function validateDeleteHost(host?: string): boolean { 123 | return !!host; 124 | } 125 | 126 | expect(validateDeleteHost("api.example.com")).toBe(true); 127 | expect(validateDeleteHost(undefined)).toBe(false); 128 | expect(validateDeleteHost("")).toBe(false); 129 | }); 130 | 131 | test("should set default values correctly", () => { 132 | // Test default lines for logs command 133 | const defaultLines = 50; 134 | const customLines = 100; 135 | 136 | function getLogLines(lines?: number): number { 137 | return lines || defaultLines; 138 | } 139 | 140 | expect(getLogLines()).toBe(50); 141 | expect(getLogLines(customLines)).toBe(100); 142 | expect(getLogLines(0)).toBe(50); // Falsy value should use default 143 | }); 144 | }); -------------------------------------------------------------------------------- /packages/proxy/internal/test/blue_green_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "sync/atomic" 8 | "testing" 9 | "time" 10 | 11 | "github.com/elitan/iop/proxy/internal/router" 12 | "github.com/elitan/iop/proxy/internal/state" 13 | ) 14 | 15 | // TestBlueGreenBehavior tests the specific behavior we need for zero-downtime deployments 16 | func TestBlueGreenBehavior(t *testing.T) { 17 | stateFile := "test-blue-green.json" 18 | defer os.Remove(stateFile) 19 | 20 | t.Run("sequential traffic switching", func(t *testing.T) { 21 | st := state.NewState(stateFile) 22 | rt := router.NewRouter(st, nil) 23 | 24 | // Blue backend 25 | blue := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | w.Write([]byte("blue")) 27 | })) 28 | defer blue.Close() 29 | 30 | // Green backend 31 | green := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | w.Write([]byte("green")) 33 | })) 34 | defer green.Close() 35 | 36 | // Deploy blue 37 | st.DeployHost("app.example.com", blue.Listener.Addr().String(), "test", "web", "/health", false) 38 | st.UpdateHealthStatus("app.example.com", true) 39 | 40 | // Make some requests to blue 41 | for i := 0; i < 3; i++ { 42 | req := httptest.NewRequest("GET", "/", nil) 43 | req.Host = "app.example.com" 44 | w := httptest.NewRecorder() 45 | rt.ServeHTTP(w, req) 46 | 47 | if w.Body.String() != "blue" { 48 | t.Errorf("Expected 'blue' before switch, got %s", w.Body.String()) 49 | } 50 | } 51 | 52 | // Switch to green 53 | st.SwitchTarget("app.example.com", green.Listener.Addr().String()) 54 | 55 | // Make requests to green 56 | for i := 0; i < 3; i++ { 57 | req := httptest.NewRequest("GET", "/", nil) 58 | req.Host = "app.example.com" 59 | w := httptest.NewRecorder() 60 | rt.ServeHTTP(w, req) 61 | 62 | if w.Body.String() != "green" { 63 | t.Errorf("Expected 'green' after switch, got %s", w.Body.String()) 64 | } 65 | } 66 | 67 | t.Log("Sequential traffic switching works correctly") 68 | }) 69 | 70 | t.Run("connection draining", func(t *testing.T) { 71 | st := state.NewState(stateFile) 72 | rt := router.NewRouter(st, nil) 73 | 74 | // Slow backend that takes 100ms to respond 75 | slowRequests := int32(0) 76 | slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 77 | atomic.AddInt32(&slowRequests, 1) 78 | time.Sleep(100 * time.Millisecond) 79 | w.Write([]byte("slow-response")) 80 | })) 81 | defer slow.Close() 82 | 83 | // Fast backend 84 | fast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 85 | w.Write([]byte("fast-response")) 86 | })) 87 | defer fast.Close() 88 | 89 | // Deploy slow backend 90 | st.DeployHost("drain.example.com", slow.Listener.Addr().String(), "test", "web", "/health", false) 91 | st.UpdateHealthStatus("drain.example.com", true) 92 | 93 | // Start a slow request 94 | done := make(chan string) 95 | go func() { 96 | req := httptest.NewRequest("GET", "/", nil) 97 | req.Host = "drain.example.com" 98 | w := httptest.NewRecorder() 99 | rt.ServeHTTP(w, req) 100 | done <- w.Body.String() 101 | }() 102 | 103 | // Switch to fast backend after 20ms (while slow request is in flight) 104 | time.Sleep(20 * time.Millisecond) 105 | st.SwitchTarget("drain.example.com", fast.Listener.Addr().String()) 106 | 107 | // The in-flight request should complete with the slow backend response 108 | result := <-done 109 | if result != "slow-response" { 110 | t.Errorf("Expected 'slow-response' for in-flight request, got %s", result) 111 | } 112 | 113 | // New requests should go to fast backend 114 | req := httptest.NewRequest("GET", "/", nil) 115 | req.Host = "drain.example.com" 116 | w := httptest.NewRecorder() 117 | rt.ServeHTTP(w, req) 118 | 119 | if w.Body.String() != "fast-response" { 120 | t.Errorf("Expected 'fast-response' for new request, got %s", w.Body.String()) 121 | } 122 | }) 123 | 124 | t.Run("health check prevents unhealthy switch", func(t *testing.T) { 125 | st := state.NewState(stateFile) 126 | rt := router.NewRouter(st, nil) 127 | 128 | // Healthy backend 129 | healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 130 | w.Write([]byte("healthy")) 131 | })) 132 | defer healthy.Close() 133 | 134 | // Deploy and mark as healthy 135 | st.DeployHost("health.example.com", healthy.Listener.Addr().String(), "test", "web", "/health", false) 136 | st.UpdateHealthStatus("health.example.com", true) 137 | 138 | // Verify it works 139 | req := httptest.NewRequest("GET", "/", nil) 140 | req.Host = "health.example.com" 141 | w := httptest.NewRecorder() 142 | rt.ServeHTTP(w, req) 143 | 144 | if w.Code != http.StatusOK { 145 | t.Errorf("Expected 200, got %d", w.Code) 146 | } 147 | 148 | // Create unhealthy backend 149 | unhealthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 150 | w.Write([]byte("unhealthy")) 151 | })) 152 | defer unhealthy.Close() 153 | 154 | // Switch target but mark as unhealthy 155 | st.SwitchTarget("health.example.com", unhealthy.Listener.Addr().String()) 156 | st.UpdateHealthStatus("health.example.com", false) 157 | 158 | // Should get 503 now 159 | w = httptest.NewRecorder() 160 | rt.ServeHTTP(w, req) 161 | 162 | if w.Code != http.StatusServiceUnavailable { 163 | t.Errorf("Expected 503 for unhealthy service, got %d", w.Code) 164 | } 165 | }) 166 | } -------------------------------------------------------------------------------- /packages/cli/tests/types.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { 3 | ServiceEntryWithoutNameSchema, 4 | IopConfigSchema, 5 | IopSecretsSchema, 6 | } from "../src/config/types"; 7 | 8 | describe("type schemas", () => { 9 | describe("ServiceEntryWithoutNameSchema", () => { 10 | test("should validate a minimal valid service", () => { 11 | const validService = { 12 | image: "nginx:latest", 13 | server: "server1.example.com", 14 | }; 15 | 16 | const result = ServiceEntryWithoutNameSchema.safeParse(validService); 17 | expect(result.success).toBe(true); 18 | }); 19 | 20 | test("should validate a complete service", () => { 21 | const validService = { 22 | image: "nginx:latest", 23 | server: "server1.example.com", 24 | ports: ["80:80", "443:443"], 25 | volumes: ["/data:/usr/share/nginx/html"], 26 | environment: { 27 | plain: ["DEBUG=false", "LOG_LEVEL=info"], 28 | secret: ["API_KEY", "DB_PASSWORD"], 29 | }, 30 | registry: { 31 | url: "registry.example.com", 32 | username: "user", 33 | password_secret: "REGISTRY_PASSWORD", 34 | }, 35 | }; 36 | 37 | const result = ServiceEntryWithoutNameSchema.safeParse(validService); 38 | expect(result.success).toBe(true); 39 | }); 40 | 41 | test("should reject a service missing required fields", () => { 42 | const invalidService = { 43 | server: "server1.example.com", // missing 'image' 44 | }; 45 | 46 | const result = ServiceEntryWithoutNameSchema.safeParse(invalidService); 47 | expect(result.success).toBe(false); 48 | 49 | if (!result.success) { 50 | const errorPaths = result.error.errors.map((e) => e.path.join(".")); 51 | expect(errorPaths).toContain("image"); 52 | } 53 | }); 54 | 55 | test("should reject a service with invalid field types", () => { 56 | const invalidService = { 57 | image: "nginx:latest", 58 | server: ["server1.example.com"], // should be a string, not array 59 | }; 60 | 61 | const result = ServiceEntryWithoutNameSchema.safeParse(invalidService); 62 | expect(result.success).toBe(false); 63 | 64 | if (!result.success) { 65 | const errorPaths = result.error.errors.map((e) => e.path.join(".")); 66 | expect(errorPaths).toContain("server"); 67 | } 68 | }); 69 | }); 70 | 71 | describe("IopConfigSchema", () => { 72 | test("should validate a minimal valid config", () => { 73 | const validConfig = { 74 | name: "test-project", 75 | services: { 76 | web: { 77 | image: "nginx:latest", 78 | server: "server1.example.com", 79 | }, 80 | }, 81 | }; 82 | 83 | const result = IopConfigSchema.safeParse(validConfig); 84 | expect(result.success).toBe(true); 85 | }); 86 | 87 | test("should validate a complete config", () => { 88 | const validConfig = { 89 | name: "my-project", 90 | services: { 91 | web: { 92 | image: "nginx:latest", 93 | server: "server1.example.com", 94 | }, 95 | db: { 96 | image: "postgres:14", 97 | server: "db.example.com", 98 | }, 99 | }, 100 | docker: { 101 | registry: "registry.example.com", 102 | username: "admin", 103 | }, 104 | ssh: { 105 | username: "deployer", 106 | port: 2222, 107 | }, 108 | }; 109 | 110 | const result = IopConfigSchema.safeParse(validConfig); 111 | expect(result.success).toBe(true); 112 | }); 113 | 114 | test("should reject a config missing required fields", () => { 115 | const invalidConfig = { 116 | // Missing required 'name' field 117 | services: { 118 | web: { 119 | image: "nginx:latest", 120 | server: "server1.example.com", 121 | }, 122 | }, 123 | }; 124 | 125 | const result = IopConfigSchema.safeParse(invalidConfig); 126 | expect(result.success).toBe(false); 127 | 128 | if (!result.success) { 129 | const errorPaths = result.error.errors.map((e) => e.path.join(".")); 130 | expect(errorPaths).toContain("name"); 131 | } 132 | }); 133 | }); 134 | 135 | describe("IopSecretsSchema", () => { 136 | test("should validate a secrets object", () => { 137 | const validSecrets = { 138 | API_KEY: "123456", 139 | DB_PASSWORD: "secret", 140 | JWT_SECRET: "very-secret", 141 | }; 142 | 143 | const result = IopSecretsSchema.safeParse(validSecrets); 144 | expect(result.success).toBe(true); 145 | }); 146 | 147 | test("should validate an empty secrets object", () => { 148 | const validSecrets = {}; 149 | 150 | const result = IopSecretsSchema.safeParse(validSecrets); 151 | expect(result.success).toBe(true); 152 | }); 153 | 154 | test("should reject a secrets object with non-string values", () => { 155 | const invalidSecrets = { 156 | API_KEY: "123456", 157 | CONFIG: { key: "value" }, // Object instead of string 158 | }; 159 | 160 | const result = IopSecretsSchema.safeParse(invalidSecrets); 161 | expect(result.success).toBe(false); 162 | 163 | if (!result.success) { 164 | const errorPaths = result.error.errors.map((e) => e.path.join(".")); 165 | expect(errorPaths).toContain("CONFIG"); 166 | } 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /examples/plausible/README.md: -------------------------------------------------------------------------------- 1 | # Plausible Analytics with iop 2 | 3 | This example demonstrates how to deploy [Plausible Analytics](https://plausible.io/) Community Edition using iop. Plausible is a privacy-focused, open-source web analytics tool that requires no cookies and is fully compliant with GDPR, CCPA, and PECR. 4 | 5 | ## Architecture 6 | 7 | This setup includes: 8 | 9 | - **Plausible**: The main analytics application 10 | - **PostgreSQL**: Primary database for user data and settings 11 | - **ClickHouse**: Events database for analytics data storage 12 | 13 | ## Prerequisites 14 | 15 | 1. A server with Docker installed (iop can handle this automatically) 16 | 2. A domain name pointing to your server 17 | 3. SSL certificates (handled automatically by iop) 18 | 19 | ## Quick Start 20 | 21 | 1. **Clone this example:** 22 | 23 | ```bash 24 | cp -r examples/plausible my-plausible-analytics 25 | cd my-plausible-analytics 26 | ``` 27 | 28 | 2. **Configure your deployment:** 29 | 30 | - Edit `iop.yml` and replace `your-server.com` with your actual server IP/domain 31 | - Update the `BASE_URL` in `.iop/secrets` with your analytics domain 32 | 33 | 3. **Set up secrets:** 34 | 35 | ```bash 36 | # Generate required secrets 37 | SECRET_KEY_BASE=$(openssl rand -base64 48) 38 | TOTP_VAULT_KEY=$(openssl rand -base64 32) 39 | POSTGRES_PASSWORD=$(openssl rand -base64 32) 40 | 41 | # Edit .iop/secrets with your values 42 | nano .iop/secrets 43 | ``` 44 | 45 | 4. **Deploy to your server:** 46 | ```bash 47 | iop 48 | ``` 49 | 50 | ## Configuration 51 | 52 | ### Required Environment Variables 53 | 54 | Edit `.iop/secrets` with your actual values: 55 | 56 | - `BASE_URL`: Your analytics domain (e.g., `https://analytics.yourdomain.com`) 57 | - `SECRET_KEY_BASE`: Generate with `openssl rand -base64 48` 58 | - `TOTP_VAULT_KEY`: Generate with `openssl rand -base64 32` 59 | - `POSTGRES_PASSWORD`: Database password 60 | 61 | ### Optional Configuration 62 | 63 | Uncomment and configure in `.iop/secrets` as needed: 64 | 65 | **Email Configuration:** 66 | 67 | ```bash 68 | MAILER_ADAPTER=Smtp 69 | MAILER_EMAIL=hello@yourdomain.com 70 | SMTP_HOST_ADDR=smtp.yourdomain.com 71 | SMTP_HOST_PORT=587 72 | SMTP_USER_NAME=hello@yourdomain.com 73 | SMTP_USER_PWD=your-smtp-password 74 | SMTP_HOST_SSL_ENABLED=true 75 | ``` 76 | 77 | **Google OAuth:** 78 | 79 | ```bash 80 | GOOGLE_CLIENT_ID=your-google-client-id 81 | GOOGLE_CLIENT_SECRET=your-google-client-secret 82 | ``` 83 | 84 | **IP Geolocation:** 85 | 86 | ```bash 87 | MAXMIND_LICENSE_KEY=your-maxmind-license-key 88 | MAXMIND_EDITION=GeoLite2-City 89 | ``` 90 | 91 | ## First-Time Setup 92 | 93 | 1. **Create your admin account:** 94 | After deployment, visit your analytics domain and create your first admin account. 95 | 96 | 2. **Add your website:** 97 | 98 | - Click "Add a website" in the dashboard 99 | - Enter your website domain 100 | - Copy the tracking script to your website's `` section 101 | 102 | 3. **Configure tracking:** 103 | Add this script to your website: 104 | ```html 105 | 110 | ``` 111 | 112 | ## Monitoring and Maintenance 113 | 114 | ### Check deployment status: 115 | 116 | ```bash 117 | iop status 118 | ``` 119 | 120 | ### View logs: 121 | 122 | ```bash 123 | # Plausible application logs 124 | ssh iop@your-server.com "docker logs plausible" 125 | 126 | # Database logs 127 | ssh iop@your-server.com "docker logs plausible_db" 128 | 129 | # ClickHouse logs 130 | ssh iop@your-server.com "docker logs plausible_events_db" 131 | ``` 132 | 133 | ### Backup data: 134 | 135 | ```bash 136 | # Backup PostgreSQL database 137 | ssh iop@your-server.com "docker exec plausible_db pg_dump -U postgres plausible_db > plausible_backup.sql" 138 | 139 | # Backup ClickHouse data 140 | ssh iop@your-server.com "docker exec plausible_events_db clickhouse-client --query 'BACKUP DATABASE plausible_events_db TO Disk('default', 'backup.zip')'" 141 | ``` 142 | 143 | ## Troubleshooting 144 | 145 | ### Common Issues 146 | 147 | 1. **Database connection errors:** 148 | 149 | - Check that PostgreSQL is healthy: `docker exec plausible_db pg_isready -U postgres` 150 | - Verify database URLs in secrets file 151 | 152 | 2. **ClickHouse connection errors:** 153 | 154 | - Check ClickHouse health: `docker exec plausible_events_db wget --no-verbose --tries=1 -O - http://127.0.0.1:8123/ping` 155 | - Verify ClickHouse is listening on correct port 156 | 157 | 3. **SSL certificate issues:** 158 | - iop automatically handles SSL certificates 159 | - Check domain DNS points to your server 160 | - Verify `BASE_URL` matches your domain 161 | 162 | ### Performance Tuning 163 | 164 | For high-traffic sites, consider: 165 | 166 | - Increasing server resources 167 | - Adjusting ClickHouse memory settings in `clickhouse/low-resources.xml` 168 | - Setting up multiple servers for load balancing 169 | 170 | ## Security Considerations 171 | 172 | - Registration is disabled by default (`DISABLE_REGISTRATION=true`) 173 | - Email verification is disabled for simplicity 174 | - All data is stored on your servers 175 | - No external tracking or data sharing 176 | - GDPR/CCPA compliant by design 177 | 178 | ## Updating 179 | 180 | To update Plausible: 181 | 182 | 1. Edit `iop.yml` and change the image version 183 | 2. Run `iop deploy` for zero-downtime update 184 | 185 | ## Support 186 | 187 | - [Plausible Documentation](https://plausible.io/docs) 188 | - [iop Documentation](https://iop.dev) 189 | - [Plausible Community Edition](https://github.com/plausible/community-edition) 190 | -------------------------------------------------------------------------------- /packages/proxy/internal/cli/http_cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/elitan/iop/proxy/internal/api" 9 | ) 10 | 11 | // HTTPCli provides command-line interface using HTTP API 12 | type HTTPCli struct { 13 | client *api.HTTPClient 14 | } 15 | 16 | // NewHTTPBasedCLI creates a new HTTP-based CLI handler 17 | func NewHTTPBasedCLI(client *api.HTTPClient) *HTTPCli { 18 | return &HTTPCli{ 19 | client: client, 20 | } 21 | } 22 | 23 | // Execute processes CLI commands via HTTP API 24 | func (c *HTTPCli) Execute(args []string) error { 25 | if len(args) < 1 { 26 | return fmt.Errorf("no command specified") 27 | } 28 | 29 | command := args[0] 30 | 31 | switch command { 32 | case "deploy": 33 | return c.deploy(args[1:]) 34 | case "remove": 35 | return c.remove(args[1:]) 36 | case "list": 37 | return c.list(args[1:]) 38 | case "status": 39 | return c.status(args[1:]) 40 | case "updatehealth": 41 | return c.updateHealth(args[1:]) 42 | case "cert-status": 43 | return c.certStatus(args[1:]) 44 | case "cert-renew": 45 | return c.certRenew(args[1:]) 46 | case "set-staging": 47 | return c.setStaging(args[1:]) 48 | case "switch": 49 | return c.switchTarget(args[1:]) 50 | default: 51 | return fmt.Errorf("unknown command: %s", command) 52 | } 53 | } 54 | 55 | // deploy handles the deploy command via HTTP API 56 | func (c *HTTPCli) deploy(args []string) error { 57 | fs := flag.NewFlagSet("deploy", flag.ContinueOnError) 58 | host := fs.String("host", "", "Hostname to deploy") 59 | target := fs.String("target", "", "Target container:port") 60 | project := fs.String("project", "", "Project name") 61 | healthPath := fs.String("health-path", "/up", "Health check path") 62 | app := fs.String("app", "", "App name") 63 | ssl := fs.Bool("ssl", true, "Enable SSL") 64 | 65 | if err := fs.Parse(args); err != nil { 66 | return err 67 | } 68 | 69 | if *host == "" || *target == "" || *project == "" { 70 | return fmt.Errorf("missing required flags: --host, --target, --project") 71 | } 72 | 73 | return c.client.Deploy(*host, *target, *project, *app, *healthPath, *ssl) 74 | } 75 | 76 | // remove handles the remove command via HTTP API 77 | func (c *HTTPCli) remove(args []string) error { 78 | fs := flag.NewFlagSet("remove", flag.ContinueOnError) 79 | host := fs.String("host", "", "Hostname to remove") 80 | 81 | if err := fs.Parse(args); err != nil { 82 | return err 83 | } 84 | 85 | if *host == "" { 86 | return fmt.Errorf("missing required flag: --host") 87 | } 88 | 89 | return c.client.Remove(*host) 90 | } 91 | 92 | // list handles the list command via HTTP API 93 | func (c *HTTPCli) list(args []string) error { 94 | return c.client.List() 95 | } 96 | 97 | // status handles the status command via HTTP API (same as list) 98 | func (c *HTTPCli) status(args []string) error { 99 | return c.client.List() 100 | } 101 | 102 | // updateHealth handles the updatehealth command via HTTP API 103 | func (c *HTTPCli) updateHealth(args []string) error { 104 | fs := flag.NewFlagSet("updatehealth", flag.ContinueOnError) 105 | host := fs.String("host", "", "Hostname to update") 106 | healthyStr := fs.String("healthy", "", "Health status (true/false)") 107 | 108 | if err := fs.Parse(args); err != nil { 109 | return err 110 | } 111 | 112 | if *host == "" || *healthyStr == "" { 113 | return fmt.Errorf("missing required flags: --host, --healthy") 114 | } 115 | 116 | healthy, err := strconv.ParseBool(*healthyStr) 117 | if err != nil { 118 | return fmt.Errorf("invalid healthy value: %s", *healthyStr) 119 | } 120 | 121 | return c.client.UpdateHealth(*host, healthy) 122 | } 123 | 124 | // certStatus handles the cert-status command via HTTP API 125 | func (c *HTTPCli) certStatus(args []string) error { 126 | fs := flag.NewFlagSet("cert-status", flag.ContinueOnError) 127 | host := fs.String("host", "", "Hostname to check (optional)") 128 | 129 | if err := fs.Parse(args); err != nil { 130 | return err 131 | } 132 | 133 | return c.client.CertStatus(*host) 134 | } 135 | 136 | // certRenew handles the cert-renew command via HTTP API 137 | func (c *HTTPCli) certRenew(args []string) error { 138 | fs := flag.NewFlagSet("cert-renew", flag.ContinueOnError) 139 | host := fs.String("host", "", "Hostname to renew certificate") 140 | 141 | if err := fs.Parse(args); err != nil { 142 | return err 143 | } 144 | 145 | if *host == "" { 146 | return fmt.Errorf("missing required flag: --host") 147 | } 148 | 149 | return c.client.CertRenew(*host) 150 | } 151 | 152 | // setStaging handles the set-staging command via HTTP API 153 | func (c *HTTPCli) setStaging(args []string) error { 154 | fs := flag.NewFlagSet("set-staging", flag.ContinueOnError) 155 | enabledStr := fs.String("enabled", "", "Enable staging mode (true/false)") 156 | 157 | if err := fs.Parse(args); err != nil { 158 | return err 159 | } 160 | 161 | if *enabledStr == "" { 162 | return fmt.Errorf("missing required flag: --enabled") 163 | } 164 | 165 | enabled, err := strconv.ParseBool(*enabledStr) 166 | if err != nil { 167 | return fmt.Errorf("invalid enabled value: %s", *enabledStr) 168 | } 169 | 170 | return c.client.SetStaging(enabled) 171 | } 172 | 173 | // switchTarget handles the switch command via HTTP API 174 | func (c *HTTPCli) switchTarget(args []string) error { 175 | fs := flag.NewFlagSet("switch", flag.ContinueOnError) 176 | host := fs.String("host", "", "Hostname to switch") 177 | target := fs.String("target", "", "New target container:port") 178 | 179 | if err := fs.Parse(args); err != nil { 180 | return err 181 | } 182 | 183 | if *host == "" || *target == "" { 184 | return fmt.Errorf("missing required flags: --host, --target") 185 | } 186 | 187 | return c.client.SwitchTarget(*host, *target) 188 | } 189 | -------------------------------------------------------------------------------- /scripts/zero-deploy-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # --- Configuration --- 4 | TARGET_URL="https://test.eliasson.me" # <<< IMPORTANT: Replace with your actual URL 5 | CONCURRENT_REQUESTS=10 # Number of requests to send concurrently 6 | DELAY_BETWEEN_BATCHES=0.1 # Short delay in seconds between sending batches 7 | VERBOSE=false # Set to true for detailed output 8 | 9 | # Colors for better readability 10 | RED='\033[0;31m' 11 | GREEN='\033[0;32m' 12 | YELLOW='\033[1;33m' 13 | BLUE='\033[0;34m' 14 | CYAN='\033[0;36m' 15 | NC='\033[0m' # No Color 16 | 17 | # Counters 18 | TOTAL_REQUESTS=0 19 | SUCCESSFUL_REQUESTS=0 20 | FAILED_REQUESTS=0 21 | BATCH_COUNT=0 22 | 23 | # Temporary files for tracking results from background processes 24 | SUCCESS_FILE=$(mktemp) 25 | FAILURE_FILE=$(mktemp) 26 | 27 | # Cleanup function 28 | cleanup() { 29 | rm -f "$SUCCESS_FILE" "$FAILURE_FILE" 30 | } 31 | 32 | # Ensure cleanup on exit 33 | trap 'cleanup' EXIT 34 | 35 | echo -e "${CYAN}🚀 Starting load test${NC}" 36 | echo -e "${BLUE}Target:${NC} $TARGET_URL" 37 | echo -e "${BLUE}Concurrent requests per batch:${NC} $CONCURRENT_REQUESTS" 38 | echo -e "${BLUE}Delay between batches:${NC} ${DELAY_BETWEEN_BATCHES}s" 39 | echo -e "${YELLOW}Press Ctrl+C to stop and see final results${NC}" 40 | echo "" 41 | 42 | # Function to update status line 43 | update_status() { 44 | local success_rate=0 45 | if [ $TOTAL_REQUESTS -gt 0 ]; then 46 | success_rate=$(echo "scale=1; $SUCCESSFUL_REQUESTS * 100 / $TOTAL_REQUESTS" | bc -l) 47 | fi 48 | printf "\r${CYAN}Batch #%d${NC} | ${GREEN}✓ %d${NC} | ${RED}✗ %d${NC} | ${BLUE}Total: %d${NC} | ${YELLOW}Success: %s%%${NC}" \ 49 | $BATCH_COUNT $SUCCESSFUL_REQUESTS $FAILED_REQUESTS $TOTAL_REQUESTS $success_rate 50 | } 51 | 52 | # Function to send a single curl request 53 | send_request() { 54 | local request_id=$1 55 | local start_time=$(date +%s.%N) 56 | 57 | if [ "$VERBOSE" = true ]; then 58 | echo -e "${BLUE}[Request $request_id]${NC} Starting..." 59 | fi 60 | 61 | # Send request silently and capture HTTP status code 62 | local http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TARGET_URL") 63 | local curl_exit_code=$? 64 | local end_time=$(date +%s.%N) 65 | local duration=$(echo "scale=3; $end_time - $start_time" | bc -l) 66 | 67 | # Write results to temporary files (this works from background processes) 68 | if [ "$curl_exit_code" -eq 0 ] && [ "$http_code" -ge 200 ] && [ "$http_code" -lt 400 ]; then 69 | echo "1" >> "$SUCCESS_FILE" 70 | if [ "$VERBOSE" = true ]; then 71 | echo -e "${GREEN}[Request $request_id]${NC} ✓ HTTP $http_code (${duration}s)" 72 | fi 73 | else 74 | echo "1" >> "$FAILURE_FILE" 75 | if [ "$VERBOSE" = true ]; then 76 | if [ "$curl_exit_code" -ne 0 ]; then 77 | echo -e "${RED}[Request $request_id]${NC} ✗ Connection failed (${duration}s)" 78 | else 79 | echo -e "${RED}[Request $request_id]${NC} ✗ HTTP $http_code (${duration}s)" 80 | fi 81 | fi 82 | fi 83 | } 84 | 85 | # Function to update counters from temporary files 86 | update_counters() { 87 | # Count successful requests 88 | if [ -f "$SUCCESS_FILE" ]; then 89 | SUCCESSFUL_REQUESTS=$(wc -l < "$SUCCESS_FILE" | tr -d ' ') 90 | else 91 | SUCCESSFUL_REQUESTS=0 92 | fi 93 | 94 | # Count failed requests 95 | if [ -f "$FAILURE_FILE" ]; then 96 | FAILED_REQUESTS=$(wc -l < "$FAILURE_FILE" | tr -d ' ') 97 | else 98 | FAILED_REQUESTS=0 99 | fi 100 | 101 | # Calculate total 102 | TOTAL_REQUESTS=$((SUCCESSFUL_REQUESTS + FAILED_REQUESTS)) 103 | } 104 | 105 | # Function to show final results 106 | show_final_results() { 107 | echo "" 108 | echo "" 109 | echo -e "${CYAN}📊 Final Results${NC}" 110 | echo -e "${BLUE}═══════════════════════════════════════${NC}" 111 | echo -e "${BLUE}Total requests sent:${NC} $TOTAL_REQUESTS" 112 | echo -e "${GREEN}Successful requests:${NC} $SUCCESSFUL_REQUESTS" 113 | echo -e "${RED}Failed requests:${NC} $FAILED_REQUESTS" 114 | echo -e "${BLUE}Total batches:${NC} $BATCH_COUNT" 115 | 116 | if [ $TOTAL_REQUESTS -gt 0 ]; then 117 | local success_rate=$(echo "scale=2; $SUCCESSFUL_REQUESTS * 100 / $TOTAL_REQUESTS" | bc -l) 118 | echo -e "${YELLOW}Success rate:${NC} ${success_rate}%" 119 | 120 | if [ $(echo "$success_rate >= 95" | bc -l) -eq 1 ]; then 121 | echo -e "${GREEN}🎉 Excellent! Your service is performing well.${NC}" 122 | elif [ $(echo "$success_rate >= 80" | bc -l) -eq 1 ]; then 123 | echo -e "${YELLOW}⚠️ Good, but could be improved.${NC}" 124 | else 125 | echo -e "${RED}❌ Poor performance detected. Investigation needed.${NC}" 126 | fi 127 | fi 128 | echo "" 129 | } 130 | 131 | # Trap CTRL+C to show final results and exit cleanly 132 | trap 'update_counters; show_final_results; exit 0' INT 133 | 134 | # Check if verbose mode requested 135 | if [ "$1" = "-v" ] || [ "$1" = "--verbose" ]; then 136 | VERBOSE=true 137 | echo -e "${YELLOW}Verbose mode enabled${NC}" 138 | echo "" 139 | fi 140 | 141 | # --- Main loop --- 142 | while true; do 143 | BATCH_COUNT=$((BATCH_COUNT + 1)) 144 | 145 | # Send concurrent requests 146 | for i in $(seq 1 $CONCURRENT_REQUESTS); do 147 | send_request $i & 148 | done 149 | 150 | # Wait for all requests in current batch to complete 151 | wait 152 | 153 | # Update counters from temporary files 154 | update_counters 155 | 156 | # Update status line (non-verbose mode) 157 | if [ "$VERBOSE" = false ]; then 158 | update_status 159 | fi 160 | 161 | sleep $DELAY_BETWEEN_BATCHES 162 | done -------------------------------------------------------------------------------- /packages/proxy/internal/test/realistic_blue_green_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "sync/atomic" 8 | "testing" 9 | "time" 10 | 11 | "github.com/elitan/iop/proxy/internal/router" 12 | "github.com/elitan/iop/proxy/internal/state" 13 | ) 14 | 15 | // TestRealisticBlueGreen simulates actual traffic patterns during deployment 16 | func TestRealisticBlueGreen(t *testing.T) { 17 | stateFile := "test-realistic.json" 18 | defer os.Remove(stateFile) 19 | 20 | t.Run("continuous traffic during switch", func(t *testing.T) { 21 | st := state.NewState(stateFile) 22 | rt := router.NewFixedRouter(st, nil) 23 | 24 | // Blue backend 25 | var blueCount int32 26 | blue := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 | atomic.AddInt32(&blueCount, 1) 28 | w.Write([]byte("blue")) 29 | })) 30 | defer blue.Close() 31 | 32 | // Green backend 33 | var greenCount int32 34 | green := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 | atomic.AddInt32(&greenCount, 1) 36 | w.Write([]byte("green")) 37 | })) 38 | defer green.Close() 39 | 40 | // Deploy blue 41 | st.DeployHost("app.com", blue.Listener.Addr().String(), "test", "web", "/health", false) 42 | st.UpdateHealthStatus("app.com", true) 43 | 44 | // Simulate continuous traffic 45 | trafficResults := make(chan string, 1000) 46 | done := make(chan bool) 47 | 48 | // Start traffic generator - 10 requests per second 49 | go func() { 50 | defer close(trafficResults) 51 | ticker := time.NewTicker(100 * time.Millisecond) 52 | defer ticker.Stop() 53 | 54 | for { 55 | select { 56 | case <-done: 57 | return 58 | case <-ticker.C: 59 | req := httptest.NewRequest("GET", "/", nil) 60 | req.Host = "app.com" 61 | w := httptest.NewRecorder() 62 | rt.ServeHTTP(w, req) 63 | trafficResults <- w.Body.String() 64 | } 65 | } 66 | }() 67 | 68 | // Let some traffic flow to blue 69 | time.Sleep(500 * time.Millisecond) 70 | 71 | // Switch to green (simulating deployment) 72 | t.Log("Switching from blue to green...") 73 | err := st.SwitchTarget("app.com", green.Listener.Addr().String()) 74 | if err != nil { 75 | t.Fatalf("Failed to switch: %v", err) 76 | } 77 | 78 | // Let traffic flow to green 79 | time.Sleep(500 * time.Millisecond) 80 | 81 | // Stop traffic 82 | close(done) 83 | 84 | // Count results 85 | blueResponses := 0 86 | greenResponses := 0 87 | var lastBlueIndex, firstGreenIndex int 88 | index := 0 89 | 90 | for result := range trafficResults { 91 | switch result { 92 | case "blue": 93 | blueResponses++ 94 | lastBlueIndex = index 95 | case "green": 96 | greenResponses++ 97 | if firstGreenIndex == 0 { 98 | firstGreenIndex = index 99 | } 100 | } 101 | index++ 102 | } 103 | 104 | t.Logf("Total requests: %d", blueResponses+greenResponses) 105 | t.Logf("Blue responses: %d (last at index %d)", blueResponses, lastBlueIndex) 106 | t.Logf("Green responses: %d (first at index %d)", greenResponses, firstGreenIndex) 107 | t.Logf("Blue backend hit count: %d", atomic.LoadInt32(&blueCount)) 108 | t.Logf("Green backend hit count: %d", atomic.LoadInt32(&greenCount)) 109 | 110 | // Verify we got traffic to both 111 | if blueResponses == 0 { 112 | t.Error("Expected some blue responses") 113 | } 114 | if greenResponses == 0 { 115 | t.Error("Expected some green responses") 116 | } 117 | 118 | // Verify clean switch (no interleaving after switch) 119 | if firstGreenIndex > 0 && lastBlueIndex > firstGreenIndex { 120 | t.Errorf("Traffic not cleanly switched: last blue at %d, first green at %d", 121 | lastBlueIndex, firstGreenIndex) 122 | } 123 | }) 124 | 125 | t.Run("in-flight requests complete on old backend", func(t *testing.T) { 126 | st := state.NewState(stateFile) 127 | rt := router.NewFixedRouter(st, nil) 128 | 129 | // Slow backend that identifies itself 130 | requestID := int32(0) 131 | slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 132 | id := atomic.AddInt32(&requestID, 1) 133 | time.Sleep(200 * time.Millisecond) // Slow request 134 | w.Write([]byte("slow-" + string(rune('0'+id)))) 135 | })) 136 | defer slow.Close() 137 | 138 | // Fast backend 139 | fast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 140 | w.Write([]byte("fast")) 141 | })) 142 | defer fast.Close() 143 | 144 | // Deploy slow 145 | st.DeployHost("app2.com", slow.Listener.Addr().String(), "test", "web", "/health", false) 146 | st.UpdateHealthStatus("app2.com", true) 147 | 148 | // Start 3 slow requests 149 | results := make(chan string, 3) 150 | for i := 0; i < 3; i++ { 151 | go func() { 152 | req := httptest.NewRequest("GET", "/", nil) 153 | req.Host = "app2.com" 154 | w := httptest.NewRecorder() 155 | rt.ServeHTTP(w, req) 156 | results <- w.Body.String() 157 | }() 158 | } 159 | 160 | // Give requests time to start 161 | time.Sleep(50 * time.Millisecond) 162 | 163 | // Switch to fast backend 164 | st.SwitchTarget("app2.com", fast.Listener.Addr().String()) 165 | 166 | // New request should go to fast 167 | req := httptest.NewRequest("GET", "/", nil) 168 | req.Host = "app2.com" 169 | w := httptest.NewRecorder() 170 | rt.ServeHTTP(w, req) 171 | 172 | if w.Body.String() != "fast" { 173 | t.Errorf("New request should go to fast backend, got: %s", w.Body.String()) 174 | } 175 | 176 | // Collect slow results 177 | slowResults := make([]string, 0, 3) 178 | for i := 0; i < 3; i++ { 179 | result := <-results 180 | slowResults = append(slowResults, result) 181 | } 182 | 183 | // All slow requests should have completed on slow backend 184 | for _, result := range slowResults { 185 | if result != "slow-1" && result != "slow-2" && result != "slow-3" { 186 | t.Errorf("Expected slow request to complete on slow backend, got: %s", result) 187 | } 188 | } 189 | 190 | t.Logf("In-flight requests completed: %v", slowResults) 191 | }) 192 | } -------------------------------------------------------------------------------- /packages/proxy/internal/test/controller_integration_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/elitan/iop/proxy/internal/core" 10 | "github.com/elitan/iop/proxy/internal/deployment" 11 | "github.com/elitan/iop/proxy/internal/events" 12 | "github.com/elitan/iop/proxy/internal/storage" 13 | ) 14 | 15 | // mockHealthChecker always returns success or failure based on configuration 16 | type mockHealthChecker struct { 17 | shouldPass bool 18 | } 19 | 20 | func (m *mockHealthChecker) CheckHealth(ctx context.Context, target, healthPath string) error { 21 | if m.shouldPass { 22 | return nil 23 | } 24 | return fmt.Errorf("health check failed") 25 | } 26 | 27 | // mockProxyUpdater captures route updates 28 | type mockProxyUpdater struct { 29 | routes map[string]mockRoute 30 | } 31 | 32 | type mockRoute struct { 33 | target string 34 | healthy bool 35 | } 36 | 37 | func newMockProxyUpdater() *mockProxyUpdater { 38 | return &mockProxyUpdater{ 39 | routes: make(map[string]mockRoute), 40 | } 41 | } 42 | 43 | func (m *mockProxyUpdater) UpdateRoute(hostname, target string, healthy bool) { 44 | m.routes[hostname] = mockRoute{target: target, healthy: healthy} 45 | } 46 | 47 | // TestControllerIntegration tests the Controller with proper event flow 48 | func TestControllerIntegration(t *testing.T) { 49 | // Setup 50 | store := storage.NewMemoryStore() 51 | eventBus := events.NewSimpleBus() 52 | healthService := &mockHealthChecker{shouldPass: true} 53 | proxyUpdater := newMockProxyUpdater() 54 | 55 | controller := deployment.NewController(store, proxyUpdater, healthService, eventBus) 56 | 57 | // Subscribe to deployment events 58 | eventCh := eventBus.Subscribe() 59 | defer eventBus.Unsubscribe(eventCh) 60 | 61 | t.Run("complete deployment lifecycle with cleanup", func(t *testing.T) { 62 | ctx := context.Background() 63 | 64 | // Step 1: Deploy first version 65 | t.Log("Deploying first version...") 66 | err := controller.Deploy(ctx, "myapp.com", "myapp:v1", "myproject", "web") 67 | if err != nil { 68 | t.Fatalf("Failed to deploy first version: %v", err) 69 | } 70 | 71 | // Wait for health check and traffic switch 72 | time.Sleep(100 * time.Millisecond) 73 | 74 | // Verify first deployment 75 | deployment, err := controller.GetStatus("myapp.com") 76 | if err != nil { 77 | t.Fatalf("Failed to get deployment status: %v", err) 78 | } 79 | 80 | if deployment.Hostname != "myapp.com" { 81 | t.Errorf("Expected hostname myapp.com, got %s", deployment.Hostname) 82 | } 83 | 84 | // Check that proxy was updated 85 | if proxyUpdater.routes["myapp.com"].target == "" { 86 | t.Error("Expected route to be set for myapp.com") 87 | } 88 | 89 | // Step 2: Deploy second version (should cleanup first) 90 | t.Log("Deploying second version...") 91 | err = controller.Deploy(ctx, "myapp.com", "myapp:v2", "myproject", "web") 92 | if err != nil { 93 | t.Fatalf("Failed to deploy second version: %v", err) 94 | } 95 | 96 | // Wait for health check and traffic switch 97 | time.Sleep(100 * time.Millisecond) 98 | 99 | // Verify second deployment 100 | deployment, err = controller.GetStatus("myapp.com") 101 | if err != nil { 102 | t.Fatalf("Failed to get final deployment status: %v", err) 103 | } 104 | 105 | // Check that the active container is healthy 106 | var activeContainer, inactiveContainer core.Container 107 | if deployment.Active == core.Blue { 108 | activeContainer = deployment.Blue 109 | inactiveContainer = deployment.Green 110 | } else { 111 | activeContainer = deployment.Green 112 | inactiveContainer = deployment.Blue 113 | } 114 | 115 | if activeContainer.HealthState != core.HealthHealthy { 116 | t.Errorf("Expected active container to be healthy, got %s", activeContainer.HealthState) 117 | } 118 | 119 | // Check immediate cleanup behavior 120 | if inactiveContainer.Target != "" && inactiveContainer.HealthState != core.HealthStopped { 121 | t.Errorf("Expected inactive container to be cleaned up, got target=%s, health=%s", 122 | inactiveContainer.Target, inactiveContainer.HealthState) 123 | } 124 | 125 | t.Log("Complete deployment lifecycle with cleanup completed successfully!") 126 | }) 127 | 128 | t.Run("event flow verification", func(t *testing.T) { 129 | // Collect events from the previous test 130 | events := make([]string, 0) 131 | timeout := time.After(500 * time.Millisecond) 132 | 133 | for { 134 | select { 135 | case event := <-eventCh: 136 | switch event.(type) { 137 | case *core.DeploymentStarted: 138 | events = append(events, "DeploymentStarted") 139 | case *core.TrafficSwitched: 140 | events = append(events, "TrafficSwitched") 141 | case *core.DeploymentCompleted: 142 | events = append(events, "DeploymentCompleted") 143 | } 144 | case <-timeout: 145 | goto checkEvents 146 | } 147 | } 148 | 149 | checkEvents: 150 | // We should have seen both deployments 151 | expectedEvents := []string{ 152 | "DeploymentStarted", "TrafficSwitched", "DeploymentCompleted", // First deployment 153 | "DeploymentStarted", "TrafficSwitched", "DeploymentCompleted", // Second deployment 154 | } 155 | 156 | if len(events) < len(expectedEvents) { 157 | t.Errorf("Expected at least %d events, got %d: %v", len(expectedEvents), len(events), events) 158 | } 159 | 160 | t.Logf("Events received: %v", events) 161 | }) 162 | 163 | t.Run("container naming validation", func(t *testing.T) { 164 | // Test the container naming conventions 165 | deployment, err := controller.GetStatus("myapp.com") 166 | if err != nil { 167 | t.Fatalf("Failed to get deployment status: %v", err) 168 | } 169 | 170 | // Check that container targets follow proper naming 171 | var activeContainer core.Container 172 | if deployment.Active == core.Blue { 173 | activeContainer = deployment.Blue 174 | } else { 175 | activeContainer = deployment.Green 176 | } 177 | 178 | expectedTarget := "myapp-com-" + string(deployment.Active) + ":3000" 179 | if activeContainer.Target != expectedTarget { 180 | t.Errorf("Expected container target %s, got %s", expectedTarget, activeContainer.Target) 181 | } 182 | 183 | t.Log("Container naming validation completed!") 184 | }) 185 | } -------------------------------------------------------------------------------- /packages/cli/tests/reserved-names.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "bun:test"; 2 | import { validateConfig, ConfigValidationError } from "../src/utils/config-validator"; 3 | import { IopConfig } from "../src/config/types"; 4 | 5 | describe("Reserved Names Validation", () => { 6 | test("should reject service with reserved name 'proxy'", () => { 7 | const config: IopConfig = { 8 | name: "test-project", 9 | services: { 10 | proxy: { 11 | image: "test/proxy", 12 | server: "test.com", 13 | }, 14 | }, 15 | }; 16 | 17 | const errors = validateConfig(config); 18 | 19 | expect(errors).toHaveLength(1); 20 | expect(errors[0].type).toBe("reserved_name"); 21 | expect(errors[0].message).toContain("proxy"); 22 | expect(errors[0].message).toContain("reserved"); 23 | expect(errors[0].entries).toEqual(["proxy"]); 24 | expect(errors[0].suggestions).toBeDefined(); 25 | expect(errors[0].suggestions?.some(s => s.includes("proxy-app"))).toBe(true); 26 | }); 27 | 28 | test("should reject service with reserved name 'status'", () => { 29 | const config: IopConfig = { 30 | name: "test-project", 31 | services: { 32 | status: { 33 | image: "test/status", 34 | server: "test.com", 35 | }, 36 | }, 37 | }; 38 | 39 | const errors = validateConfig(config); 40 | 41 | expect(errors).toHaveLength(1); 42 | expect(errors[0].type).toBe("reserved_name"); 43 | expect(errors[0].message).toContain("status"); 44 | expect(errors[0].entries).toEqual(["status"]); 45 | }); 46 | 47 | test("should reject service with reserved name 'init'", () => { 48 | const config: IopConfig = { 49 | name: "test-project", 50 | services: { 51 | init: { 52 | image: "test/init", 53 | server: "test.com", 54 | }, 55 | }, 56 | }; 57 | 58 | const errors = validateConfig(config); 59 | 60 | expect(errors).toHaveLength(1); 61 | expect(errors[0].type).toBe("reserved_name"); 62 | expect(errors[0].message).toContain("init"); 63 | }); 64 | 65 | test("should reject multiple reserved names", () => { 66 | const config: IopConfig = { 67 | name: "test-project", 68 | services: { 69 | proxy: { 70 | image: "test/proxy", 71 | server: "test.com", 72 | }, 73 | init: { 74 | image: "test/init", 75 | server: "test.com", 76 | }, 77 | status: { 78 | image: "test/status", 79 | server: "test.com", 80 | }, 81 | }, 82 | }; 83 | 84 | const errors = validateConfig(config); 85 | 86 | expect(errors).toHaveLength(3); 87 | expect(errors.map(e => e.entries[0]).sort()).toEqual(["init", "proxy", "status"]); 88 | expect(errors.every(e => e.type === "reserved_name")).toBe(true); 89 | }); 90 | 91 | test("should allow non-reserved names", () => { 92 | const config: IopConfig = { 93 | name: "test-project", 94 | services: { 95 | web: { 96 | image: "test/web", 97 | server: "test.com", 98 | }, 99 | api: { 100 | image: "test/api", 101 | server: "test.com", 102 | }, 103 | database: { 104 | image: "postgres:15", 105 | server: "test.com", 106 | }, 107 | redis: { 108 | image: "redis:alpine", 109 | server: "test.com", 110 | }, 111 | }, 112 | }; 113 | 114 | const errors = validateConfig(config); 115 | 116 | // Should not have any reserved name errors 117 | const reservedNameErrors = errors.filter(e => e.type === "reserved_name"); 118 | expect(reservedNameErrors).toHaveLength(0); 119 | }); 120 | 121 | test("should allow names similar to reserved names", () => { 122 | const config: IopConfig = { 123 | name: "test-project", 124 | services: { 125 | "proxy-app": { 126 | image: "test/proxy-app", 127 | server: "test.com", 128 | }, 129 | "web-proxy": { 130 | image: "test/web-proxy", 131 | server: "test.com", 132 | }, 133 | "status-checker": { 134 | image: "test/status-checker", 135 | server: "test.com", 136 | }, 137 | }, 138 | }; 139 | 140 | const errors = validateConfig(config); 141 | 142 | // Should not have any reserved name errors 143 | const reservedNameErrors = errors.filter(e => e.type === "reserved_name"); 144 | expect(reservedNameErrors).toHaveLength(0); 145 | }); 146 | 147 | test("should work with array format configuration", () => { 148 | const config: IopConfig = { 149 | name: "test-project", 150 | services: [ 151 | { 152 | name: "proxy", 153 | image: "test/proxy", 154 | server: "test.com", 155 | }, 156 | { 157 | name: "status", 158 | image: "test/status", 159 | server: "test.com", 160 | }, 161 | ], 162 | }; 163 | 164 | const errors = validateConfig(config); 165 | 166 | const reservedNameErrors = errors.filter(e => e.type === "reserved_name"); 167 | expect(reservedNameErrors).toHaveLength(2); 168 | expect(reservedNameErrors.map(e => e.entries[0]).sort()).toEqual(["proxy", "status"]); 169 | }); 170 | 171 | test("should include helpful suggestions", () => { 172 | const config: IopConfig = { 173 | name: "test-project", 174 | services: { 175 | proxy: { 176 | image: "test/proxy", 177 | server: "test.com", 178 | }, 179 | }, 180 | }; 181 | 182 | const errors = validateConfig(config); 183 | 184 | expect(errors).toHaveLength(1); 185 | const suggestions = errors[0].suggestions || []; 186 | 187 | // Check that suggestions contain helpful alternatives 188 | expect(suggestions.some(s => s.includes("proxy-app"))).toBe(true); 189 | expect(suggestions.some(s => s.includes("proxy-service"))).toBe(true); 190 | expect(suggestions.some(s => s.includes("web-proxy"))).toBe(true); 191 | expect(suggestions.some(s => s.includes("Reserved names:"))).toBe(true); 192 | expect(suggestions.some(s => s.includes("init, status, proxy"))).toBe(true); 193 | }); 194 | }); -------------------------------------------------------------------------------- /packages/cli/src/utils/service-fingerprint.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | import { exec } from 'child_process'; 3 | import { promisify } from 'util'; 4 | import { ServiceEntry, IopSecrets } from '../config/types'; 5 | 6 | const execAsync = promisify(exec); 7 | 8 | export interface ServiceFingerprint { 9 | type: 'built' | 'external'; 10 | runtimeHash: string; // Combined config + secrets hash 11 | 12 | // For built services 13 | localImageHash?: string; 14 | serverImageHash?: string; 15 | 16 | // For external services 17 | imageReference?: string; 18 | } 19 | 20 | export interface RedeploymentDecision { 21 | shouldRedeploy: boolean; 22 | reason: string; 23 | priority: 'critical' | 'normal' | 'optional'; 24 | changeType: 'image' | 'runtime' | 'first-deployment'; 25 | needsImageTransfer: boolean; 26 | } 27 | 28 | /** 29 | * Creates a runtime hash combining configuration and secrets 30 | * This determines if containers need to be recreated (but not if image needs transfer) 31 | */ 32 | export function createRuntimeHash( 33 | serviceEntry: ServiceEntry, 34 | secrets: IopSecrets 35 | ): string { 36 | const runtimeConfig = { 37 | // Configuration that affects container runtime 38 | ports: serviceEntry.ports?.sort() || [], 39 | volumes: serviceEntry.volumes?.sort() || [], 40 | command: serviceEntry.command, 41 | replicas: serviceEntry.replicas || 1, 42 | restart: (serviceEntry as any).restart, 43 | proxy: serviceEntry.proxy ? { 44 | hosts: serviceEntry.proxy.hosts?.sort() || [], 45 | app_port: serviceEntry.proxy.app_port, 46 | ssl: serviceEntry.proxy.ssl, 47 | ssl_redirect: serviceEntry.proxy.ssl_redirect, 48 | forward_headers: serviceEntry.proxy.forward_headers, 49 | response_timeout: serviceEntry.proxy.response_timeout, 50 | } : undefined, 51 | health_check: serviceEntry.health_check, 52 | // Environment variables (both plain and secret values) 53 | environment: { 54 | plain: serviceEntry.environment?.plain?.sort() || [], 55 | secretValues: (serviceEntry.environment?.secret || []) 56 | .sort() 57 | .reduce((acc, key) => { 58 | if (secrets[key] !== undefined) { 59 | acc[key] = secrets[key]; 60 | } 61 | return acc; 62 | }, {} as Record), 63 | }, 64 | }; 65 | 66 | return crypto 67 | .createHash('sha256') 68 | .update(JSON.stringify(runtimeConfig)) 69 | .digest('hex') 70 | .substring(0, 12); 71 | } 72 | 73 | /** 74 | * Gets the Docker image hash for a locally built image 75 | */ 76 | export async function getLocalImageHash(imageName: string): Promise { 77 | try { 78 | const command = `docker inspect --format='{{.Id}}' ${imageName}:latest`; 79 | const result = await execAsync(command); 80 | const imageId = result.stdout.trim(); 81 | 82 | return imageId || null; 83 | } catch (error) { 84 | return null; // Image doesn't exist locally 85 | } 86 | } 87 | 88 | 89 | /** 90 | * Determines if a service is built locally or uses external image 91 | */ 92 | export function isBuiltService(serviceEntry: ServiceEntry): boolean { 93 | return !!serviceEntry.build; 94 | } 95 | 96 | /** 97 | * Creates a service fingerprint based on service type 98 | */ 99 | export async function createServiceFingerprint( 100 | serviceEntry: ServiceEntry, 101 | secrets: IopSecrets, 102 | projectName?: string 103 | ): Promise { 104 | const runtimeHash = createRuntimeHash(serviceEntry, secrets); 105 | 106 | if (isBuiltService(serviceEntry)) { 107 | // Built service - get local image hash directly from Docker 108 | // Use same naming convention as getServiceImageName() 109 | const imageName = serviceEntry.name; 110 | const localImageHash = await getLocalImageHash(imageName); 111 | 112 | return { 113 | type: 'built', 114 | runtimeHash, 115 | localImageHash: localImageHash || undefined, 116 | }; 117 | } else { 118 | // External service - just track image reference and runtime config 119 | return { 120 | type: 'external', 121 | runtimeHash, 122 | imageReference: serviceEntry.image, 123 | }; 124 | } 125 | } 126 | 127 | /** 128 | * Determines if a service should be redeployed based on fingerprint comparison 129 | */ 130 | export function shouldRedeploy( 131 | current: ServiceFingerprint | null, 132 | desired: ServiceFingerprint 133 | ): RedeploymentDecision { 134 | // No current fingerprint means first deployment 135 | if (!current) { 136 | return { 137 | shouldRedeploy: true, 138 | reason: 'first deployment', 139 | priority: 'normal', 140 | changeType: 'first-deployment', 141 | needsImageTransfer: true 142 | }; 143 | } 144 | 145 | // Check for image changes first (highest priority for transfer decisions) 146 | if (desired.type === 'built') { 147 | // Compare local desired image with current server image 148 | if (desired.localImageHash && current.serverImageHash !== desired.localImageHash) { 149 | return { 150 | shouldRedeploy: true, 151 | reason: 'image updated', 152 | priority: 'normal', 153 | changeType: 'image', 154 | needsImageTransfer: true 155 | }; 156 | } 157 | } 158 | 159 | // For external services, check image reference 160 | if (desired.type === 'external') { 161 | if (current.imageReference !== desired.imageReference) { 162 | return { 163 | shouldRedeploy: true, 164 | reason: 'image version updated', 165 | priority: 'normal', 166 | changeType: 'image', 167 | needsImageTransfer: false // External images are pulled, not transferred 168 | }; 169 | } 170 | } 171 | 172 | // Runtime changes (config + secrets) - no image transfer needed 173 | if (current.runtimeHash !== desired.runtimeHash) { 174 | return { 175 | shouldRedeploy: true, 176 | reason: 'configuration or secrets changed', 177 | priority: 'critical', 178 | changeType: 'runtime', 179 | needsImageTransfer: false 180 | }; 181 | } 182 | 183 | return { 184 | shouldRedeploy: false, 185 | reason: 'up-to-date, skipped', 186 | priority: 'optional', 187 | changeType: 'image', // doesn't matter since shouldRedeploy is false 188 | needsImageTransfer: false 189 | }; 190 | } 191 | 192 | -------------------------------------------------------------------------------- /packages/cli/bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "lightform", 6 | "dependencies": { 7 | "@types/cli-progress": "^3.11.6", 8 | "cli-progress": "^3.12.0", 9 | "js-yaml": "^4.1.0", 10 | "ssh2-promise": "^1.0.0", 11 | "zod": "^3.24.4", 12 | }, 13 | "devDependencies": { 14 | "@types/bun": "latest", 15 | "@types/js-yaml": "^4.0.5", 16 | "@types/node": "^20", 17 | "@types/ssh2": "^1.15.0", 18 | "typescript": "^5", 19 | }, 20 | }, 21 | }, 22 | "packages": { 23 | "@heroku/socksv5": ["@heroku/socksv5@0.0.9", "", { "dependencies": { "ip-address": "^5.8.8" } }, "sha512-bV8v7R/c0gNve8i7yPmZbcCTJUqRbCnMSvcegcMaz+ly+FoZf9i4+3MTjKsX+OZn9w0w1I6VJYQBcdM+yMWPQQ=="], 24 | 25 | "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], 26 | 27 | "@types/cli-progress": ["@types/cli-progress@3.11.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA=="], 28 | 29 | "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], 30 | 31 | "@types/node": ["@types/node@20.17.47", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-3dLX0Upo1v7RvUimvxLeXqwrfyKxUINk0EAM83swP2mlSUcwV73sZy8XhNz8bcZ3VbsfQyC/y6jRdL5tgCNpDQ=="], 32 | 33 | "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], 34 | 35 | "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 36 | 37 | "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 38 | 39 | "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], 40 | 41 | "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], 42 | 43 | "buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="], 44 | 45 | "bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="], 46 | 47 | "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], 48 | 49 | "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], 50 | 51 | "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 52 | 53 | "ip-address": ["ip-address@5.9.4", "", { "dependencies": { "jsbn": "1.1.0", "lodash": "^4.17.15", "sprintf-js": "1.1.2" } }, "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw=="], 54 | 55 | "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 56 | 57 | "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], 58 | 59 | "jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="], 60 | 61 | "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], 62 | 63 | "nan": ["nan@2.22.2", "", {}, "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ=="], 64 | 65 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 66 | 67 | "sprintf-js": ["sprintf-js@1.1.2", "", {}, "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="], 68 | 69 | "ssh2": ["ssh2@1.16.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.20.0" } }, "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg=="], 70 | 71 | "ssh2-promise": ["ssh2-promise@1.0.3", "", { "dependencies": { "@heroku/socksv5": "^0.0.9", "ssh2": "^1.10.0" } }, "sha512-842MuERRLuGdTneOZhc5HeogBtGFI/WeeJ9LH3Jp+eB57av8hHGDzxZqgWuQmuxVDniRf7B+agSfgc2wKfLY4w=="], 72 | 73 | "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 74 | 75 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 76 | 77 | "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], 78 | 79 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 80 | 81 | "undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], 82 | 83 | "zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="], 84 | 85 | "@types/ssh2/@types/node": ["@types/node@18.19.103", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hHTHp+sEz6SxFsp+SA+Tqrua3AbmlAw+Y//aEwdHrdZkYVRWdvWD3y5uPZ0flYOkgskaFWqZ/YGFm3FaFQ0pRw=="], 86 | 87 | "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /packages/proxy/internal/deployment/controller_load_test.go: -------------------------------------------------------------------------------- 1 | package deployment 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "sync/atomic" 8 | "testing" 9 | "time" 10 | 11 | "github.com/elitan/iop/proxy/internal/core" 12 | "github.com/elitan/iop/proxy/internal/events" 13 | "github.com/elitan/iop/proxy/internal/storage" 14 | ) 15 | 16 | // TestControllerUnderLoad tests deployment behavior when proxy is handling traffic 17 | func TestControllerUnderLoad(t *testing.T) { 18 | t.Run("deployment during active traffic", func(t *testing.T) { 19 | // Setup 20 | store := storage.NewMemoryStore() 21 | eventBus := events.NewSimpleBus() 22 | healthService := &mockHealthChecker{shouldPass: true} 23 | proxyUpdater := &loadTestProxyUpdater{ 24 | mockProxyUpdater: newMockProxyUpdater(), 25 | requestCount: &atomic.Int32{}, 26 | } 27 | 28 | controller := NewController(store, proxyUpdater, healthService, eventBus) 29 | ctx := context.Background() 30 | 31 | // Initial deployment 32 | err := controller.Deploy(ctx, "loaded.com", "image:v1", "project", "app") 33 | if err != nil { 34 | t.Fatalf("Initial deployment failed: %v", err) 35 | } 36 | 37 | // Wait for it to become active 38 | time.Sleep(100 * time.Millisecond) 39 | 40 | // Start simulating traffic 41 | stopTraffic := make(chan struct{}) 42 | var trafficWg sync.WaitGroup 43 | trafficWg.Add(1) 44 | 45 | go func() { 46 | defer trafficWg.Done() 47 | ticker := time.NewTicker(5 * time.Millisecond) 48 | defer ticker.Stop() 49 | 50 | for { 51 | select { 52 | case <-stopTraffic: 53 | return 54 | case <-ticker.C: 55 | // Simulate a request 56 | proxyUpdater.requestCount.Add(1) 57 | } 58 | } 59 | }() 60 | 61 | // Let traffic run for a bit 62 | time.Sleep(50 * time.Millisecond) 63 | beforeCount := proxyUpdater.requestCount.Load() 64 | 65 | // Deploy new version while traffic is running 66 | err = controller.Deploy(ctx, "loaded.com", "image:v2", "project", "app") 67 | if err != nil { 68 | t.Fatalf("Second deployment failed: %v", err) 69 | } 70 | 71 | // Wait for deployment to complete 72 | time.Sleep(150 * time.Millisecond) 73 | 74 | // Stop traffic 75 | close(stopTraffic) 76 | trafficWg.Wait() 77 | 78 | afterCount := proxyUpdater.requestCount.Load() 79 | 80 | // Verify traffic continued during deployment 81 | if afterCount <= beforeCount { 82 | t.Error("Expected traffic to continue during deployment") 83 | } 84 | 85 | // Verify deployment succeeded 86 | deployment, err := controller.GetStatus("loaded.com") 87 | if err != nil { 88 | t.Fatalf("Failed to get deployment status: %v", err) 89 | } 90 | 91 | // Active container should be healthy 92 | var activeContainer core.Container 93 | if deployment.Active == core.Blue { 94 | activeContainer = deployment.Blue 95 | } else { 96 | activeContainer = deployment.Green 97 | } 98 | 99 | if activeContainer.HealthState != core.HealthHealthy { 100 | t.Errorf("Expected active container to be healthy after deployment under load, got %s", 101 | activeContainer.HealthState) 102 | } 103 | 104 | t.Logf("Handled %d requests during deployment", afterCount-beforeCount) 105 | }) 106 | 107 | t.Run("multiple hosts under load", func(t *testing.T) { 108 | // Setup 109 | store := storage.NewMemoryStore() 110 | eventBus := events.NewSimpleBus() 111 | healthService := &mockHealthChecker{shouldPass: true} 112 | proxyUpdater := newMockProxyUpdater() 113 | 114 | controller := NewController(store, proxyUpdater, healthService, eventBus) 115 | ctx := context.Background() 116 | 117 | // Deploy to multiple hosts 118 | hosts := []string{"app1.com", "app2.com", "app3.com", "app4.com", "app5.com"} 119 | 120 | var wg sync.WaitGroup 121 | for i, host := range hosts { 122 | wg.Add(1) 123 | go func(h string, version int) { 124 | defer wg.Done() 125 | 126 | // Deploy initial version 127 | err := controller.Deploy(ctx, h, fmt.Sprintf("image:v%d", version), "project", "app") 128 | if err != nil { 129 | t.Errorf("Failed to deploy to %s: %v", h, err) 130 | } 131 | 132 | // Wait a bit then deploy update 133 | time.Sleep(100 * time.Millisecond) 134 | 135 | err = controller.Deploy(ctx, h, fmt.Sprintf("image:v%d-updated", version), "project", "app") 136 | if err != nil { 137 | t.Errorf("Failed to update %s: %v", h, err) 138 | } 139 | }(host, i+1) 140 | } 141 | 142 | wg.Wait() 143 | 144 | // Wait for all deployments to complete 145 | time.Sleep(200 * time.Millisecond) 146 | 147 | // Verify all hosts are properly deployed 148 | for _, host := range hosts { 149 | deployment, err := controller.GetStatus(host) 150 | if err != nil { 151 | t.Errorf("Failed to get status for %s: %v", host, err) 152 | continue 153 | } 154 | 155 | // Should have one healthy container 156 | var activeContainer core.Container 157 | if deployment.Active == core.Blue { 158 | activeContainer = deployment.Blue 159 | } else { 160 | activeContainer = deployment.Green 161 | } 162 | 163 | if activeContainer.HealthState != core.HealthHealthy { 164 | t.Errorf("Host %s: expected healthy container, got %s", 165 | host, activeContainer.HealthState) 166 | } 167 | } 168 | }) 169 | } 170 | 171 | // loadTestProxyUpdater tracks request counts during updates 172 | type loadTestProxyUpdater struct { 173 | *mockProxyUpdater 174 | requestCount *atomic.Int32 175 | } 176 | 177 | // TestDeploymentIdempotency tests that deployments are idempotent 178 | func TestDeploymentIdempotency(t *testing.T) { 179 | // Setup 180 | store := storage.NewMemoryStore() 181 | eventBus := events.NewSimpleBus() 182 | healthService := &mockHealthChecker{shouldPass: true} 183 | proxyUpdater := newMockProxyUpdater() 184 | 185 | controller := NewController(store, proxyUpdater, healthService, eventBus) 186 | ctx := context.Background() 187 | 188 | // Deploy same version multiple times 189 | for i := 0; i < 3; i++ { 190 | err := controller.Deploy(ctx, "idempotent.com", "image:v1", "project", "app") 191 | if err != nil { 192 | t.Fatalf("Deployment %d failed: %v", i+1, err) 193 | } 194 | time.Sleep(150 * time.Millisecond) 195 | } 196 | 197 | // Should still have a working deployment 198 | deployment, err := controller.GetStatus("idempotent.com") 199 | if err != nil { 200 | t.Fatalf("Failed to get deployment status: %v", err) 201 | } 202 | 203 | // Should have exactly one healthy container 204 | healthyCount := 0 205 | if deployment.Blue.HealthState == core.HealthHealthy { 206 | healthyCount++ 207 | } 208 | if deployment.Green.HealthState == core.HealthHealthy { 209 | healthyCount++ 210 | } 211 | 212 | if healthyCount != 1 { 213 | t.Errorf("Expected exactly 1 healthy container after idempotent deployments, got %d", healthyCount) 214 | } 215 | } -------------------------------------------------------------------------------- /packages/cli/tests/init.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, beforeEach, afterEach } from "bun:test"; 2 | import { initCommand } from "../src/commands/init"; 3 | import { rmSync, existsSync } from "node:fs"; 4 | import { join } from "node:path"; 5 | import { mkdir } from "node:fs/promises"; 6 | 7 | // Test in a temporary directory 8 | const TEST_DIR = "./test-tmp"; 9 | 10 | describe("init command", () => { 11 | beforeEach(async () => { 12 | // Create a fresh test directory 13 | try { 14 | rmSync(TEST_DIR, { recursive: true, force: true }); 15 | } catch (error) { 16 | // Ignore if directory doesn't exist 17 | } 18 | 19 | await mkdir(TEST_DIR, { recursive: true }); 20 | process.chdir(TEST_DIR); 21 | }); 22 | 23 | afterEach(() => { 24 | // Clean up and go back to original directory 25 | process.chdir(".."); 26 | try { 27 | rmSync(TEST_DIR, { recursive: true, force: true }); 28 | } catch (error) { 29 | console.error(`Failed to clean up test directory: ${error}`); 30 | } 31 | }); 32 | 33 | test("should create iop.yml and .iop/secrets files", async () => { 34 | // Run the init command in non-interactive mode 35 | await initCommand(["--non-interactive"]); 36 | 37 | // Check that files were created 38 | expect(existsSync("iop.yml")).toBe(true); 39 | expect(existsSync(".iop")).toBe(true); 40 | expect(existsSync(join(".iop", "secrets"))).toBe(true); 41 | 42 | // Check file contents 43 | const configFile = Bun.file("iop.yml"); 44 | const configContent = await configFile.text(); 45 | expect(configContent).toContain("services:"); 46 | expect(configContent).toContain("web:"); 47 | 48 | // Secrets file should contain example content 49 | const secretsFile = Bun.file(join(".iop", "secrets")); 50 | const secretsContent = await secretsFile.text(); 51 | expect(secretsContent).toContain("# Add your secret environment variables here"); 52 | expect(secretsContent).toContain("# DATABASE_URL=postgres://user:password@localhost:5432/mydb"); 53 | expect(secretsContent).toContain("# POSTGRES_PASSWORD=supersecret"); 54 | expect(secretsContent).toContain("# API_KEY=your-api-key"); 55 | }); 56 | 57 | test("should not overwrite existing files", async () => { 58 | // Create the config file with custom content 59 | const customContent = "name: test-project"; 60 | await Bun.write("iop.yml", customContent); 61 | 62 | // Create the secrets directory and file 63 | await mkdir(".iop", { recursive: true }); 64 | const customSecrets = "API_KEY=1234"; 65 | await Bun.write(join(".iop", "secrets"), customSecrets); 66 | 67 | // Run the init command in non-interactive mode 68 | await initCommand(["--non-interactive"]); 69 | 70 | // Verify files still have original content 71 | const configFile = Bun.file("iop.yml"); 72 | const configContent = await configFile.text(); 73 | expect(configContent).toBe(customContent); 74 | 75 | const secretsFile = Bun.file(join(".iop", "secrets")); 76 | const secretsContent = await secretsFile.text(); 77 | expect(secretsContent).toBe(customSecrets); 78 | }); 79 | 80 | test("should create .gitignore and add secrets file when .gitignore doesn't exist", async () => { 81 | // Run the init command in non-interactive mode 82 | await initCommand(["--non-interactive"]); 83 | 84 | // Check that .gitignore was created 85 | expect(existsSync(".gitignore")).toBe(true); 86 | 87 | // Check that secrets file is in .gitignore 88 | const gitignoreFile = Bun.file(".gitignore"); 89 | const gitignoreContent = await gitignoreFile.text(); 90 | expect(gitignoreContent).toContain(".iop/secrets"); 91 | }); 92 | 93 | test("should add secrets file to existing .gitignore", async () => { 94 | // Create existing .gitignore with some content 95 | const existingContent = `node_modules/ 96 | dist/ 97 | *.log 98 | `; 99 | await Bun.write(".gitignore", existingContent); 100 | 101 | // Run the init command in non-interactive mode 102 | await initCommand(["--non-interactive"]); 103 | 104 | // Check that .gitignore exists and contains both old and new content 105 | const gitignoreFile = Bun.file(".gitignore"); 106 | const gitignoreContent = await gitignoreFile.text(); 107 | 108 | expect(gitignoreContent).toContain("node_modules/"); 109 | expect(gitignoreContent).toContain("dist/"); 110 | expect(gitignoreContent).toContain("*.log"); 111 | expect(gitignoreContent).toContain(".iop/secrets"); 112 | }); 113 | 114 | test("should not duplicate secrets file in .gitignore if already present", async () => { 115 | // Create .gitignore that already contains the secrets file 116 | const existingContent = `node_modules/ 117 | dist/ 118 | .iop/secrets 119 | *.log 120 | `; 121 | await Bun.write(".gitignore", existingContent); 122 | 123 | // Run the init command in non-interactive mode 124 | await initCommand(["--non-interactive"]); 125 | 126 | // Check that .gitignore doesn't have duplicate entries 127 | const gitignoreFile = Bun.file(".gitignore"); 128 | const gitignoreContent = await gitignoreFile.text(); 129 | 130 | const lines = gitignoreContent.split("\n"); 131 | const secretsLines = lines.filter( 132 | (line) => line.trim() === ".iop/secrets" 133 | ); 134 | expect(secretsLines.length).toBe(1); 135 | }); 136 | 137 | test("should handle different variations of secrets path in .gitignore", async () => { 138 | // Test with leading slash 139 | await Bun.write(".gitignore", "/.iop/secrets\n"); 140 | await initCommand(["--non-interactive"]); 141 | 142 | let gitignoreContent = await Bun.file(".gitignore").text(); 143 | let lines = gitignoreContent.split("\n"); 144 | let secretsLines = lines.filter( 145 | (line) => 146 | line.trim() === ".iop/secrets" || line.trim() === "/.iop/secrets" 147 | ); 148 | expect(secretsLines.length).toBe(1); 149 | 150 | // Clean up and test with forward slashes on Windows 151 | await Bun.write(".gitignore", ".iop/secrets\n"); 152 | await initCommand(["--non-interactive"]); 153 | 154 | gitignoreContent = await Bun.file(".gitignore").text(); 155 | lines = gitignoreContent.split("\n"); 156 | secretsLines = lines.filter( 157 | (line) => 158 | line.trim() === ".iop/secrets" || line.trim() === "/.iop/secrets" 159 | ); 160 | expect(secretsLines.length).toBe(1); 161 | }); 162 | 163 | test("should handle empty .gitignore file", async () => { 164 | // Create empty .gitignore 165 | await Bun.write(".gitignore", ""); 166 | 167 | // Run the init command in non-interactive mode 168 | await initCommand(["--non-interactive"]); 169 | 170 | // Check that secrets file is added to .gitignore 171 | const gitignoreFile = Bun.file(".gitignore"); 172 | const gitignoreContent = await gitignoreFile.text(); 173 | expect(gitignoreContent.trim()).toBe(".iop/secrets"); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /packages/cli/tests/service-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'bun:test'; 2 | import { 3 | requiresZeroDowntimeDeployment, 4 | getDeploymentStrategy, 5 | getServiceProxyPort, 6 | isInfrastructurePort 7 | } from '../src/utils/service-utils'; 8 | import { ServiceEntry } from '../src/config/types'; 9 | 10 | describe('service-utils', () => { 11 | describe('requiresZeroDowntimeDeployment', () => { 12 | it('should return true for services with proxy config', () => { 13 | const service: ServiceEntry = { 14 | name: 'web', 15 | server: 'example.com', 16 | image: 'nginx', 17 | proxy: { 18 | app_port: 3000 19 | } 20 | }; 21 | 22 | expect(requiresZeroDowntimeDeployment(service)).toBe(true); 23 | }); 24 | 25 | it('should return false for services with health_check config but no proxy', () => { 26 | const service: ServiceEntry = { 27 | name: 'api', 28 | server: 'example.com', 29 | image: 'node:18', 30 | health_check: { 31 | path: '/health' 32 | } 33 | }; 34 | 35 | expect(requiresZeroDowntimeDeployment(service)).toBe(false); 36 | }); 37 | 38 | it('should return false for services with exposed ports but no proxy', () => { 39 | const service: ServiceEntry = { 40 | name: 'app', 41 | server: 'example.com', 42 | image: 'myapp', 43 | ports: ['3000', '8080'] 44 | }; 45 | 46 | expect(requiresZeroDowntimeDeployment(service)).toBe(false); 47 | }); 48 | 49 | it('should return false for services with interface-bound exposed ports but no proxy', () => { 50 | const service: ServiceEntry = { 51 | name: 'app', 52 | server: 'example.com', 53 | image: 'myapp', 54 | ports: [':3000'] // Bind to all interfaces 55 | }; 56 | 57 | expect(requiresZeroDowntimeDeployment(service)).toBe(false); 58 | }); 59 | 60 | it('should return false for services with mapped infrastructure ports', () => { 61 | const service: ServiceEntry = { 62 | name: 'db', 63 | server: 'example.com', 64 | image: 'postgres:15', 65 | ports: ['5432:5432'] // Infrastructure port mapping 66 | }; 67 | 68 | expect(requiresZeroDowntimeDeployment(service)).toBe(false); 69 | }); 70 | 71 | it('should return false for services with localhost-only ports', () => { 72 | const service: ServiceEntry = { 73 | name: 'redis', 74 | server: 'example.com', 75 | image: 'redis:7', 76 | ports: ['127.0.0.1:6379:6379'] // Localhost only 77 | }; 78 | 79 | expect(requiresZeroDowntimeDeployment(service)).toBe(false); 80 | }); 81 | 82 | it('should return false for services with external interface ports but no proxy', () => { 83 | const service: ServiceEntry = { 84 | name: 'web', 85 | server: 'example.com', 86 | image: 'nginx', 87 | ports: ['0.0.0.0:80:80'] // External interface 88 | }; 89 | 90 | expect(requiresZeroDowntimeDeployment(service)).toBe(false); 91 | }); 92 | 93 | it('should return false for services with no proxy config', () => { 94 | const service: ServiceEntry = { 95 | name: 'worker', 96 | server: 'example.com', 97 | image: 'myworker' 98 | }; 99 | 100 | expect(requiresZeroDowntimeDeployment(service)).toBe(false); 101 | }); 102 | 103 | it('should return true for services with proxy config regardless of other attributes', () => { 104 | const service: ServiceEntry = { 105 | name: 'web', 106 | server: 'example.com', 107 | image: 'nginx', 108 | ports: ['5432:5432'], // Even infrastructure ports 109 | health_check: { path: '/health' }, 110 | proxy: { app_port: 3000 } // This is what matters 111 | }; 112 | 113 | expect(requiresZeroDowntimeDeployment(service)).toBe(true); 114 | }); 115 | }); 116 | 117 | describe('getDeploymentStrategy', () => { 118 | it('should return zero-downtime for HTTP services', () => { 119 | const service: ServiceEntry = { 120 | name: 'web', 121 | server: 'example.com', 122 | image: 'nginx', 123 | proxy: { app_port: 80 } 124 | }; 125 | 126 | expect(getDeploymentStrategy(service)).toBe('zero-downtime'); 127 | }); 128 | 129 | it('should return stop-start for infrastructure services', () => { 130 | const service: ServiceEntry = { 131 | name: 'db', 132 | server: 'example.com', 133 | image: 'postgres:15', 134 | ports: ['5432:5432'] 135 | }; 136 | 137 | expect(getDeploymentStrategy(service)).toBe('stop-start'); 138 | }); 139 | }); 140 | 141 | describe('getServiceProxyPort', () => { 142 | it('should return explicit proxy port when configured', () => { 143 | const service: ServiceEntry = { 144 | name: 'web', 145 | server: 'example.com', 146 | image: 'nginx', 147 | proxy: { app_port: 8080 } 148 | }; 149 | 150 | expect(getServiceProxyPort(service)).toBe(8080); 151 | }); 152 | 153 | it('should infer port from exposed ports configuration', () => { 154 | const service: ServiceEntry = { 155 | name: 'app', 156 | server: 'example.com', 157 | image: 'myapp', 158 | ports: ['3000'] 159 | }; 160 | 161 | expect(getServiceProxyPort(service)).toBe(3000); 162 | }); 163 | 164 | it('should infer port from interface-bound configuration', () => { 165 | const service: ServiceEntry = { 166 | name: 'app', 167 | server: 'example.com', 168 | image: 'myapp', 169 | ports: [':4000'] 170 | }; 171 | 172 | expect(getServiceProxyPort(service)).toBe(4000); 173 | }); 174 | 175 | it('should return undefined when no port can be determined', () => { 176 | const service: ServiceEntry = { 177 | name: 'worker', 178 | server: 'example.com', 179 | image: 'myworker' 180 | }; 181 | 182 | expect(getServiceProxyPort(service)).toBeUndefined(); 183 | }); 184 | }); 185 | 186 | describe('isInfrastructurePort', () => { 187 | it('should identify common database ports', () => { 188 | expect(isInfrastructurePort('5432')).toBe(true); // PostgreSQL 189 | expect(isInfrastructurePort('3306')).toBe(true); // MySQL 190 | expect(isInfrastructurePort('6379')).toBe(true); // Redis 191 | expect(isInfrastructurePort('27017')).toBe(true); // MongoDB 192 | }); 193 | 194 | it('should identify infrastructure ports in mappings', () => { 195 | expect(isInfrastructurePort('5433:5432')).toBe(true); 196 | expect(isInfrastructurePort('127.0.0.1:3307:3306')).toBe(true); 197 | }); 198 | 199 | it('should not identify HTTP ports as infrastructure', () => { 200 | expect(isInfrastructurePort('80')).toBe(false); 201 | expect(isInfrastructurePort('443')).toBe(false); 202 | expect(isInfrastructurePort('3000')).toBe(false); 203 | expect(isInfrastructurePort('8080')).toBe(false); 204 | }); 205 | }); 206 | }); -------------------------------------------------------------------------------- /docs/content/docs/installation.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | description: Install iop CLI tool and set up your environment 4 | --- 5 | 6 | # Installation 7 | 8 | Get iop up and running on your system. 9 | 10 | ## Prerequisites 11 | 12 | ### Local Machine 13 | 14 | - **Node.js 18+** - For running the iop CLI 15 | - **Git** - For version tracking and release IDs 16 | - **Docker** - Required for building app images locally 17 | 18 | ### Target Servers 19 | 20 | - **Ubuntu/Debian Linux** - iop supports Ubuntu and Debian-based distributions 21 | - **SSH access** - iop needs to connect and set up infrastructure 22 | - **Ports 22, 80, and 443 open** - For SSH, HTTP, and HTTPS traffic 23 | 24 | ## Install iop CLI 25 | 26 | ### Using npm (Recommended) 27 | 28 | ```bash 29 | npm install -g iop 30 | ``` 31 | 32 | ### Using npx (No Installation) 33 | 34 | You can run iop without installing it globally: 35 | 36 | ```bash 37 | npx iop init 38 | npx iop 39 | ``` 40 | 41 | ## Verify Installation 42 | 43 | Check that iop is installed correctly: 44 | 45 | ```bash 46 | iop --help 47 | ``` 48 | 49 | You should see output similar to: 50 | 51 | ``` 52 | iop CLI - Zero-downtime Docker deployments 53 | ================================================ 54 | 55 | USAGE: 56 | iop [flags] # Deploy (default action) 57 | iop [flags] # Run specific command 58 | 59 | COMMANDS: 60 | init Initialize iop.yml config and secrets file 61 | status Check deployment status across all servers 62 | proxy Manage iop proxy (status, update) 63 | 64 | GLOBAL FLAGS: 65 | --help Show command help 66 | --verbose Show detailed output 67 | 68 | EXAMPLES: 69 | iop init # Initialize new project 70 | iop # Deploy all apps and services 71 | iop --verbose # Deploy with detailed output 72 | iop status # Check all deployments 73 | iop proxy status # Check proxy status 74 | ``` 75 | 76 | ## Server Preparation (Optional) 77 | 78 | iop automatically sets up servers during deployment, but you can prepare them in advance: 79 | 80 | ### Create a Deployment User (Recommended) 81 | 82 | For better security, create a dedicated user for deployments: 83 | 84 | ```bash 85 | # On your server 86 | sudo useradd -m -s /bin/bash iop 87 | sudo usermod -aG docker,sudo iop 88 | 89 | # Set up SSH key access 90 | ssh-copy-id iop@your-server.com 91 | ``` 92 | 93 | Then configure iop to use this user: 94 | 95 | ```yaml 96 | # iop.yml 97 | ssh: 98 | username: iop 99 | ``` 100 | 101 | ### Manual Docker Installation (Optional) 102 | 103 | iop will install Docker automatically, but you can install it manually: 104 | 105 | ```bash 106 | # On Ubuntu/Debian 107 | curl -fsSL https://get.docker.com -o get-docker.sh 108 | sudo sh get-docker.sh 109 | sudo usermod -aG docker $USER 110 | ``` 111 | 112 | ## Network Configuration 113 | 114 | Ensure your servers have the required ports open: 115 | 116 | - **Port 22** - SSH access for iop 117 | - **Port 80** - HTTP traffic (redirected to HTTPS) 118 | - **Port 443** - HTTPS traffic 119 | - **Port 8080** - iop proxy API (localhost only) 120 | 121 | ### Firewall Configuration 122 | 123 | iop automatically configures firewall rules, but you can set them up manually: 124 | 125 | ```bash 126 | # On Ubuntu/Debian with ufw 127 | sudo ufw allow 22/tcp # SSH 128 | sudo ufw allow 80/tcp # HTTP 129 | sudo ufw allow 443/tcp # HTTPS 130 | sudo ufw enable 131 | ``` 132 | 133 | ## Docker Configuration 134 | 135 | iop requires Docker to be running locally for building images: 136 | 137 | ### macOS (Docker Desktop) 138 | 139 | 1. Download and install [Docker Desktop](https://www.docker.com/products/docker-desktop/) 140 | 2. Start Docker Desktop 141 | 3. Verify: `docker --version` 142 | 143 | ### Linux 144 | 145 | ```bash 146 | # Install Docker 147 | curl -fsSL https://get.docker.com -o get-docker.sh 148 | sh get-docker.sh 149 | 150 | # Add your user to docker group (avoid sudo) 151 | sudo usermod -aG docker $USER 152 | 153 | # Start Docker service 154 | sudo systemctl start docker 155 | sudo systemctl enable docker 156 | 157 | # Verify installation 158 | docker --version 159 | ``` 160 | 161 | ### Windows 162 | 163 | 1. Install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/) 164 | 2. Enable WSL 2 backend if prompted 165 | 3. Verify: `docker --version` 166 | 167 | ## Next Steps 168 | 169 | With iop installed, you're ready to: 170 | 171 | 1. [Initialize your first project](/quick-start) - `iop init` 172 | 2. [Configure your deployment](/configuration) - Edit `iop.yml` 173 | 3. [Deploy your application](/quick-start) - `iop` 174 | 175 | ## Troubleshooting 176 | 177 | ### Command Not Found 178 | 179 | If you get `command not found` after installation: 180 | 181 | 1. **Restart your terminal** - Close and reopen your terminal 182 | 2. **Check your PATH** - Make sure npm global bin directory is in your PATH: 183 | 184 | ```bash 185 | npm config get prefix 186 | # Add the bin directory to your PATH if needed 187 | export PATH=$(npm config get prefix)/bin:$PATH 188 | ``` 189 | 190 | 3. **Use npx** - Run `npx iop` as an alternative 191 | 192 | ### Permission Issues 193 | 194 | If you encounter permission issues during installation: 195 | 196 | 1. **Don't use sudo with npm install** - This can cause permission problems 197 | 2. **Configure npm to avoid sudo**: 198 | 199 | ```bash 200 | mkdir ~/.npm-global 201 | npm config set prefix '~/.npm-global' 202 | echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc 203 | source ~/.bashrc 204 | ``` 205 | 206 | 3. **Use a Node version manager** - Consider using nvm, fnm, or volta 207 | 208 | ### Docker Issues 209 | 210 | If Docker is not working: 211 | 212 | 1. **Verify Docker is running**: `docker --version` 213 | 2. **Check Docker daemon**: `docker ps` 214 | 3. **Add to docker group**: `sudo usermod -aG docker $USER` (then logout/login) 215 | 216 | ### SSH Connection Issues 217 | 218 | If iop cannot connect to your servers: 219 | 220 | 1. **Test SSH manually**: `ssh user@your-server.com` 221 | 2. **Check SSH key authentication**: `ssh -i ~/.ssh/id_rsa user@your-server.com` 222 | 3. **Verify server hostname/IP** in `iop.yml` 223 | 4. **Use verbose mode**: `iop --verbose` for detailed connection info 224 | 225 | ### Node.js Version Issues 226 | 227 | iop requires Node.js 18+. Check your version: 228 | 229 | ```bash 230 | node --version 231 | ``` 232 | 233 | If you need to upgrade: 234 | 235 | **Using nvm (recommended):** 236 | ```bash 237 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash 238 | nvm install 18 239 | nvm use 18 240 | ``` 241 | 242 | **Using package manager:** 243 | ```bash 244 | # Ubuntu/Debian 245 | curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - 246 | sudo apt-get install -y nodejs 247 | 248 | # macOS with Homebrew 249 | brew install node@18 250 | ``` 251 | 252 | ## Development Setup 253 | 254 | If you want to contribute to iop or run it from source: 255 | 256 | ```bash 257 | # Clone the repository 258 | git clone https://github.com/elitan/iop.git 259 | cd iop 260 | 261 | # Install dependencies 262 | bun install 263 | 264 | # Build the CLI 265 | bun run build 266 | 267 | # Link for local development 268 | cd packages/cli 269 | bun link 270 | 271 | # Now you can use `iop` anywhere 272 | iop --help 273 | ``` 274 | 275 | This will build the CLI from source and make it available globally on your system. --------------------------------------------------------------------------------