├── 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 |
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 |
15 |
16 | -
17 | Get started by editing{" "}
18 |
19 | src/app/page.tsx
20 |
21 | .
22 |
23 | -
24 | Save and see your changes instantly.
25 |
26 |
27 |
28 |
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.
--------------------------------------------------------------------------------