From cb6ba11c7310ed6eee831f808c10dcad490a45ff Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Mon, 29 Jun 2026 08:01:37 +1000 Subject: [PATCH 1/8] Fix control plane upgrade status UI Amp-Thread-ID: https://ampcode.com/threads/T-019f0429-c6b7-711e-9d05-1ee6dce1919a Co-authored-by: Amp --- docs/installation.mdx | 34 ++++++ web/components/settings/global-settings.tsx | 121 +++++++++----------- web/lib/control-plane-updates.ts | 23 +++- 3 files changed, 106 insertions(+), 72 deletions(-) diff --git a/docs/installation.mdx b/docs/installation.mdx index b62363ed..25fe7e20 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -73,6 +73,40 @@ Production hosts should also cap Docker container logs. The installer creates already configured, keep your existing daemon settings and add equivalent log rotation manually. +## Manual upgrades + +Use the one-click upgrade flow in **Settings** when possible. If the updater +cannot run, update the deployment from the host manually. + +Back up your `.env` file and database before upgrading. Then fetch the Compose +files for the release, update `TECHULUS_CLOUD_VERSION`, pull images, and restart +the stack: + +```bash +cd /opt/techulus-cloud +TARGET_VERSION=vX.Y.Z +COMPOSE_FILE=$(grep -E '^COMPOSE_FILE=' .env | cut -d= -f2- || true) +if [ -z "$COMPOSE_FILE" ]; then + COMPOSE_FILE=compose.production.yml +fi + +cp .env ".env.backup.$(date +%Y%m%d%H%M%S)" +curl -fsSL "https://raw.githubusercontent.com/techulus/cloud/${TARGET_VERSION}/deployment/compose.production.yml" -o compose.production.yml +curl -fsSL "https://raw.githubusercontent.com/techulus/cloud/${TARGET_VERSION}/deployment/compose.postgres.yml" -o compose.postgres.yml + +if grep -q '^TECHULUS_CLOUD_VERSION=' .env; then + sed -i.bak "s/^TECHULUS_CLOUD_VERSION=.*/TECHULUS_CLOUD_VERSION=${TARGET_VERSION}/" .env +else + echo "TECHULUS_CLOUD_VERSION=${TARGET_VERSION}" >> .env +fi + +docker compose -f "$COMPOSE_FILE" pull +docker compose -f "$COMPOSE_FILE" up -d --remove-orphans +``` + +Rollback after migrations may require restoring the database backup before you +restart older images. + The Compose files include container health checks for visibility. Plain Docker Compose reports unhealthy containers but does not restart them automatically, so use the common commands below when investigating a self-hosted service. diff --git a/web/components/settings/global-settings.tsx b/web/components/settings/global-settings.tsx index 746fbbb3..d8769f0c 100644 --- a/web/components/settings/global-settings.tsx +++ b/web/components/settings/global-settings.tsx @@ -73,6 +73,9 @@ const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", }); +const CONTROL_PLANE_UPGRADE_DOCS_URL = + "https://docs.techulus.com/installation#manual-upgrades"; + function formatCheckedAt(value: string | null | undefined) { if (!value) return "Never"; const date = new Date(value); @@ -80,25 +83,6 @@ function formatCheckedAt(value: string | null | undefined) { return dateTimeFormatter.format(date); } -function buildUpgradeCommand(targetVersion: string) { - return `DEPLOY_DIR=/opt/techulus-cloud -cd "$DEPLOY_DIR" -COMPOSE_FILE=$(grep -E '^COMPOSE_FILE=' .env | cut -d= -f2- || true) -if [ -z "$COMPOSE_FILE" ]; then - COMPOSE_FILE=compose.production.yml -fi -sudo cp .env ".env.backup.$(date +%Y%m%d%H%M%S)" -sudo curl -fsSL "https://raw.githubusercontent.com/techulus/cloud/${targetVersion}/deployment/compose.production.yml" -o compose.production.yml -sudo curl -fsSL "https://raw.githubusercontent.com/techulus/cloud/${targetVersion}/deployment/compose.postgres.yml" -o compose.postgres.yml -if grep -q '^TECHULUS_CLOUD_VERSION=' .env; then - sudo sed -i.bak "s/^TECHULUS_CLOUD_VERSION=.*/TECHULUS_CLOUD_VERSION=${targetVersion}/" .env -else - echo "TECHULUS_CLOUD_VERSION=${targetVersion}" | sudo tee -a .env >/dev/null -fi -sudo docker compose -f "$COMPOSE_FILE" pull -sudo docker compose -f "$COMPOSE_FILE" up -d --remove-orphans`; -} - export function GlobalSettings({ servers, initialSettings, @@ -271,9 +255,6 @@ export function GlobalSettings({ const updateState = initialSettings.controlPlaneUpdateState; const upgradeState = initialSettings.controlPlaneUpgradeState; const displayVersion = updateState?.currentVersion ?? appVersion ?? "dev"; - const upgradeCommand = updateState?.latestVersion - ? buildUpgradeCommand(updateState.latestVersion) - : null; const upgradeRunning = upgradeState?.status === "running"; if (servers.length === 0) { @@ -625,52 +606,56 @@ export function GlobalSettings({ > )} - {updateState?.updateAvailable && - updateState.latestVersion && - upgradeCommand && ( - - - } - > - Upgrade to {updateState.latestVersion} - - - - Upgrade control plane - - Run this command on the host that runs - /opt/techulus-cloud. It updates the compose files from - the release tag, pulls images, and restarts the stack. - - - - {upgradeCommand} - -
- The one-click upgrader runs the same flow through an - internal updater service. Back up your database before - upgrades that may include schema changes. Rollback after - migrations may require restoring the database backup. -
- - - -
-
- )} + {updateState?.updateAvailable && updateState.latestVersion && ( + + + } + > + Upgrade to {updateState.latestVersion} + + + + Upgrade control plane + + This starts the internal updater service, which backs up + the environment file, refreshes the release compose + files, pulls images, and restarts the stack. + + +
+ The updater creates a database backup before running the + new version. Rollback after migrations may require + restoring that backup. Prefer the one-click upgrade; use + manual upgrade steps only if the updater cannot run. See{" "} + + the installation docs + + . +
+ + + +
+
+ )} {updateState && !updateState.updateAvailable && diff --git a/web/lib/control-plane-updates.ts b/web/lib/control-plane-updates.ts index 3aef9ab6..58331f28 100644 --- a/web/lib/control-plane-updates.ts +++ b/web/lib/control-plane-updates.ts @@ -104,8 +104,9 @@ async function fetchLatestRelease(): Promise { return response.json(); } -export async function checkControlPlaneUpdate(): Promise { - const currentVersion = getCurrentControlPlaneVersion(); +export async function checkControlPlaneUpdate( + currentVersion = getCurrentControlPlaneVersion(), +): Promise { const checkedAt = new Date().toISOString(); const channel = getChannel(currentVersion); @@ -150,12 +151,14 @@ export async function checkControlPlaneUpdate(): Promise( SETTING_KEYS.CONTROL_PLANE_UPDATE_STATE, ), - checkControlPlaneUpdate(), + checkControlPlaneUpdate(currentVersion), ]); const state = nextState.error && previousState @@ -245,5 +248,17 @@ export async function startControlPlaneUpgrade(targetVersion: string) { export async function refreshControlPlaneUpgradeState() { const upgradeState = await fetchUpdaterStatus(); await setSetting(SETTING_KEYS.CONTROL_PLANE_UPGRADE_STATE, upgradeState); + + if (upgradeState.status === "succeeded" && upgradeState.targetVersion) { + try { + await checkAndPersistControlPlaneUpdate(upgradeState.targetVersion); + } catch (error) { + console.error( + "[control-plane-updates] failed to refresh update state after upgrade", + error, + ); + } + } + return upgradeState; } From 7db0182bebf264b7c0a400adb90d7ab6e9c3d695 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:01:10 +1000 Subject: [PATCH 2/8] Rewrite tc CLI in Go --- .github/workflows/cli-ci.yml | 89 +++ .github/workflows/release.yml | 57 +- cli/.mise.toml | 2 +- cli/bun.lock | 91 --- cli/cmd/tc/main.go | 17 + cli/go.mod | 13 + cli/go.sum | 12 + cli/internal/api/client.go | 170 +++++ cli/internal/api/client_test.go | 77 +++ cli/internal/auth/config.go | 95 +++ cli/internal/auth/config_test.go | 49 ++ cli/internal/cli/app.go | 789 +++++++++++++++++++++++ cli/internal/cli/app_test.go | 204 ++++++ cli/internal/cli/types.go | 196 ++++++ cli/internal/manifest/manifest.go | 224 +++++++ cli/internal/manifest/manifest_test.go | 111 ++++ cli/internal/output/output.go | 40 ++ cli/package.json | 24 - cli/pnpm-lock.yaml | 348 ---------- cli/src/config.ts | 64 -- cli/src/main.ts | 844 ------------------------- cli/src/manifest.ts | 93 --- cli/tsconfig.json | 14 - 23 files changed, 2141 insertions(+), 1482 deletions(-) create mode 100644 .github/workflows/cli-ci.yml delete mode 100644 cli/bun.lock create mode 100644 cli/cmd/tc/main.go create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/internal/api/client.go create mode 100644 cli/internal/api/client_test.go create mode 100644 cli/internal/auth/config.go create mode 100644 cli/internal/auth/config_test.go create mode 100644 cli/internal/cli/app.go create mode 100644 cli/internal/cli/app_test.go create mode 100644 cli/internal/cli/types.go create mode 100644 cli/internal/manifest/manifest.go create mode 100644 cli/internal/manifest/manifest_test.go create mode 100644 cli/internal/output/output.go delete mode 100644 cli/package.json delete mode 100644 cli/pnpm-lock.yaml delete mode 100644 cli/src/config.ts delete mode 100644 cli/src/main.ts delete mode 100644 cli/src/manifest.ts delete mode 100644 cli/tsconfig.json diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml new file mode 100644 index 00000000..49c40aab --- /dev/null +++ b/.github/workflows/cli-ci.yml @@ -0,0 +1,89 @@ +name: CLI CI + +on: + pull_request: + paths: + - "cli/**" + - ".github/workflows/cli-ci.yml" + push: + branches: + - main + paths: + - "cli/**" + - ".github/workflows/cli-ci.yml" + +permissions: + contents: read + +concurrency: + group: cli-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: Test + run: go test ./... + + - name: Vet + run: go vet ./... + + - name: Install staticcheck + run: GOBIN=$PWD/.bin go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Staticcheck + run: ./.bin/staticcheck ./... + + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: windows + goarch: amd64 + defaults: + run: + working-directory: cli + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + mkdir -p dist + ext="" + if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi + go build -o dist/tc-${{ matrix.goos }}-${{ matrix.goarch }}$ext ./cmd/tc diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df91d225..d9487554 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,8 +46,52 @@ jobs: path: agent/agent-${{ matrix.goos }}-${{ matrix.goarch }} retention-days: 1 + cli: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: windows + goarch: amd64 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: Build + working-directory: cli + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + ext="" + if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi + go build -ldflags="-s -w -X main.version=${{ github.ref_name }}" -o tc-${{ matrix.goos }}-${{ matrix.goarch }}$ext ./cmd/tc + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: tc-${{ matrix.goos }}-${{ matrix.goarch }} + path: cli/tc-${{ matrix.goos }}-${{ matrix.goarch }}* + retention-days: 1 + release: - needs: [agent, web, registry, updater] + needs: [agent, cli, web, registry, updater] runs-on: ubuntu-latest steps: - name: Download agent artifacts @@ -57,10 +101,17 @@ jobs: pattern: agent-* merge-multiple: true + - name: Download CLI artifacts + uses: actions/download-artifact@v8 + with: + path: binaries + pattern: tc-* + merge-multiple: true + - name: Generate SHA256 checksums run: | cd binaries - sha256sum agent-* > checksums.txt + sha256sum agent-* tc-* > checksums.txt - name: Create GitHub release env: @@ -70,7 +121,7 @@ jobs: --repo ${{ github.repository }} \ --title "${{ github.ref_name }}" \ --generate-notes \ - binaries/agent-* binaries/checksums.txt + binaries/agent-* binaries/tc-* binaries/checksums.txt web: runs-on: ubuntu-latest diff --git a/cli/.mise.toml b/cli/.mise.toml index a94d1edc..b95a26e9 100644 --- a/cli/.mise.toml +++ b/cli/.mise.toml @@ -1,2 +1,2 @@ [tools] -bun = "latest" +go = "1.25.5" diff --git a/cli/bun.lock b/cli/bun.lock deleted file mode 100644 index 4b10c3f2..00000000 --- a/cli/bun.lock +++ /dev/null @@ -1,91 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "techulus-cli", - "dependencies": { - "yaml": "^2.8.2", - "zod": "^4.3.5", - }, - "devDependencies": { - "@types/node": "^22.17.0", - "tsx": "^4.19.2", - "typescript": "^5.9.2", - }, - }, - }, - "packages": { - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - - "@types/node": ["@types/node@22.19.16", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-K6csxIjY+9RoDxdP6/wzaJzXaCf4znBz0/y0rrQDsbqmzQ5QFsOjubbsYWZhj6ZCgz3mjlyDZS+EJkhA9jWl9Q=="], - - "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], - - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "0.27.7", "get-tsconfig": "4.13.7" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], - - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - } -} diff --git a/cli/cmd/tc/main.go b/cli/cmd/tc/main.go new file mode 100644 index 00000000..6814ec0c --- /dev/null +++ b/cli/cmd/tc/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "os" + + "techulus/cloud-cli/internal/cli" +) + +var version = "dev" + +func main() { + if err := cli.Execute(version, os.Stdin, os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 00000000..ce56b32b --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,13 @@ +module techulus/cloud-cli + +go 1.25.5 + +require ( + github.com/spf13/cobra v1.10.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 00000000..7af05198 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go new file mode 100644 index 00000000..8dedbf33 --- /dev/null +++ b/cli/internal/api/client.go @@ -0,0 +1,170 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type Client struct { + Host string + APIKey string + HTTPClient *http.Client +} + +type ErrorResponse struct { + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + +type APIError struct { + Status int + Message string + Code string + Host string +} + +func (e *APIError) Error() string { + message := e.Message + if message == "" { + message = fmt.Sprintf("Request failed with %d", e.Status) + } + if e.Code != "" { + message += fmt.Sprintf(" (%s)", e.Code) + } + if e.Status == http.StatusUnauthorized || e.Status == http.StatusForbidden { + message += fmt.Sprintf("\n\nYour CLI session is not authorized. Run:\n tc auth login --host %s", e.Host) + } + return message +} + +func NormalizeHost(host string) string { + trimmed := strings.TrimRight(strings.TrimSpace(host), "/") + if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") { + return "https://" + trimmed + } + return trimmed +} + +func NewClient(host, apiKey string) *Client { + return &Client{ + Host: NormalizeHost(host), + APIKey: apiKey, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) RequestJSON(ctx context.Context, method, path string, query url.Values, body any, out any) error { + endpoint := c.Host + path + if len(query) > 0 { + endpoint += "?" + query.Encode() + } + headers := map[string]string{} + if c.APIKey != "" { + headers["x-api-key"] = c.APIKey + } + return JSON(ctx, c.HTTPClient, method, endpoint, headers, body, out) +} + +func JSON(ctx context.Context, client *http.Client, method, endpoint string, headers map[string]string, body any, out any) error { + if client == nil { + client = http.DefaultClient + } + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return err + } + req.Header.Set("content-type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var apiErr ErrorResponse + _ = json.Unmarshal(raw, &apiErr) + message := apiErr.Message + if message == "" { + message = apiErr.Error + } + parsed, _ := url.Parse(endpoint) + host := "" + if parsed != nil { + host = NormalizeHost(parsed.Scheme + "://" + parsed.Host) + } + return &APIError{ + Status: resp.StatusCode, + Message: message, + Code: apiErr.Code, + Host: host, + } + } + if out == nil || len(raw) == 0 { + return nil + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("invalid JSON response: %w", err) + } + return nil +} + +func JSONStatus(ctx context.Context, client *http.Client, method, endpoint string, body any, out any) (int, error) { + if client == nil { + client = http.DefaultClient + } + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return 0, err + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return 0, err + } + req.Header.Set("content-type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, err + } + if len(raw) > 0 && out != nil { + if err := json.Unmarshal(raw, out); err != nil { + return resp.StatusCode, fmt.Errorf("invalid JSON response: %w", err) + } + } + return resp.StatusCode, nil +} diff --git a/cli/internal/api/client_test.go b/cli/internal/api/client_test.go new file mode 100644 index 00000000..770006c8 --- /dev/null +++ b/cli/internal/api/client_test.go @@ -0,0 +1,77 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRequestJSONSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-api-key") != "secret" { + t.Fatalf("x-api-key = %q", r.Header.Get("x-api-key")) + } + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "secret") + client.HTTPClient = server.Client() + + var got struct { + OK bool `json:"ok"` + } + if err := client.RequestJSON(context.Background(), http.MethodGet, "/test", nil, nil, &got); err != nil { + t.Fatalf("RequestJSON() error = %v", err) + } + if !got.OK { + t.Fatal("ok = false") + } +} + +func TestRequestJSONAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"Bad manifest"}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "") + client.HTTPClient = server.Client() + err := client.RequestJSON(context.Background(), http.MethodPost, "/test", nil, nil, nil) + if err == nil || err.Error() != "Bad manifest" { + t.Fatalf("error = %v", err) + } +} + +func TestRequestJSONUnauthorizedGuidance(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"Unauthorized","code":"UNAUTHORIZED"}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "") + client.HTTPClient = server.Client() + err := client.RequestJSON(context.Background(), http.MethodGet, "/test", nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "tc auth login --host "+server.URL) { + t.Fatalf("error = %v", err) + } +} + +func TestRequestJSONInvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`not-json`)) + })) + defer server.Close() + + client := NewClient(server.URL, "") + client.HTTPClient = server.Client() + var got map[string]any + err := client.RequestJSON(context.Background(), http.MethodGet, "/test", nil, nil, &got) + if err == nil || !strings.Contains(err.Error(), "invalid JSON response") { + t.Fatalf("error = %v", err) + } +} diff --git a/cli/internal/auth/config.go b/cli/internal/auth/config.go new file mode 100644 index 00000000..4134fb45 --- /dev/null +++ b/cli/internal/auth/config.go @@ -0,0 +1,95 @@ +package auth + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" +) + +const ConfigDirName = "techulus-cloud-cli" + +type Config struct { + Host string `json:"host"` + APIKey string `json:"apiKey"` + KeyID string `json:"keyId,omitempty"` + KeyName string `json:"keyName,omitempty"` + User *User `json:"user,omitempty"` +} + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` +} + +func ConfigRoot() string { + home, _ := os.UserHomeDir() + return ConfigRootFor(runtime.GOOS, home, os.Getenv) +} + +func ConfigRootFor(goos, home string, getenv func(string) string) string { + if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" { + return xdg + } + if goos == "darwin" { + return filepath.Join(home, "Library", "Application Support") + } + if goos == "windows" { + if appData := getenv("APPDATA"); appData != "" { + return appData + } + } + return filepath.Join(home, ".config") +} + +func ConfigDir() string { + return filepath.Join(ConfigRoot(), ConfigDirName) +} + +func ConfigPath() string { + return filepath.Join(ConfigDir(), "config.json") +} + +func ConfigPathFor(goos, home string, getenv func(string) string) string { + return filepath.Join(ConfigRootFor(goos, home, getenv), ConfigDirName, "config.json") +} + +func ReadConfig() (*Config, error) { + contents, err := os.ReadFile(ConfigPath()) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + var config Config + if err := json.Unmarshal(contents, &config); err != nil { + return nil, err + } + return &config, nil +} + +func WriteConfig(config Config) error { + if err := os.MkdirAll(ConfigDir(), 0o700); err != nil { + return err + } + contents, err := json.MarshalIndent(config, "", "\t") + if err != nil { + return err + } + if err := os.WriteFile(ConfigPath(), contents, 0o600); err != nil { + return err + } + return os.Chmod(ConfigPath(), 0o600) +} + +func DeleteConfig() error { + err := os.Remove(ConfigPath()) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} diff --git a/cli/internal/auth/config_test.go b/cli/internal/auth/config_test.go new file mode 100644 index 00000000..9ebb7743 --- /dev/null +++ b/cli/internal/auth/config_test.go @@ -0,0 +1,49 @@ +package auth + +import ( + "path/filepath" + "testing" +) + +func TestConfigPathForPlatforms(t *testing.T) { + tests := []struct { + name string + goos string + env map[string]string + want string + }{ + { + name: "darwin", + goos: "darwin", + want: filepath.Join("/home/alice", "Library", "Application Support", ConfigDirName, "config.json"), + }, + { + name: "linux xdg", + goos: "linux", + env: map[string]string{"XDG_CONFIG_HOME": "/xdg"}, + want: filepath.Join("/xdg", ConfigDirName, "config.json"), + }, + { + name: "linux fallback", + goos: "linux", + want: filepath.Join("/home/alice", ".config", ConfigDirName, "config.json"), + }, + { + name: "windows appdata", + goos: "windows", + env: map[string]string{"APPDATA": `C:\Users\Alice\AppData\Roaming`}, + want: filepath.Join(`C:\Users\Alice\AppData\Roaming`, ConfigDirName, "config.json"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ConfigPathFor(tt.goos, "/home/alice", func(key string) string { + return tt.env[key] + }) + if got != tt.want { + t.Fatalf("ConfigPathFor() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go new file mode 100644 index 00000000..22c88ffc --- /dev/null +++ b/cli/internal/cli/app.go @@ -0,0 +1,789 @@ +package cli + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "techulus/cloud-cli/internal/api" + "techulus/cloud-cli/internal/auth" + "techulus/cloud-cli/internal/manifest" + "techulus/cloud-cli/internal/output" +) + +const ( + cliClientID = "techulus-cli" + defaultLogTail = 100 + logPollInterval = 2 * time.Second + defaultAPITimeout = 30 * time.Second +) + +type App struct { + Version string + In io.Reader + Out io.Writer + Err io.Writer + HTTPClient *http.Client + Sleep func(time.Duration) + Now func() time.Time + IsInteractive func() bool + GetCWD func() (string, error) +} + +func Execute(version string, in io.Reader, out io.Writer, errOut io.Writer) error { + app := NewApp(version, in, out, errOut) + return app.Execute() +} + +func NewApp(version string, in io.Reader, out io.Writer, errOut io.Writer) *App { + return &App{ + Version: version, + In: in, + Out: out, + Err: errOut, + HTTPClient: &http.Client{Timeout: defaultAPITimeout}, + Sleep: time.Sleep, + Now: time.Now, + IsInteractive: func() bool { + inFile, inOK := in.(*os.File) + outFile, outOK := out.(*os.File) + if !inOK || !outOK { + return false + } + inStat, inErr := inFile.Stat() + outStat, outErr := outFile.Stat() + return inErr == nil && outErr == nil && + inStat.Mode()&os.ModeCharDevice != 0 && + outStat.Mode()&os.ModeCharDevice != 0 + }, + GetCWD: func() (string, error) { + if cwd := os.Getenv("INIT_CWD"); cwd != "" { + return cwd, nil + } + return os.Getwd() + }, + } +} + +func (a *App) Execute() error { + cmd := a.rootCommand() + cmd.SetIn(a.In) + cmd.SetOut(a.Out) + cmd.SetErr(a.Err) + return cmd.Execute() +} + +func (a *App) rootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "tc", + Short: "Techulus Cloud CLI", + SilenceUsage: true, + SilenceErrors: true, + } + + root.AddCommand(a.authCommand()) + root.AddCommand(a.initCommand()) + root.AddCommand(a.linkCommand()) + root.AddCommand(a.applyCommand()) + root.AddCommand(a.deployCommand()) + root.AddCommand(a.statusCommand()) + root.AddCommand(a.logsCommand()) + root.AddCommand(a.versionCommand()) + root.AddCommand(a.completionCommand(root)) + + return root +} + +func (a *App) authCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Manage CLI authentication", + } + cmd.AddCommand(a.authLoginCommand()) + cmd.AddCommand(a.authLogoutCommand()) + cmd.AddCommand(a.authWhoamiCommand()) + return cmd +} + +func (a *App) authLoginCommand() *cobra.Command { + var host string + cmd := &cobra.Command{ + Use: "login", + Short: "Sign in with device login", + RunE: func(cmd *cobra.Command, args []string) error { + if host == "" { + existing, err := auth.ReadConfig() + if err != nil { + return err + } + if existing != nil { + host = existing.Host + } + } + if host == "" { + return errors.New("missing --host") + } + return a.runAuthLogin(cmd.Context(), api.NormalizeHost(host)) + }, + } + cmd.Flags().StringVar(&host, "host", "", "Control plane host URL") + return cmd +} + +func (a *App) authLogoutCommand() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove the saved CLI session", + RunE: func(cmd *cobra.Command, args []string) error { + if err := auth.DeleteConfig(); err != nil { + return err + } + output.Section(a.Out, "Signed out") + output.Field(a.Out, "Config", "removed") + return nil + }, + } +} + +func (a *App) authWhoamiCommand() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Show the current CLI account", + RunE: func(cmd *cobra.Command, args []string) error { + config, err := requireConfig() + if err != nil { + return err + } + var response struct { + User auth.User `json:"user"` + } + client := a.client(config) + if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/cli/auth/whoami", nil, nil, &response); err != nil { + return err + } + output.Section(a.Out, "Account") + output.Field(a.Out, "User", response.User.Email) + output.Field(a.Out, "Name", response.User.Name) + output.Field(a.Out, "Host", config.Host) + return nil + }, + } +} + +func (a *App) initCommand() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Create a starter techulus.yml", + RunE: func(cmd *cobra.Command, args []string) error { + cwd, err := a.GetCWD() + if err != nil { + return err + } + manifestPath := filepath.Join(cwd, "techulus.yml") + if _, err := os.Stat(manifestPath); err == nil { + return errors.New("techulus.yml already exists") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + folderName := manifest.Slugify(filepath.Base(cwd)) + if folderName == "" { + folderName = "my-service" + } + starter := fmt.Sprintf(`apiVersion: v1 +project: %s +environment: production +service: + name: %s + source: + type: image + image: nginx:1.27 + replicas: + count: 1 + resources: + cpuCores: 2 + memoryMb: 1024 + ports: + - port: 80 + public: false +`, folderName, folderName) + if err := os.WriteFile(manifestPath, []byte(starter), 0o644); err != nil { + return err + } + output.Section(a.Out, "Manifest") + output.Field(a.Out, "Created", manifestPath) + output.Next(a.Out, "tc apply") + return nil + }, + } +} + +func (a *App) linkCommand() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "link", + Short: "Create techulus.yml from an existing service", + RunE: func(cmd *cobra.Command, args []string) error { + if !a.IsInteractive() { + return errors.New("tc link requires an interactive terminal") + } + config, err := requireConfig() + if err != nil { + return err + } + cwd, err := a.GetCWD() + if err != nil { + return err + } + manifestPath := filepath.Join(cwd, "techulus.yml") + if _, err := os.Stat(manifestPath); err == nil && !force { + return errors.New("techulus.yml already exists. Run `tc link --force` to replace it") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + client := a.client(config) + var targets linkTargetsResponse + if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/manifest/link-targets", nil, nil, &targets); err != nil { + return err + } + if countSupportedServices(targets.Projects) == 0 { + return errors.New("no linkable services were found in your account") + } + projectChoices := filterProjectsWithServices(targets.Projects) + if len(projectChoices) == 0 { + return errors.New("no services were found in your account") + } + reader := bufio.NewReader(a.In) + project, err := selectFromList(reader, a.Out, "Select a project:", projectChoices, renderProjectChoice, nil) + if err != nil { + return err + } + environmentChoices := filterEnvironmentsWithServices(project.Environments) + environment, err := selectFromList(reader, a.Out, "Select an environment:", environmentChoices, renderEnvironmentChoice, nil) + if err != nil { + return err + } + service, err := selectFromList(reader, a.Out, "Select a service:", environment.Services, renderServiceChoice, disabledServiceReason) + if err != nil { + return err + } + + var result linkManifestResponse + if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/link", nil, map[string]string{"serviceId": service.ID}, &result); err != nil { + return err + } + if err := manifest.Save(manifestPath, result.Manifest); err != nil { + return err + } + output.Section(a.Out, "Linked") + output.Field(a.Out, "Service", fmt.Sprintf("%s/%s/%s", result.Service.Project, result.Service.Environment, result.Service.Name)) + output.Field(a.Out, "Manifest", manifestPath) + output.Next(a.Out, "tc status or tc apply") + return nil + }, + } + cmd.Flags().BoolVar(&force, "force", false, "Replace an existing techulus.yml") + return cmd +} + +func (a *App) applyCommand() *cobra.Command { + return &cobra.Command{ + Use: "apply", + Short: "Apply techulus.yml to the linked service", + RunE: func(cmd *cobra.Command, args []string) error { + config, err := requireConfig() + if err != nil { + return err + } + loaded, err := a.ensureManifest() + if err != nil { + return err + } + var result applyResponse + client := a.client(config) + if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/apply", nil, loaded.Manifest, &result); err != nil { + return err + } + printApplyResult(a.Out, result) + return nil + }, + } +} + +func (a *App) deployCommand() *cobra.Command { + return &cobra.Command{ + Use: "deploy", + Short: "Deploy the service described by techulus.yml", + RunE: func(cmd *cobra.Command, args []string) error { + config, err := requireConfig() + if err != nil { + return err + } + loaded, err := a.ensureManifest() + if err != nil { + return err + } + var result deployResponse + client := a.client(config) + if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/deploy", nil, loaded.Manifest, &result); err != nil { + return err + } + output.Section(a.Out, "Deploy") + output.Field(a.Out, "Service", output.ShortID(result.ServiceID)) + output.Field(a.Out, "Status", output.Status(result.Status)) + if result.RolloutID != nil && *result.RolloutID != "" { + output.Field(a.Out, "Rollout", output.ShortID(*result.RolloutID)) + } + output.Next(a.Out, "tc status") + return nil + }, + } +} + +func (a *App) statusCommand() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show service rollout and deployment status", + RunE: func(cmd *cobra.Command, args []string) error { + config, err := requireConfig() + if err != nil { + return err + } + loaded, err := a.ensureManifest() + if err != nil { + return err + } + var status statusResponse + client := a.client(config) + query := manifestIdentityQuery(loaded.Manifest) + if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/manifest/status", query, nil, &status); err != nil { + return err + } + printStatus(a.Out, loaded.Manifest, status) + return nil + }, + } +} + +func (a *App) logsCommand() *cobra.Command { + var tail int + var follow bool + cmd := &cobra.Command{ + Use: "logs", + Short: "Show service logs", + RunE: func(cmd *cobra.Command, args []string) error { + if tail < 1 || tail > 1000 { + return errors.New("log line count must be between 1 and 1000") + } + tailChanged := cmd.Flags().Changed("tail") || cmd.Flags().Changed("n") + if tailChanged && !cmd.Flags().Changed("follow") { + follow = false + } + config, err := requireConfig() + if err != nil { + return err + } + loaded, err := a.ensureManifest() + if err != nil { + return err + } + return a.runLogs(cmd.Context(), config, loaded.Manifest, tail, follow) + }, + } + cmd.Flags().IntVarP(&tail, "tail", "n", defaultLogTail, "Number of log lines to fetch") + cmd.Flags().BoolVar(&follow, "follow", true, "Continue polling for new log lines") + return cmd +} + +func (a *App) versionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the tc version", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Fprintln(a.Out, a.Version) + return nil + }, + } +} + +func (a *App) completionCommand(root *cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return root.GenBashCompletion(a.Out) + case "zsh": + return root.GenZshCompletion(a.Out) + case "fish": + return root.GenFishCompletion(a.Out, true) + case "powershell": + return root.GenPowerShellCompletion(a.Out) + default: + return fmt.Errorf("unsupported shell %q", args[0]) + } + }, + } + return cmd +} + +func (a *App) client(config *auth.Config) *api.Client { + client := api.NewClient(config.Host, config.APIKey) + client.HTTPClient = a.HTTPClient + return client +} + +func (a *App) ensureManifest() (*manifest.Loaded, error) { + cwd, err := a.GetCWD() + if err != nil { + return nil, err + } + loaded, err := manifest.Load(cwd) + if err == nil { + return loaded, nil + } + if errors.Is(err, os.ErrNotExist) { + return nil, errors.New("no techulus.yml found in the current directory. Run `tc init` to create one") + } + return nil, fmt.Errorf("invalid techulus.yml: %w", err) +} + +func requireConfig() (*auth.Config, error) { + config, err := auth.ReadConfig() + if err != nil { + return nil, err + } + if config == nil { + return nil, errors.New("not logged in. Run `tc auth login --host ` first") + } + return config, nil +} + +func (a *App) runAuthLogin(ctx context.Context, host string) error { + var deviceCode deviceCodeResponse + if err := api.JSON(ctx, a.HTTPClient, http.MethodPost, host+"/api/auth/device/code", nil, map[string]string{ + "client_id": cliClientID, + "scope": "cli", + }, &deviceCode); err != nil { + return err + } + + verificationURL := deviceCode.VerificationURIComplete + if verificationURL == "" { + verificationURL = deviceCode.VerificationURI + } + output.Section(a.Out, "Device login") + output.Field(a.Out, "Host", host) + output.Field(a.Out, "URL", verificationURL) + output.Field(a.Out, "Code", deviceCode.UserCode) + fmt.Fprintln(a.Out, "\nOpen the verification URL in your browser to continue.") + + interval := time.Duration(deviceCode.Interval) * time.Second + if interval <= 0 { + interval = 5 * time.Second + } + var accessToken string + for accessToken == "" { + a.Sleep(interval) + var tokenResponse deviceTokenResponse + status, err := api.JSONStatus(ctx, a.HTTPClient, http.MethodPost, host+"/api/auth/device/token", map[string]string{ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": deviceCode.DeviceCode, + "client_id": cliClientID, + }, &tokenResponse) + if err != nil { + return err + } + if status >= 200 && status < 300 && tokenResponse.AccessToken != "" { + accessToken = tokenResponse.AccessToken + break + } + switch tokenResponse.Error { + case "authorization_pending": + fmt.Fprint(a.Out, ".") + case "slow_down": + interval += 5 * time.Second + case "access_denied": + if tokenResponse.ErrorDescription != "" { + return errors.New(tokenResponse.ErrorDescription) + } + return errors.New("device authorization was denied") + case "expired_token": + if tokenResponse.ErrorDescription != "" { + return errors.New(tokenResponse.ErrorDescription) + } + return errors.New("device authorization expired") + case "": + return fmt.Errorf("unexpected response from device token endpoint: status %d", status) + default: + if tokenResponse.ErrorDescription != "" { + return errors.New(tokenResponse.ErrorDescription) + } + return errors.New(tokenResponse.Error) + } + } + + fmt.Fprintln(a.Out, "\n\nDevice approved. Creating a CLI API key...") + machineName, _ := os.Hostname() + platform := runtime.GOOS + "/" + runtime.GOARCH + var exchange exchangeResponse + if err := api.JSON(ctx, a.HTTPClient, http.MethodPost, host+"/api/v1/cli/auth/exchange", map[string]string{ + "authorization": "Bearer " + accessToken, + }, map[string]string{ + "machineName": machineName, + "platform": platform, + "cliVersion": a.Version, + }, &exchange); err != nil { + return err + } + if err := auth.WriteConfig(auth.Config{ + Host: host, + APIKey: exchange.APIKey, + KeyID: exchange.KeyID, + KeyName: exchange.Name, + User: &exchange.User, + }); err != nil { + return err + } + output.Section(a.Out, "Signed in") + output.Field(a.Out, "User", exchange.User.Email) + output.Field(a.Out, "Name", exchange.User.Name) + output.Field(a.Out, "Host", host) + key := "created" + if exchange.KeyID != "" { + key = output.ShortID(exchange.KeyID) + } + output.Field(a.Out, "Key", key) + return nil +} + +func (a *App) runLogs(ctx context.Context, config *auth.Config, value manifest.Manifest, tail int, follow bool) error { + client := a.client(config) + result, err := fetchLogs(ctx, client, value, tail, "") + if err != nil { + return err + } + fmt.Fprintf(a.Out, "%s/%s/%s\n", value.Project, value.Environment, value.Service.Name) + if !result.LoggingEnabled { + output.Section(a.Out, "Logs") + output.Field(a.Out, "Status", "disabled") + return nil + } + if !follow && len(result.Logs) == 0 { + output.Section(a.Out, "Logs") + output.Field(a.Out, "Lines", "none") + return nil + } + if !follow { + output.Section(a.Out, fmt.Sprintf("Logs (%d)", len(result.Logs))) + printLogs(a.Out, result.Logs) + return nil + } + output.Section(a.Out, "Logs") + if len(result.Logs) > 0 { + printLogs(a.Out, result.Logs) + } else { + output.Field(a.Out, "Waiting", "new log lines") + } + + after := getLogCursor(result.Logs) + if after == "" { + after = a.Now().UTC().Format(time.RFC3339Nano) + } + seen := map[string]bool{} + for _, log := range result.Logs { + seen[getLogKey(log)] = true + } + for { + a.Sleep(logPollInterval) + next, err := fetchLogs(ctx, client, value, defaultLogTail, after) + if err != nil { + return err + } + var unseen []serviceLog + for _, log := range next.Logs { + if !seen[getLogKey(log)] { + unseen = append(unseen, log) + } + } + if len(unseen) == 0 { + continue + } + printLogs(a.Out, unseen) + for _, log := range unseen { + seen[getLogKey(log)] = true + } + if cursor := getLogCursor(unseen); cursor != "" { + after = cursor + } + } +} + +func fetchLogs(ctx context.Context, client *api.Client, value manifest.Manifest, tail int, after string) (logsResponse, error) { + query := manifestIdentityQuery(value) + query.Set("tail", strconv.Itoa(tail)) + if after != "" { + query.Set("after", after) + } + var result logsResponse + err := client.RequestJSON(ctx, http.MethodGet, "/api/v1/manifest/logs", query, nil, &result) + return result, err +} + +func manifestIdentityQuery(value manifest.Manifest) url.Values { + return url.Values{ + "project": {value.Project}, + "environment": {value.Environment}, + "service": {value.Service.Name}, + } +} + +func printApplyResult(w io.Writer, result applyResponse) { + output.Section(w, "Apply") + output.Field(w, "Action", result.Action) + output.Field(w, "Service", output.ShortID(result.ServiceID)) + if len(result.Changes) == 0 { + output.Field(w, "Changes", "none") + return + } + output.Section(w, fmt.Sprintf("Changes (%d)", len(result.Changes))) + for _, change := range result.Changes { + fmt.Fprintf(w, " * %s\n", change.Field) + output.Field(w, "From", change.From) + output.Field(w, "To", change.To) + } +} + +func printStatus(w io.Writer, value manifest.Manifest, status statusResponse) { + fmt.Fprintf(w, "%s/%s/%s\n", value.Project, value.Environment, value.Service.Name) + output.Section(w, "Service") + output.Field(w, "ID", output.ShortID(status.Service.ID)) + output.Field(w, "Image", status.Service.Image) + if status.Service.Hostname == nil || *status.Service.Hostname == "" { + output.Field(w, "Hostname", "none") + } else { + output.Field(w, "Hostname", *status.Service.Hostname) + } + output.Field(w, "Replicas", status.Service.Replicas) + + output.Section(w, "Rollout") + if status.LatestRollout != nil { + output.Field(w, "ID", output.ShortID(status.LatestRollout.ID)) + output.Field(w, "Status", output.Status(status.LatestRollout.Status)) + if status.LatestRollout.CurrentStage != nil && *status.LatestRollout.CurrentStage != "" { + output.Field(w, "Stage", output.Status(*status.LatestRollout.CurrentStage)) + } else { + output.Field(w, "Stage", "none") + } + } else { + output.Field(w, "Latest", "none") + } + + output.Section(w, fmt.Sprintf("Deployments (%d)", len(status.Deployments))) + if len(status.Deployments) == 0 { + output.Field(w, "Current", "none") + return + } + for _, deployment := range status.Deployments { + fmt.Fprintf(w, " * %s\n", output.ShortID(deployment.ID)) + output.Field(w, "Status", output.Status(deployment.Status)) + output.Field(w, "Server", output.ShortID(deployment.ServerID)) + } +} + +func printLogs(w io.Writer, logs []serviceLog) { + for _, log := range logs { + stream := log.Stream + if stream == "" { + stream = "stdout" + } + message := strings.TrimRight(log.Message, "\n") + fmt.Fprintf(w, "%s %-9s %s\n", output.Timestamp(log.Timestamp), "["+stream+"]", message) + } +} + +func getLogCursor(logs []serviceLog) string { + var latest string + var latestTime time.Time + for _, log := range logs { + parsed, err := time.Parse(time.RFC3339Nano, log.Timestamp) + if err != nil { + continue + } + if latest == "" || parsed.After(latestTime) { + latest = log.Timestamp + latestTime = parsed + } + } + if latest != "" { + return latest + } + if len(logs) > 0 { + return logs[len(logs)-1].Timestamp + } + return "" +} + +func getLogKey(log serviceLog) string { + return fmt.Sprintf("%s:%s:%s:%s", log.Timestamp, log.Stream, log.DeploymentID, log.Message) +} + +func selectFromList[T any]( + reader *bufio.Reader, + out io.Writer, + title string, + items []T, + render func(T) string, + disabledReason func(T) string, +) (T, error) { + var zero T + if len(items) == 0 { + return zero, fmt.Errorf("no options available for %q", title) + } + for { + fmt.Fprintf(out, "\n%s\n", title) + for index, item := range items { + fmt.Fprintf(out, " %d. %s\n", index+1, render(item)) + } + fmt.Fprint(out, "> ") + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return zero, err + } + line = strings.TrimSpace(line) + choice, parseErr := strconv.Atoi(line) + if parseErr != nil || choice < 1 || choice > len(items) { + fmt.Fprintln(out, "Enter the number of the option you want.") + if errors.Is(err, io.EOF) { + return zero, io.ErrUnexpectedEOF + } + continue + } + selected := items[choice-1] + if disabledReason != nil { + if reason := disabledReason(selected); reason != "" { + fmt.Fprintln(out, reason) + if errors.Is(err, io.EOF) { + return zero, io.ErrUnexpectedEOF + } + continue + } + } + return selected, nil + } +} diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go new file mode 100644 index 00000000..23a820be --- /dev/null +++ b/cli/internal/cli/app_test.go @@ -0,0 +1,204 @@ +package cli + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "techulus/cloud-cli/internal/auth" +) + +func TestInitCreatesManifest(t *testing.T) { + tmp := t.TempDir() + stdout, stderr, err := runTestCommand(t, nil, tmp, "init") + if err != nil { + t.Fatalf("init error = %v\nstderr=%s", err, stderr) + } + if !strings.Contains(stdout, "tc apply") { + t.Fatalf("stdout = %s", stdout) + } + raw, err := os.ReadFile(filepath.Join(tmp, "techulus.yml")) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + if !strings.Contains(string(raw), "image: nginx:1.27") { + t.Fatalf("manifest = %s", raw) + } +} + +func TestLogsRejectsInvalidTailBeforeConfig(t *testing.T) { + _, _, err := runTestCommand(t, nil, t.TempDir(), "logs", "--tail", "0") + if err == nil || !strings.Contains(err.Error(), "between 1 and 1000") { + t.Fatalf("error = %v", err) + } +} + +func TestApplyPostsManifest(t *testing.T) { + tmp := t.TempDir() + writeTestManifest(t, tmp) + var sawApply bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/apply" { + t.Fatalf("path = %s", r.URL.Path) + } + if r.Header.Get("x-api-key") != "secret" { + t.Fatalf("api key = %q", r.Header.Get("x-api-key")) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["project"] != "app" { + t.Fatalf("body = %#v", body) + } + sawApply = true + w.Write([]byte(`{"action":"updated","serviceId":"1234567890abcdef","changes":[]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "apply") + if err != nil { + t.Fatalf("apply error = %v\nstderr=%s", err, stderr) + } + if !sawApply || !strings.Contains(stdout, "Action updated") { + t.Fatalf("stdout = %s sawApply=%v", stdout, sawApply) + } +} + +func TestAuthLoginDeviceFlow(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "config")) + polls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/auth/device/code": + w.Write([]byte(`{"device_code":"device","user_code":"ABCD","verification_uri":"https://verify","verification_uri_complete":"https://verify?code=ABCD","expires_in":600,"interval":1}`)) + case "/api/auth/device/token": + polls++ + if polls == 1 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"authorization_pending"}`)) + return + } + w.Write([]byte(`{"access_token":"access"}`)) + case "/api/v1/cli/auth/exchange": + if r.Header.Get("authorization") != "Bearer access" { + t.Fatalf("authorization = %q", r.Header.Get("authorization")) + } + w.Write([]byte(`{"apiKey":"secret","keyId":"key-123456789","name":"CLI","user":{"id":"user","email":"a@example.com","name":"Alice"}}`)) + default: + t.Fatalf("path = %s", r.URL.Path) + } + })) + defer server.Close() + + var stdout bytes.Buffer + var stderr bytes.Buffer + app := NewApp("test", strings.NewReader(""), &stdout, &stderr) + app.HTTPClient = server.Client() + app.Sleep = func(time.Duration) {} + app.GetCWD = func() (string, error) { return tmp, nil } + cmd := app.rootCommand() + cmd.SetArgs([]string{"auth", "login", "--host", server.URL}) + cmd.SetIn(app.In) + cmd.SetOut(app.Out) + cmd.SetErr(app.Err) + if err := cmd.Execute(); err != nil { + t.Fatalf("auth login error = %v\nstderr=%s", err, stderr.String()) + } + config, err := auth.ReadConfig() + if err != nil { + t.Fatalf("ReadConfig() error = %v", err) + } + if config == nil || config.APIKey != "secret" || config.Host != server.URL { + t.Fatalf("config = %#v", config) + } + if !strings.Contains(stdout.String(), "Signed in") { + t.Fatalf("stdout = %s", stdout.String()) + } +} + +func TestLinkInteractiveFlow(t *testing.T) { + tmp := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/manifest/link-targets": + w.Write([]byte(`{"projects":[{"id":"p","name":"Project","slug":"project","environments":[{"id":"e","name":"production","services":[{"id":"s1","name":"db","project":"Project","environment":"production","linkSupported":false,"unsupportedReason":"stateful"},{"id":"s2","name":"web","project":"Project","environment":"production","linkSupported":true,"unsupportedReason":null}]}]}]}`)) + case "/api/v1/manifest/link": + w.Write([]byte(`{"manifest":{"apiVersion":"v1","project":"Project","environment":"production","service":{"name":"web","source":{"type":"image","image":"nginx"},"replicas":{"count":1},"ports":[]}},"service":{"id":"s2","name":"web","project":"Project","environment":"production"}}`)) + default: + t.Fatalf("path = %s", r.URL.Path) + } + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommandWithInput(t, server.Client(), tmp, "1\n1\n1\n2\n", true, "link") + if err != nil { + t.Fatalf("link error = %v\nstderr=%s\nstdout=%s", err, stderr, stdout) + } + if !strings.Contains(stdout, "stateful") || !strings.Contains(stdout, "Linked") { + t.Fatalf("stdout = %s", stdout) + } + if _, err := os.Stat(filepath.Join(tmp, "techulus.yml")); err != nil { + t.Fatalf("manifest not written: %v", err) + } +} + +func runTestCommand(t *testing.T, client *http.Client, cwd string, args ...string) (string, string, error) { + t.Helper() + return runTestCommandWithInput(t, client, cwd, "", false, args...) +} + +func runTestCommandWithInput(t *testing.T, client *http.Client, cwd string, stdin string, interactive bool, args ...string) (string, string, error) { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + app := NewApp("test", strings.NewReader(stdin), &stdout, &stderr) + if client != nil { + app.HTTPClient = client + } + app.GetCWD = func() (string, error) { return cwd, nil } + app.IsInteractive = func() bool { return interactive } + cmd := app.rootCommand() + cmd.SetArgs(args) + cmd.SetIn(app.In) + cmd.SetOut(app.Out) + cmd.SetErr(app.Err) + err := cmd.Execute() + return stdout.String(), stderr.String(), err +} + +func writeTestManifest(t *testing.T, dir string) { + t.Helper() + raw := `apiVersion: v1 +project: app +environment: production +service: + name: web + source: + type: image + image: nginx:1.27 + replicas: + count: 1 +` + if err := os.WriteFile(filepath.Join(dir, "techulus.yml"), []byte(raw), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } +} + +func writeTestConfig(t *testing.T, host string) { + t.Helper() + configRoot := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_CONFIG_HOME", configRoot) + if err := auth.WriteConfig(auth.Config{Host: host, APIKey: "secret"}); err != nil { + t.Fatalf("WriteConfig() error = %v", err) + } +} diff --git a/cli/internal/cli/types.go b/cli/internal/cli/types.go new file mode 100644 index 00000000..f323ff54 --- /dev/null +++ b/cli/internal/cli/types.go @@ -0,0 +1,196 @@ +package cli + +import ( + "fmt" + + "techulus/cloud-cli/internal/auth" + "techulus/cloud-cli/internal/manifest" +) + +type deviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type deviceTokenResponse struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +type exchangeResponse struct { + APIKey string `json:"apiKey"` + KeyID string `json:"keyId"` + Name string `json:"name"` + User auth.User `json:"user"` +} + +type linkServiceTarget struct { + ID string `json:"id"` + Name string `json:"name"` + Project string `json:"project"` + Environment string `json:"environment"` + LinkSupported bool `json:"linkSupported"` + UnsupportedReason string `json:"unsupportedReason"` +} + +type linkEnvironmentTarget struct { + ID string `json:"id"` + Name string `json:"name"` + Services []linkServiceTarget `json:"services"` +} + +type linkProjectTarget struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Environments []linkEnvironmentTarget `json:"environments"` +} + +type linkTargetsResponse struct { + Projects []linkProjectTarget `json:"projects"` +} + +type linkManifestResponse struct { + Manifest manifest.Manifest `json:"manifest"` + Service struct { + ID string `json:"id"` + Name string `json:"name"` + Project string `json:"project"` + Environment string `json:"environment"` + } `json:"service"` +} + +type manifestChange struct { + Field string `json:"field"` + From string `json:"from"` + To string `json:"to"` +} + +type applyResponse struct { + Action string `json:"action"` + ServiceID string `json:"serviceId"` + Changes []manifestChange `json:"changes"` +} + +type deployResponse struct { + ServiceID string `json:"serviceId"` + RolloutID *string `json:"rolloutId"` + Status string `json:"status"` +} + +type statusResponse struct { + Service struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + Hostname *string `json:"hostname"` + Replicas int `json:"replicas"` + } `json:"service"` + LatestRollout *struct { + ID string `json:"id"` + Status string `json:"status"` + CurrentStage *string `json:"currentStage"` + } `json:"latestRollout"` + Deployments []struct { + ID string `json:"id"` + Status string `json:"status"` + ServerID string `json:"serverId"` + } `json:"deployments"` +} + +type serviceLog struct { + DeploymentID string `json:"deploymentId"` + Stream string `json:"stream"` + Message string `json:"message"` + Timestamp string `json:"timestamp"` +} + +type logsResponse struct { + LoggingEnabled bool `json:"loggingEnabled"` + Logs []serviceLog `json:"logs"` +} + +func countSupportedServices(projects []linkProjectTarget) int { + total := 0 + for _, project := range projects { + for _, environment := range project.Environments { + for _, service := range environment.Services { + if service.LinkSupported { + total++ + } + } + } + } + return total +} + +func filterProjectsWithServices(projects []linkProjectTarget) []linkProjectTarget { + var filtered []linkProjectTarget + for _, project := range projects { + for _, environment := range project.Environments { + if len(environment.Services) > 0 { + filtered = append(filtered, project) + break + } + } + } + return filtered +} + +func filterEnvironmentsWithServices(environments []linkEnvironmentTarget) []linkEnvironmentTarget { + var filtered []linkEnvironmentTarget + for _, environment := range environments { + if len(environment.Services) > 0 { + filtered = append(filtered, environment) + } + } + return filtered +} + +func renderProjectChoice(project linkProjectTarget) string { + serviceCount := 0 + for _, environment := range project.Environments { + serviceCount += len(environment.Services) + } + suffix := "s" + if serviceCount == 1 { + suffix = "" + } + return fmt.Sprintf("%s (%d service%s)", project.Name, serviceCount, suffix) +} + +func renderEnvironmentChoice(environment linkEnvironmentTarget) string { + supportedCount := 0 + for _, service := range environment.Services { + if service.LinkSupported { + supportedCount++ + } + } + return fmt.Sprintf("%s (%d/%d linkable)", environment.Name, supportedCount, len(environment.Services)) +} + +func renderServiceChoice(service linkServiceTarget) string { + if service.LinkSupported { + return service.Name + } + reason := service.UnsupportedReason + if reason == "" { + reason = "unsupported" + } + return fmt.Sprintf("%s (unsupported: %s)", service.Name, reason) +} + +func disabledServiceReason(service linkServiceTarget) string { + if service.LinkSupported { + return "" + } + if service.UnsupportedReason != "" { + return service.UnsupportedReason + } + return "This service cannot be linked." +} diff --git a/cli/internal/manifest/manifest.go b/cli/internal/manifest/manifest.go new file mode 100644 index 00000000..7bc0cccf --- /dev/null +++ b/cli/internal/manifest/manifest.go @@ -0,0 +1,224 @@ +package manifest + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +type Manifest struct { + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Project string `json:"project" yaml:"project"` + Environment string `json:"environment" yaml:"environment"` + Service Service `json:"service" yaml:"service"` +} + +type Service struct { + Name string `json:"name" yaml:"name"` + Source Source `json:"source" yaml:"source"` + Hostname *string `json:"hostname,omitempty" yaml:"hostname,omitempty"` + Ports []Port `json:"ports,omitempty" yaml:"ports,omitempty"` + Replicas Replicas `json:"replicas" yaml:"replicas"` + HealthCheck *HealthCheck `json:"healthCheck,omitempty" yaml:"healthCheck,omitempty"` + StartCommand *string `json:"startCommand,omitempty" yaml:"startCommand,omitempty"` + Resources *Resources `json:"resources,omitempty" yaml:"resources,omitempty"` +} + +type Source struct { + Type string `json:"type" yaml:"type"` + Image string `json:"image" yaml:"image"` +} + +type Port struct { + Port int `json:"port" yaml:"port"` + Public bool `json:"public" yaml:"public"` + Domain string `json:"domain,omitempty" yaml:"domain,omitempty"` +} + +type Replicas struct { + Count int `json:"count" yaml:"count"` +} + +type HealthCheck struct { + Cmd string `json:"cmd" yaml:"cmd"` + Interval int `json:"interval" yaml:"interval"` + Timeout int `json:"timeout" yaml:"timeout"` + Retries int `json:"retries" yaml:"retries"` + StartPeriod int `json:"startPeriod" yaml:"startPeriod"` +} + +type Resources struct { + CPUCores *float64 `json:"cpuCores,omitempty" yaml:"cpuCores,omitempty"` + MemoryMB *int `json:"memoryMb,omitempty" yaml:"memoryMb,omitempty"` +} + +type Loaded struct { + Path string + Manifest Manifest +} + +func Load(cwd string) (*Loaded, error) { + path := filepath.Join(cwd, "techulus.yml") + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + parsed, err := Parse(raw) + if err != nil { + return nil, err + } + return &Loaded{Path: path, Manifest: parsed}, nil +} + +func Parse(raw []byte) (Manifest, error) { + var parsed Manifest + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(true) + if err := decoder.Decode(&parsed); err != nil { + return Manifest{}, err + } + ApplyDefaults(&parsed) + return parsed, Validate(parsed) +} + +func Marshal(value Manifest) ([]byte, error) { + ApplyDefaults(&value) + if err := Validate(value); err != nil { + return nil, err + } + return yaml.Marshal(value) +} + +func Save(path string, value Manifest) error { + raw, err := Marshal(value) + if err != nil { + return err + } + return os.WriteFile(path, raw, 0o644) +} + +func ApplyDefaults(value *Manifest) { + value.Project = strings.TrimSpace(value.Project) + value.Environment = strings.TrimSpace(value.Environment) + value.Service.Name = strings.TrimSpace(value.Service.Name) + value.Service.Source.Type = strings.TrimSpace(value.Service.Source.Type) + value.Service.Source.Image = strings.TrimSpace(value.Service.Source.Image) + if value.Service.Hostname != nil { + trimmed := strings.TrimSpace(*value.Service.Hostname) + value.Service.Hostname = &trimmed + } + if value.Service.StartCommand != nil { + trimmed := strings.TrimSpace(*value.Service.StartCommand) + value.Service.StartCommand = &trimmed + } + if value.Service.Ports == nil { + value.Service.Ports = []Port{} + } + for index := range value.Service.Ports { + value.Service.Ports[index].Domain = strings.TrimSpace(value.Service.Ports[index].Domain) + } + if value.Service.Replicas.Count == 0 { + value.Service.Replicas.Count = 1 + } + if value.Service.HealthCheck != nil { + value.Service.HealthCheck.Cmd = strings.TrimSpace(value.Service.HealthCheck.Cmd) + if value.Service.HealthCheck.Interval == 0 { + value.Service.HealthCheck.Interval = 10 + } + if value.Service.HealthCheck.Timeout == 0 { + value.Service.HealthCheck.Timeout = 5 + } + if value.Service.HealthCheck.Retries == 0 { + value.Service.HealthCheck.Retries = 3 + } + if value.Service.HealthCheck.StartPeriod == 0 { + value.Service.HealthCheck.StartPeriod = 30 + } + } +} + +func Validate(value Manifest) error { + if value.APIVersion != "v1" { + return errors.New("apiVersion must be v1") + } + if strings.TrimSpace(value.Project) == "" { + return errors.New("project is required") + } + if strings.TrimSpace(value.Environment) == "" { + return errors.New("environment is required") + } + if strings.TrimSpace(value.Service.Name) == "" { + return errors.New("service.name is required") + } + if value.Service.Source.Type != "image" { + return errors.New("service.source.type must be image") + } + if strings.TrimSpace(value.Service.Source.Image) == "" { + return errors.New("service.source.image is required") + } + if value.Service.Hostname != nil && *value.Service.Hostname == "" { + return errors.New("service.hostname cannot be blank") + } + if value.Service.Replicas.Count < 1 || value.Service.Replicas.Count > 10 { + return errors.New("service.replicas.count must be between 1 and 10") + } + for index, port := range value.Service.Ports { + if port.Port < 1 || port.Port > 65535 { + return fmt.Errorf("service.ports[%d].port must be between 1 and 65535", index) + } + if port.Public && strings.TrimSpace(port.Domain) == "" { + return fmt.Errorf("service.ports[%d].domain is required for public ports", index) + } + if !port.Public && strings.TrimSpace(port.Domain) != "" { + return fmt.Errorf("service.ports[%d].domain cannot be set for internal ports", index) + } + } + if health := value.Service.HealthCheck; health != nil { + if strings.TrimSpace(health.Cmd) == "" { + return errors.New("service.healthCheck.cmd is required") + } + if health.Interval < 1 { + return errors.New("service.healthCheck.interval must be at least 1") + } + if health.Timeout < 1 { + return errors.New("service.healthCheck.timeout must be at least 1") + } + if health.Retries < 1 { + return errors.New("service.healthCheck.retries must be at least 1") + } + if health.StartPeriod < 0 { + return errors.New("service.healthCheck.startPeriod must be at least 0") + } + } + if value.Service.StartCommand != nil && *value.Service.StartCommand == "" { + return errors.New("service.startCommand cannot be blank") + } + if resources := value.Service.Resources; resources != nil { + hasCPU := resources.CPUCores != nil + hasMemory := resources.MemoryMB != nil + if hasCPU != hasMemory { + return errors.New("service.resources must set both cpuCores and memoryMb together") + } + if resources.CPUCores != nil && (*resources.CPUCores < 0.1 || *resources.CPUCores > 64) { + return errors.New("service.resources.cpuCores must be between 0.1 and 64") + } + if resources.MemoryMB != nil && (*resources.MemoryMB < 64 || *resources.MemoryMB > 65536) { + return errors.New("service.resources.memoryMb must be between 64 and 65536") + } + } + return nil +} + +var slugChars = regexp.MustCompile(`[^a-z0-9]+`) + +func Slugify(value string) string { + slug := strings.ToLower(value) + slug = slugChars.ReplaceAllString(slug, "-") + return strings.Trim(slug, "-") +} diff --git a/cli/internal/manifest/manifest_test.go b/cli/internal/manifest/manifest_test.go new file mode 100644 index 00000000..6f483662 --- /dev/null +++ b/cli/internal/manifest/manifest_test.go @@ -0,0 +1,111 @@ +package manifest + +import ( + "strings" + "testing" +) + +func TestParseValidManifestAppliesDefaults(t *testing.T) { + raw := []byte(`apiVersion: v1 +project: app +environment: production +service: + name: web + source: + type: image + image: nginx:1.27 +`) + parsed, err := Parse(raw) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if parsed.Service.Replicas.Count != 1 { + t.Fatalf("replicas default = %d, want 1", parsed.Service.Replicas.Count) + } + if parsed.Service.Ports == nil { + t.Fatal("ports default should be an empty slice, not nil") + } +} + +func TestValidatePortDomainRules(t *testing.T) { + base := validManifest() + base.Service.Ports = []Port{{Port: 80, Public: true}} + if err := Validate(base); err == nil || !strings.Contains(err.Error(), "domain is required") { + t.Fatalf("Validate(public without domain) = %v", err) + } + + base = validManifest() + base.Service.Ports = []Port{{Port: 80, Public: false, Domain: "example.com"}} + if err := Validate(base); err == nil || !strings.Contains(err.Error(), "domain cannot be set") { + t.Fatalf("Validate(internal with domain) = %v", err) + } +} + +func TestValidateResourcesMustBePaired(t *testing.T) { + cpu := 1.0 + base := validManifest() + base.Service.Resources = &Resources{CPUCores: &cpu} + if err := Validate(base); err == nil || !strings.Contains(err.Error(), "both cpuCores and memoryMb") { + t.Fatalf("Validate(resources) = %v", err) + } +} + +func TestValidateBlankOptionalStrings(t *testing.T) { + raw := []byte(`apiVersion: v1 +project: app +environment: production +service: + name: web + source: + type: image + image: nginx:1.27 + hostname: " " +`) + if _, err := Parse(raw); err == nil || !strings.Contains(err.Error(), "hostname cannot be blank") { + t.Fatalf("Parse(blank hostname) = %v", err) + } + + raw = []byte(`apiVersion: v1 +project: app +environment: production +service: + name: web + source: + type: image + image: nginx:1.27 + startCommand: " " +`) + if _, err := Parse(raw); err == nil || !strings.Contains(err.Error(), "startCommand cannot be blank") { + t.Fatalf("Parse(blank startCommand) = %v", err) + } +} + +func TestMarshalRoundTrip(t *testing.T) { + value := validManifest() + value.Service.Ports = []Port{{Port: 443, Public: true, Domain: "app.example.com"}} + raw, err := Marshal(value) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + parsed, err := Parse(raw) + if err != nil { + t.Fatalf("Parse(Marshal()) error = %v", err) + } + if parsed.Service.Ports[0].Domain != "app.example.com" { + t.Fatalf("domain = %q", parsed.Service.Ports[0].Domain) + } +} + +func validManifest() Manifest { + return Manifest{ + APIVersion: "v1", + Project: "app", + Environment: "production", + Service: Service{ + Name: "web", + Source: Source{Type: "image", Image: "nginx:1.27"}, + Replicas: Replicas{Count: 1}, + Ports: []Port{}, + }, + } +} diff --git a/cli/internal/output/output.go b/cli/internal/output/output.go new file mode 100644 index 00000000..c0589b24 --- /dev/null +++ b/cli/internal/output/output.go @@ -0,0 +1,40 @@ +package output + +import ( + "fmt" + "io" + "strings" + "time" +) + +func Section(w io.Writer, title string) { + fmt.Fprintf(w, "\n%s\n%s\n", title, strings.Repeat("-", len(title))) +} + +func Field(w io.Writer, label string, value any) { + fmt.Fprintf(w, " %-10s %v\n", label, value) +} + +func Next(w io.Writer, command string) { + Section(w, "Next") + Field(w, "Run", command) +} + +func ShortID(id string) string { + if len(id) <= 16 { + return id + } + return id[:8] + "..." + id[len(id)-4:] +} + +func Status(value string) string { + return strings.ReplaceAll(value, "_", " ") +} + +func Timestamp(value string) string { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return value + } + return parsed.UTC().Format(time.RFC3339Nano) +} diff --git a/cli/package.json b/cli/package.json deleted file mode 100644 index 6cefbb97..00000000 --- a/cli/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "techulus-cli", - "version": "0.1.0", - "private": true, - "type": "module", -"scripts": { - "dev": "node --import tsx src/main.ts", - "build": "bun build src/main.ts --compile --outfile dist/tc", - "build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/tc-linux-x64", - "build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/tc-linux-arm64", - "build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/tc-darwin-x64", - "build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/tc-darwin-arm64", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "yaml": "^2.8.2", - "zod": "^4.3.5" - }, - "devDependencies": { - "@types/node": "^22.17.0", - "tsx": "^4.19.2", - "typescript": "^5.9.2" - } -} diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml deleted file mode 100644 index db827add..00000000 --- a/cli/pnpm-lock.yaml +++ /dev/null @@ -1,348 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - yaml: - specifier: ^2.8.2 - version: 2.9.0 - zod: - specifier: ^4.3.5 - version: 4.4.3 - devDependencies: - '@types/node': - specifier: ^22.17.0 - version: 22.20.0 - tsx: - specifier: ^4.19.2 - version: 4.22.4 - typescript: - specifier: ^5.9.2 - version: 5.9.3 - -packages: - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@types/node@22.20.0': - resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - tsx@4.22.4: - resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} - engines: {node: '>=18.0.0'} - hasBin: true - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} - engines: {node: '>= 14.6'} - hasBin: true - - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - -snapshots: - - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@types/node@22.20.0': - dependencies: - undici-types: 6.21.0 - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - - fsevents@2.3.3: - optional: true - - tsx@4.22.4: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - yaml@2.9.0: {} - - zod@4.4.3: {} diff --git a/cli/src/config.ts b/cli/src/config.ts deleted file mode 100644 index c4b5c15c..00000000 --- a/cli/src/config.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -export type CliConfig = { - host: string; - apiKey: string; - keyId?: string; - keyName?: string | null; - user?: { - id: string; - email: string; - name: string; - }; -}; - -function getConfigRoot() { - if (process.env.XDG_CONFIG_HOME) { - return process.env.XDG_CONFIG_HOME; - } - - if (process.platform === "darwin") { - return path.join(os.homedir(), "Library", "Application Support"); - } - - if (process.platform === "win32" && process.env.APPDATA) { - return process.env.APPDATA; - } - - return path.join(os.homedir(), ".config"); -} - -export function getConfigDir() { - return path.join(getConfigRoot(), "techulus-cloud-cli"); -} - -export function getConfigPath() { - return path.join(getConfigDir(), "config.json"); -} - -export async function readConfig(): Promise { - try { - const contents = await readFile(getConfigPath(), "utf8"); - return JSON.parse(contents) as CliConfig; - } catch { - return null; - } -} - -export async function writeConfig(config: CliConfig) { - const dir = getConfigDir(); - const file = getConfigPath(); - - await mkdir(dir, { recursive: true, mode: 0o700 }); - await writeFile(file, JSON.stringify(config, null, 2), { - encoding: "utf8", - mode: 0o600, - }); - await chmod(file, 0o600); -} - -export async function deleteConfig() { - await rm(getConfigPath(), { force: true }); -} diff --git a/cli/src/main.ts b/cli/src/main.ts deleted file mode 100644 index 61bfca35..00000000 --- a/cli/src/main.ts +++ /dev/null @@ -1,844 +0,0 @@ -import { access, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { constants as fsConstants } from "node:fs"; -import { stdin as input, stdout as output } from "node:process"; -import { createInterface } from "node:readline/promises"; -import { deleteConfig, readConfig, writeConfig } from "./config.js"; -import { - loadManifest, - slugify, - stringifyManifest, - type TechulusManifest, -} from "./manifest.js"; - -const CLI_VERSION = "0.1.0"; -const CLI_CLIENT_ID = "techulus-cli"; -const DEFAULT_LOG_TAIL = 100; -const LOG_POLL_INTERVAL_MS = 2000; - -type JsonRequestOptions = { - method?: string; - headers?: Record; - body?: unknown; -}; - -type ErrorResponse = { - error?: string; - message?: string; - code?: string; -}; - -type LinkServiceTarget = { - id: string; - name: string; - project: string; - environment: string; - linkSupported: boolean; - unsupportedReason: string | null; -}; - -type LinkEnvironmentTarget = { - id: string; - name: string; - services: LinkServiceTarget[]; -}; - -type LinkProjectTarget = { - id: string; - name: string; - slug: string; - environments: LinkEnvironmentTarget[]; -}; - -type ServiceLog = { - deploymentId: string | undefined; - stream: string; - message: string; - timestamp: string; -}; - -function normalizeHost(host: string) { - const trimmed = host.trim().replace(/\/$/, ""); - if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { - return `https://${trimmed}`; - } - - return trimmed; -} - -async function requestJson(url: string, options: JsonRequestOptions = {}) { - const response = await fetch(url, { - method: options.method ?? "GET", - headers: { - "content-type": "application/json", - ...(options.headers ?? {}), - }, - body: options.body === undefined ? undefined : JSON.stringify(options.body), - }); - - const text = await response.text(); - const data = text ? (JSON.parse(text) as T | ErrorResponse) : null; - - if (!response.ok) { - const apiMessage = - data && typeof data === "object" - ? "message" in data && data.message - ? data.message - : "error" in data && data.error - ? data.error - : null - : null; - const code = - data && typeof data === "object" && "code" in data && data.code - ? ` (${data.code})` - : ""; - const message = apiMessage - ? `${apiMessage}${code}` - : `Request failed with ${response.status}`; - - if (response.status === 401 || response.status === 403) { - const host = normalizeHost(new URL(url).origin); - throw new Error( - `${message}\n\nYour CLI session is not authorized. Run:\n tc auth login --host ${host}`, - ); - } - - throw new Error(message); - } - - return data as T; -} - -async function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function shortId(id: string) { - if (id.length <= 16) return id; - return `${id.slice(0, 8)}…${id.slice(-4)}`; -} - -function formatStatus(value: string) { - return value.replace(/_/g, " "); -} - -function formatTimestamp(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toISOString(); -} - -function printSection(title: string) { - console.log(`\n${title}`); - console.log("─".repeat(title.length)); -} - -function printField(label: string, value: string | number) { - console.log(` ${label.padEnd(10)} ${value}`); -} - -function printNext(command: string) { - printSection("Next"); - printField("Run", command); -} - -function parseOption(args: string[], name: string) { - const index = args.indexOf(name); - if (index === -1) { - return null; - } - - const value = args[index + 1]; - if (!value || value.startsWith("--")) { - throw new Error(`Missing value for ${name}`); - } - - return value; -} - -function parseLogLineLimit(args: string[]) { - const rawTail = parseOption(args, "-n") ?? parseOption(args, "--tail"); - if (!rawTail) return null; - - if (!/^\d+$/.test(rawTail)) { - throw new Error("log line count must be a positive integer"); - } - - const tail = Number.parseInt(rawTail, 10); - if (tail < 1 || tail > 1000) { - throw new Error("log line count must be between 1 and 1000"); - } - - return tail; -} - -function printUsage() { - console.log(`Usage: - tc auth login --host - tc auth logout - tc auth whoami - tc init - tc link [--force] - tc apply - tc deploy - tc logs - tc logs -n - tc status`); -} - -async function pathExists(filePath: string) { - try { - await access(filePath, fsConstants.F_OK); - return true; - } catch { - return false; - } -} - -function countSupportedServices(projects: LinkProjectTarget[]) { - return projects.reduce( - (total, project) => - total + - project.environments.reduce( - (environmentTotal, environment) => - environmentTotal + - environment.services.filter((service) => service.linkSupported).length, - 0, - ), - 0, - ); -} - -async function selectFromList( - title: string, - items: T[], - renderItem: (item: T, index: number) => string, - getDisabledReason?: (item: T) => string | null, -) { - if (items.length === 0) { - throw new Error(`No options available for "${title}"`); - } - - if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error("tc link requires an interactive terminal."); - } - - const rl = createInterface({ input, output }); - - try { - while (true) { - console.log(`\n${title}`); - for (const [index, item] of items.entries()) { - console.log(` ${index + 1}. ${renderItem(item, index)}`); - } - - const answer = (await rl.question("> ")).trim(); - const choice = Number.parseInt(answer, 10); - - if (!Number.isInteger(choice) || choice < 1 || choice > items.length) { - console.log("Enter the number of the option you want."); - continue; - } - - const selected = items[choice - 1]; - const disabledReason = getDisabledReason?.(selected) ?? null; - - if (disabledReason) { - console.log(disabledReason); - continue; - } - - return selected; - } - } finally { - rl.close(); - } -} - -async function ensureManifest(cwd: string) { - try { - return await loadManifest(cwd); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - throw new Error( - "No techulus.yml found in the current directory. Run `tc init` to create one.", - ); - } - throw new Error( - error instanceof Error - ? `Invalid techulus.yml: ${error.message}` - : "Failed to load techulus.yml", - ); - } -} - -function authHeaders(apiKey: string) { - return { - "x-api-key": apiKey, - }; -} - -async function requireConfig() { - const config = await readConfig(); - if (!config) { - throw new Error("Not logged in. Run `tc auth login --host ` first."); - } - - return config; -} - -async function commandAuthLogin(args: string[]) { - const existingConfig = await readConfig(); - const rawHost = parseOption(args, "--host") ?? existingConfig?.host; - - if (!rawHost) { - throw new Error("Missing --host"); - } - - const host = normalizeHost(rawHost); - - const deviceCode = await requestJson<{ - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete: string; - expires_in: number; - interval: number; - }>(`${host}/api/auth/device/code`, { - method: "POST", - body: { - client_id: CLI_CLIENT_ID, - scope: "cli", - }, - }); - - const verificationUrl = - deviceCode.verification_uri_complete || deviceCode.verification_uri; - - printSection("Device login"); - printField("Host", host); - printField("URL", verificationUrl); - printField("Code", deviceCode.user_code); - console.log("\nOpen the verification URL in your browser to continue."); - - let accessToken = ""; - let intervalMs = deviceCode.interval * 1000; - - while (!accessToken) { - await sleep(intervalMs); - - const response = await fetch(`${host}/api/auth/device/token`, { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: deviceCode.device_code, - client_id: CLI_CLIENT_ID, - }), - }); - - const data = (await response.json()) as - | { - access_token: string; - } - | { - error: string; - error_description?: string; - }; - - if (response.ok && "access_token" in data) { - accessToken = data.access_token; - break; - } - - if (!("error" in data)) { - throw new Error("Unexpected response from device token endpoint"); - } - - switch (data.error) { - case "authorization_pending": - process.stdout.write("."); - break; - case "slow_down": - intervalMs += 5000; - break; - case "access_denied": - throw new Error(data.error_description || "Device authorization was denied"); - case "expired_token": - throw new Error(data.error_description || "Device authorization expired"); - default: - throw new Error(data.error_description || data.error); - } - } - - console.log("\n\nDevice approved. Creating a CLI API key..."); - - const machineName = os.hostname(); - const platform = `${process.platform}/${process.arch}`; - const exchange = await requestJson<{ - apiKey: string; - keyId: string; - name: string | null; - user: { id: string; email: string; name: string }; - }>(`${host}/api/v1/cli/auth/exchange`, { - method: "POST", - headers: { - authorization: `Bearer ${accessToken}`, - }, - body: { - machineName, - platform, - cliVersion: CLI_VERSION, - }, - }); - - await writeConfig({ - host, - apiKey: exchange.apiKey, - keyId: exchange.keyId, - keyName: exchange.name, - user: exchange.user, - }); - - printSection("Signed in"); - printField("User", exchange.user.email); - printField("Name", exchange.user.name); - printField("Host", host); - printField("Key", exchange.keyId ? shortId(exchange.keyId) : "created"); -} - -async function commandAuthLogout() { - await deleteConfig(); - printSection("Signed out"); - printField("Config", "removed"); -} - -async function commandAuthWhoAmI() { - const config = await requireConfig(); - const whoami = await requestJson<{ - user: { id: string; email: string; name: string }; - }>(`${config.host}/api/v1/cli/auth/whoami`, { - headers: authHeaders(config.apiKey), - }); - - printSection("Account"); - printField("User", whoami.user.email); - printField("Name", whoami.user.name); - printField("Host", config.host); -} - -async function commandInit(cwd: string) { - const manifestPath = path.join(cwd, "techulus.yml"); - try { - await access(manifestPath, fsConstants.F_OK); - throw new Error("techulus.yml already exists"); - } catch (error) { - if (error instanceof Error && error.message === "techulus.yml already exists") { - throw error; - } - } - - const folderName = slugify(path.basename(cwd)) || "my-service"; - const manifest = `apiVersion: v1 -project: ${folderName} -environment: production -service: - name: ${folderName} - source: - type: image - image: nginx:1.27 - replicas: - count: 1 - resources: - cpuCores: 2 - memoryMb: 1024 - ports: - - port: 80 - public: false -`; - - await writeFile(manifestPath, manifest, "utf8"); - printSection("Manifest"); - printField("Created", manifestPath); - printNext("tc apply"); -} - -async function commandLink(cwd: string, args: string[]) { - const config = await requireConfig(); - const manifestPath = path.join(cwd, "techulus.yml"); - const force = args.includes("--force"); - - if ((await pathExists(manifestPath)) && !force) { - throw new Error( - "techulus.yml already exists. Run `tc link --force` to replace it.", - ); - } - - const targets = await requestJson<{ projects: LinkProjectTarget[] }>( - `${config.host}/api/v1/manifest/link-targets`, - { - headers: authHeaders(config.apiKey), - }, - ); - - if (countSupportedServices(targets.projects) === 0) { - throw new Error("No linkable services were found in your account."); - } - - const projectChoices = targets.projects.filter( - (project) => - project.environments.some((environment) => environment.services.length > 0), - ); - if (projectChoices.length === 0) { - throw new Error("No services were found in your account."); - } - - const project = await selectFromList( - "Select a project:", - projectChoices, - (project) => { - const serviceCount = project.environments.reduce( - (total, environment) => total + environment.services.length, - 0, - ); - return `${project.name} (${serviceCount} service${serviceCount === 1 ? "" : "s"})`; - }, - ); - - const environmentChoices = project.environments.filter( - (environment) => environment.services.length > 0, - ); - const environment = await selectFromList( - "Select an environment:", - environmentChoices, - (environment) => { - const supportedCount = environment.services.filter( - (service) => service.linkSupported, - ).length; - return `${environment.name} (${supportedCount}/${environment.services.length} linkable)`; - }, - ); - - const service = await selectFromList( - "Select a service:", - environment.services, - (service) => - service.linkSupported - ? service.name - : `${service.name} (unsupported: ${service.unsupportedReason})`, - (service) => - service.linkSupported - ? null - : service.unsupportedReason ?? "This service can't be linked.", - ); - - const result = await requestJson<{ - manifest: TechulusManifest; - service: { - id: string; - name: string; - project: string; - environment: string; - }; - }>(`${config.host}/api/v1/manifest/link`, { - method: "POST", - headers: authHeaders(config.apiKey), - body: { - serviceId: service.id, - }, - }); - - await writeFile(manifestPath, stringifyManifest(result.manifest), "utf8"); - - printSection("Linked"); - printField( - "Service", - `${result.service.project}/${result.service.environment}/${result.service.name}`, - ); - printField("Manifest", manifestPath); - printNext("tc status or tc apply"); -} - -function printApplyResult(result: { - action: "created" | "updated" | "noop"; - serviceId: string; - changes: Array<{ field: string; from: string; to: string }>; -}) { - printSection("Apply"); - printField("Action", result.action); - printField("Service", shortId(result.serviceId)); - - if (result.changes.length === 0) { - printField("Changes", "none"); - return; - } - - printSection(`Changes (${result.changes.length})`); - for (const change of result.changes) { - console.log(` • ${change.field}`); - printField("From", change.from); - printField("To", change.to); - } -} - -async function commandApply(cwd: string) { - const config = await requireConfig(); - const { manifest } = await ensureManifest(cwd); - const result = await requestJson<{ - action: "created" | "updated" | "noop"; - serviceId: string; - changes: Array<{ field: string; from: string; to: string }>; - }>(`${config.host}/api/v1/manifest/apply`, { - method: "POST", - headers: authHeaders(config.apiKey), - body: manifest, - }); - - printApplyResult(result); -} - -async function commandDeploy(cwd: string) { - const config = await requireConfig(); - const { manifest } = await ensureManifest(cwd); - const result = await requestJson<{ - serviceId: string; - rolloutId: string | null; - status: string; - }>(`${config.host}/api/v1/manifest/deploy`, { - method: "POST", - headers: authHeaders(config.apiKey), - body: manifest, - }); - - printSection("Deploy"); - printField("Service", shortId(result.serviceId)); - printField("Status", formatStatus(result.status)); - if (result.rolloutId) { - printField("Rollout", shortId(result.rolloutId)); - } - printNext("tc status"); -} - -async function commandStatus(cwd: string) { - const config = await requireConfig(); - const { manifest } = await ensureManifest(cwd); - const params = new URLSearchParams({ - project: manifest.project, - environment: manifest.environment, - service: manifest.service.name, - }); - const status = await requestJson<{ - service: { - id: string; - image: string; - hostname: string | null; - replicas: number; - }; - latestRollout: { - id: string; - status: string; - currentStage: string | null; - } | null; - deployments: Array<{ - id: string; - status: string; - serverId: string; - }>; - }>(`${config.host}/api/v1/manifest/status?${params.toString()}`, { - headers: authHeaders(config.apiKey), - }); - - console.log(`${manifest.project}/${manifest.environment}/${manifest.service.name}`); - - printSection("Service"); - printField("ID", shortId(status.service.id)); - printField("Image", status.service.image); - printField("Hostname", status.service.hostname ?? "none"); - printField("Replicas", status.service.replicas); - - printSection("Rollout"); - if (status.latestRollout) { - printField("ID", shortId(status.latestRollout.id)); - printField("Status", formatStatus(status.latestRollout.status)); - printField( - "Stage", - status.latestRollout.currentStage - ? formatStatus(status.latestRollout.currentStage) - : "none", - ); - } else { - printField("Latest", "none"); - } - - printSection(`Deployments (${status.deployments.length})`); - if (status.deployments.length === 0) { - printField("Current", "none"); - return; - } - - for (const deployment of status.deployments) { - console.log(` • ${shortId(deployment.id)}`); - printField("Status", formatStatus(deployment.status)); - printField("Server", shortId(deployment.serverId)); - } -} - -function printLogs(logs: ServiceLog[]) { - for (const log of logs) { - const stream = `[${log.stream || "stdout"}]`.padEnd(9); - const message = log.message.replace(/\n+$/, ""); - console.log(`${formatTimestamp(log.timestamp)} ${stream} ${message}`); - } -} - -function getLogCursor(logs: ServiceLog[]) { - return logs.reduce((latest, log) => { - if (!latest) return log.timestamp; - return new Date(log.timestamp).getTime() > new Date(latest).getTime() - ? log.timestamp - : latest; - }, null); -} - -function getLogKey(log: ServiceLog) { - return `${log.timestamp}:${log.stream}:${log.deploymentId ?? ""}:${log.message}`; -} - -async function fetchManifestLogs( - config: Awaited>, - manifest: TechulusManifest, - options: { tail: number; after?: string | null }, -) { - const params = new URLSearchParams({ - project: manifest.project, - environment: manifest.environment, - service: manifest.service.name, - tail: String(options.tail), - }); - if (options.after) { - params.set("after", options.after); - } - - return requestJson<{ - loggingEnabled: boolean; - logs: ServiceLog[]; - }>(`${config.host}/api/v1/manifest/logs?${params.toString()}`, { - headers: authHeaders(config.apiKey), - }); -} - -async function commandLogs(cwd: string, args: string[]) { - const lineLimit = parseLogLineLimit(args); - const config = await requireConfig(); - const { manifest } = await ensureManifest(cwd); - const result = await fetchManifestLogs(config, manifest, { - tail: lineLimit ?? DEFAULT_LOG_TAIL, - }); - - console.log(`${manifest.project}/${manifest.environment}/${manifest.service.name}`); - - if (!result.loggingEnabled) { - printSection("Logs"); - printField("Status", "disabled"); - return; - } - - if (lineLimit && result.logs.length === 0) { - printSection("Logs"); - printField("Lines", "none"); - return; - } - - if (lineLimit) { - printSection(`Logs (${result.logs.length})`); - printLogs(result.logs); - return; - } - - printSection("Logs"); - if (result.logs.length > 0) { - printLogs(result.logs); - } else { - printField("Waiting", "new log lines"); - } - - let after = getLogCursor(result.logs) ?? new Date().toISOString(); - const seen = new Set(result.logs.map(getLogKey)); - - while (true) { - await sleep(LOG_POLL_INTERVAL_MS); - const next = await fetchManifestLogs(config, manifest, { - tail: DEFAULT_LOG_TAIL, - after, - }); - const logs = next.logs.filter((log) => !seen.has(getLogKey(log))); - if (logs.length === 0) continue; - - printLogs(logs); - for (const log of logs) { - seen.add(getLogKey(log)); - } - after = getLogCursor(logs) ?? after; - } -} - -async function main() { - const argv = process.argv.slice(2); - if (argv[0] === "--") { - argv.shift(); - } - - const [command, subcommand, ...rest] = argv; - const cwd = process.env.INIT_CWD || process.cwd(); - - if (!command) { - printUsage(); - return; - } - - switch (command) { - case "auth": - switch (subcommand) { - case "login": - await commandAuthLogin(rest); - return; - case "logout": - await commandAuthLogout(); - return; - case "whoami": - await commandAuthWhoAmI(); - return; - default: - printUsage(); - return; - } - case "init": - await commandInit(cwd); - return; - case "link": - await commandLink(cwd, rest); - return; - case "apply": - await commandApply(cwd); - return; - case "deploy": - await commandDeploy(cwd); - return; - case "logs": - await commandLogs(cwd, [subcommand, ...rest].filter(Boolean)); - return; - case "status": - await commandStatus(cwd); - return; - default: - printUsage(); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : "Unknown error"); - process.exit(1); -}); diff --git a/cli/src/manifest.ts b/cli/src/manifest.ts deleted file mode 100644 index 89cf0a27..00000000 --- a/cli/src/manifest.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import YAML from "yaml"; -import { z } from "zod"; - -const manifestPortSchema = z - .object({ - port: z.number().int().min(1).max(65535), - public: z.boolean().default(false), - domain: z.string().trim().min(1).optional(), - }) - .strict(); - -const manifestHealthCheckSchema = z - .object({ - cmd: z.string().trim().min(1), - interval: z.number().int().min(1).default(10), - timeout: z.number().int().min(1).default(5), - retries: z.number().int().min(1).default(3), - startPeriod: z.number().int().min(0).default(30), - }) - .strict(); - -const manifestResourcesSchema = z - .object({ - cpuCores: z.number().min(0.1).max(64).nullable().optional(), - memoryMb: z.number().int().min(64).max(65536).nullable().optional(), - }) - .strict() - .superRefine((value, ctx) => { - const hasCpu = value.cpuCores !== undefined && value.cpuCores !== null; - const hasMemory = value.memoryMb !== undefined && value.memoryMb !== null; - - if (hasCpu !== hasMemory) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "resources must set both cpuCores and memoryMb together", - }); - } - }); - -export const techulusManifestSchema = z - .object({ - apiVersion: z.literal("v1"), - project: z.string().trim().min(1), - environment: z.string().trim().min(1), - service: z - .object({ - name: z.string().trim().min(1), - source: z - .object({ - type: z.literal("image"), - image: z.string().trim().min(1), - }) - .strict(), - hostname: z.string().trim().min(1).optional(), - ports: z.array(manifestPortSchema).default([]), - replicas: z - .object({ - count: z.number().int().min(1).max(10).default(1), - }) - .strict() - .default({ count: 1 }), - healthCheck: manifestHealthCheckSchema.optional(), - startCommand: z.string().trim().min(1).optional(), - resources: manifestResourcesSchema.optional(), - }) - .strict(), - }) - .strict(); - -export type TechulusManifest = z.infer; - -export async function loadManifest(cwd: string) { - const manifestPath = path.join(cwd, "techulus.yml"); - const raw = await readFile(manifestPath, "utf8"); - const parsed = YAML.parse(raw); - return { - path: manifestPath, - manifest: techulusManifestSchema.parse(parsed), - }; -} - -export function stringifyManifest(manifest: TechulusManifest) { - return YAML.stringify(techulusManifestSchema.parse(manifest)); -} - -export function slugify(value: string) { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); -} diff --git a/cli/tsconfig.json b/cli/tsconfig.json deleted file mode 100644 index 00051910..00000000 --- a/cli/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": ["src/**/*.ts"] -} From 749c5592c41f037dda8f92bc36263d0401441c94 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Mon, 29 Jun 2026 09:35:29 +1000 Subject: [PATCH 3/8] Trigger agent upgrades from control plane Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019f103a-e558-7226-a539-d795e27e4472 --- agent/cmd/agent/main.go | 1 + agent/internal/agent/upgrade.go | 207 ++++++++++++++++++ agent/internal/agent/workqueue.go | 22 +- web/actions/servers.ts | 10 + .../dashboard/servers/[id]/page.tsx | 6 +- web/components/server/agent-update-nudge.tsx | 82 ++++++- web/db/queries.ts | 8 + web/db/schema.ts | 25 ++- web/lib/agent-status.ts | 33 +++ web/lib/agent-upgrades.ts | 136 ++++++++++++ web/lib/inngest/functions/crons.ts | 15 ++ web/lib/inngest/functions/index.ts | 1 + web/lib/scheduler.ts | 39 ++++ web/lib/work-queue.ts | 89 +++++++- 14 files changed, 658 insertions(+), 16 deletions(-) create mode 100644 agent/internal/agent/upgrade.go create mode 100644 web/lib/agent-upgrades.ts diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go index da0c7961..f8e12401 100644 --- a/agent/cmd/agent/main.go +++ b/agent/cmd/agent/main.go @@ -102,6 +102,7 @@ func main() { if err := os.MkdirAll(dataDir, 0o700); err != nil { log.Fatalf("Failed to create data directory: %v", err) } + agent.CheckPendingUpgradeMarker(dataDir) keyDir := filepath.Join(dataDir, "keys") diff --git a/agent/internal/agent/upgrade.go b/agent/internal/agent/upgrade.go new file mode 100644 index 00000000..d240e102 --- /dev/null +++ b/agent/internal/agent/upgrade.go @@ -0,0 +1,207 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + agenthttp "techulus/cloud-agent/internal/http" + "techulus/cloud-agent/internal/paths" +) + +const ( + agentBinaryPath = "/usr/local/bin/techulus-agent" + agentPreviousPath = "/usr/local/bin/techulus-agent.previous" + agentUpgradeMarkerFile = "upgrade-pending.json" + agentReleaseBaseURL = "https://github.com/techulus/cloud/releases/download" +) + +var ( + targetVersionPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$`) + sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + errAgentUpgradeRestartNeeded = errors.New("agent upgrade restart needed") +) + +type agentUpgradeMarker struct { + TargetVersion string `json:"targetVersion"` +} + +func CheckPendingUpgradeMarker(dataDir string) { + marker, err := readAgentUpgradeMarker(dataDir) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Printf("[upgrade] failed to read upgrade marker: %v", err) + } + return + } + + if marker.TargetVersion == Version { + if err := os.Remove(agentUpgradeMarkerPath(dataDir)); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("[upgrade] failed to remove upgrade marker: %v", err) + } + log.Printf("[upgrade] completed upgrade to %s", Version) + return + } + + if _, err := os.Stat(agentPreviousPath); err != nil { + log.Printf("[upgrade] pending upgrade to %s did not boot target version %s and no previous binary is available: %v", marker.TargetVersion, Version, err) + if removeErr := os.Remove(agentUpgradeMarkerPath(dataDir)); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + log.Printf("[upgrade] failed to remove unrecoverable upgrade marker: %v", removeErr) + } + return + } + + log.Printf("[upgrade] restoring previous binary after failed upgrade to %s (running %s)", marker.TargetVersion, Version) + if err := copyFile(agentPreviousPath, agentBinaryPath, 0o755); err != nil { + log.Printf("[upgrade] failed to restore previous binary: %v", err) + return + } + if err := os.Chmod(agentBinaryPath, 0o755); err != nil { + log.Printf("[upgrade] failed to chmod restored binary: %v", err) + return + } + if err := os.Remove(agentUpgradeMarkerPath(dataDir)); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("[upgrade] failed to remove upgrade marker after rollback: %v", err) + } + os.Exit(0) +} + +func (a *Agent) ProcessAgentUpgrade(item agenthttp.WorkQueueItem) error { + var payload struct { + TargetVersion string `json:"targetVersion"` + ExpectedSHA256 string `json:"expectedSha256"` + } + + if err := json.Unmarshal([]byte(item.Payload), &payload); err != nil { + return fmt.Errorf("failed to parse upgrade_agent payload: %w", err) + } + + targetVersion := strings.TrimSpace(payload.TargetVersion) + if targetVersion == Version { + log.Printf("[upgrade] already running target version %s", targetVersion) + return nil + } + if !targetVersionPattern.MatchString(targetVersion) { + return fmt.Errorf("invalid target version: %s", targetVersion) + } + if runtime.GOOS != "linux" { + return fmt.Errorf("agent upgrades are only supported on linux") + } + arch := runtime.GOARCH + if arch != "amd64" && arch != "arm64" { + return fmt.Errorf("unsupported architecture: %s", arch) + } + expectedSHA256 := strings.ToLower(strings.TrimSpace(payload.ExpectedSHA256)) + if !sha256Pattern.MatchString(expectedSHA256) { + return fmt.Errorf("expectedSha256 is required") + } + + log.Printf("[upgrade] installing agent %s for linux/%s", targetVersion, arch) + tmpPath := filepath.Join(filepath.Dir(agentBinaryPath), fmt.Sprintf(".techulus-agent-%s.tmp", targetVersion)) + defer os.Remove(tmpPath) + + assetURL := fmt.Sprintf("%s/%s/agent-linux-%s", agentReleaseBaseURL, targetVersion, arch) + if err := downloadFile(assetURL, tmpPath); err != nil { + return err + } + if err := verifySHA256(tmpPath, expectedSHA256); err != nil { + return err + } + if err := os.Chmod(tmpPath, 0o755); err != nil { + return fmt.Errorf("failed to chmod new agent binary: %w", err) + } + + if err := copyFile(agentBinaryPath, agentPreviousPath, 0o755); err != nil { + return fmt.Errorf("failed to back up current agent binary: %w", err) + } + if err := os.Chmod(agentPreviousPath, 0o755); err != nil { + return fmt.Errorf("failed to chmod backed up agent binary: %w", err) + } + if err := writeAgentUpgradeMarker(a.DataDir, targetVersion); err != nil { + return err + } + if err := os.Rename(tmpPath, agentBinaryPath); err != nil { + return fmt.Errorf("failed to install new agent binary: %w", err) + } + + log.Printf("[upgrade] installed %s; restart required", targetVersion) + return errAgentUpgradeRestartNeeded +} + +func agentUpgradeMarkerPath(dataDir string) string { + if dataDir == "" { + dataDir = paths.DataDir + } + return filepath.Join(dataDir, agentUpgradeMarkerFile) +} + +func readAgentUpgradeMarker(dataDir string) (*agentUpgradeMarker, error) { + data, err := os.ReadFile(agentUpgradeMarkerPath(dataDir)) + if err != nil { + return nil, err + } + var marker agentUpgradeMarker + if err := json.Unmarshal(data, &marker); err != nil { + return nil, err + } + return &marker, nil +} + +func writeAgentUpgradeMarker(dataDir, targetVersion string) error { + data, err := json.Marshal(agentUpgradeMarker{TargetVersion: targetVersion}) + if err != nil { + return err + } + return os.WriteFile(agentUpgradeMarkerPath(dataDir), data, 0o600) +} + +func downloadFile(url, destPath string) error { + client := &http.Client{Timeout: 2 * time.Minute} + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("failed to download agent binary: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("agent binary download failed with status %d", resp.StatusCode) + } + + file, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("failed to create temp agent binary: %w", err) + } + defer file.Close() + if _, err := io.Copy(file, resp.Body); err != nil { + return fmt.Errorf("failed to write temp agent binary: %w", err) + } + return nil +} + +func verifySHA256(path, expected string) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open downloaded agent binary: %w", err) + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return fmt.Errorf("failed to hash downloaded agent binary: %w", err) + } + actual := hex.EncodeToString(hash.Sum(nil)) + if actual != expected { + return fmt.Errorf("checksum verification failed") + } + return nil +} diff --git a/agent/internal/agent/workqueue.go b/agent/internal/agent/workqueue.go index 8eb02fa0..77aead2b 100644 --- a/agent/internal/agent/workqueue.go +++ b/agent/internal/agent/workqueue.go @@ -1,8 +1,10 @@ package agent import ( + "errors" "fmt" "log" + "os" "time" agenthttp "techulus/cloud-agent/internal/http" @@ -84,16 +86,21 @@ func (a *Agent) processLeasedWorkItem(item agenthttp.WorkQueueItem) { status := "completed" errorMsg := "" + restartAfterReport := false if err := a.ProcessWorkItem(item); err != nil { - status = "failed" - errorMsg = err.Error() - log.Printf("[work-queue] item %s failed: %v", Truncate(item.ID, 8), err) + if errors.Is(err, errAgentUpgradeRestartNeeded) { + restartAfterReport = true + } else { + status = "failed" + errorMsg = err.Error() + log.Printf("[work-queue] item %s failed: %v", Truncate(item.ID, 8), err) + } } else { log.Printf("[work-queue] item %s completed", Truncate(item.ID, 8)) } a.workMutex.Lock() - if a.activeWorkItem != nil && a.activeWorkItem.ID == item.ID && a.activeWorkItem.Attempt == item.Attempt { + if !restartAfterReport && a.activeWorkItem != nil && a.activeWorkItem.ID == item.ID && a.activeWorkItem.Attempt == item.Attempt { a.activeWorkItem = nil } a.pendingWorkResults = append(a.pendingWorkResults, agenthttp.CompletedWorkItem{ @@ -105,6 +112,11 @@ func (a *Agent) processLeasedWorkItem(item agenthttp.WorkQueueItem) { a.workMutex.Unlock() a.RequestStatusReport("work item " + status) + if restartAfterReport { + a.reportStatus("agent upgrade completed") + log.Printf("[upgrade] exiting so systemd restarts the upgraded agent") + os.Exit(0) + } } func (a *Agent) ProcessWorkItem(item agenthttp.WorkQueueItem) error { @@ -128,6 +140,8 @@ func (a *Agent) ProcessWorkItem(item agenthttp.WorkQueueItem) error { return a.ProcessRestoreVolume(item) case "create_manifest": return a.ProcessCreateManifest(item) + case "upgrade_agent": + return a.ProcessAgentUpgrade(item) default: return fmt.Errorf("unknown work item type: %s", item.Type) } diff --git a/web/actions/servers.ts b/web/actions/servers.ts index cda97409..36966743 100644 --- a/web/actions/servers.ts +++ b/web/actions/servers.ts @@ -2,9 +2,11 @@ import { randomBytes } from "node:crypto"; import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; import { ZodError } from "zod"; import { db } from "@/db"; import { servers } from "@/db/schema"; +import { enqueueAgentUpgrade } from "@/lib/agent-upgrades"; import { requireAuth } from "@/lib/auth"; import { nameSchema } from "@/lib/schemas"; import { getZodErrorMessage } from "@/lib/utils"; @@ -66,3 +68,11 @@ export async function updateServerName(id: string, name: string) { throw error; } } + +export async function upgradeAgent(serverId: string, targetVersion: string) { + await requireAuth(); + const result = await enqueueAgentUpgrade(serverId, targetVersion); + revalidatePath("/dashboard/servers"); + revalidatePath(`/dashboard/servers/${serverId}`); + return result; +} diff --git a/web/app/(dashboard)/dashboard/servers/[id]/page.tsx b/web/app/(dashboard)/dashboard/servers/[id]/page.tsx index cd95ecd3..ca9ad179 100644 --- a/web/app/(dashboard)/dashboard/servers/[id]/page.tsx +++ b/web/app/(dashboard)/dashboard/servers/[id]/page.tsx @@ -68,9 +68,13 @@ export default async function ServerDetailPage({ {hasUpdate && ( )} diff --git a/web/components/server/agent-update-nudge.tsx b/web/components/server/agent-update-nudge.tsx index 0651befe..3764beea 100644 --- a/web/components/server/agent-update-nudge.tsx +++ b/web/components/server/agent-update-nudge.tsx @@ -1,9 +1,14 @@ "use client"; -import { useState } from "react"; import { ArrowUpCircle } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { upgradeAgent } from "@/actions/servers"; +import { Button } from "@/components/ui/button"; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -12,17 +17,49 @@ import { } from "@/components/ui/dialog"; interface AgentUpdateNudgeProps { + serverId: string; currentVersion: string; latestVersion: string; - appUrl: string; + serverStatus: "pending" | "online" | "offline" | "unknown"; + upgradeStatus: "idle" | "queued" | "upgrading" | "succeeded" | "failed"; + upgradeTargetVersion: string | null; + upgradeError: string | null; } export function AgentUpdateNudge({ + serverId, currentVersion, latestVersion, - appUrl, + serverStatus, + upgradeStatus, + upgradeTargetVersion, + upgradeError, }: AgentUpdateNudgeProps) { const [open, setOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + const isTargetUpgradeActive = + upgradeTargetVersion === latestVersion && + (upgradeStatus === "queued" || upgradeStatus === "upgrading"); + const disabled = + serverStatus !== "online" || isTargetUpgradeActive || isPending; + + const handleUpgrade = () => { + startTransition(async () => { + try { + await upgradeAgent(serverId, latestVersion); + toast.success("Agent upgrade queued"); + setOpen(false); + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to queue agent upgrade", + ); + } + }); + }; return ( <> @@ -45,17 +82,46 @@ export function AgentUpdateNudge({ Update Agent - Run the following command on your server to update the agent from{" "} + Queue an upgrade for this server from{" "} {currentVersion} to{" "} {latestVersion}. - - sudo bash -c "$(curl -fsSL {appUrl}/update.sh)" - +
+ The control plane will send a signed work item to the agent. The + agent downloads the release binary, verifies its checksum, and + restarts itself after installation. +
+ + {isTargetUpgradeActive && ( +

+ Upgrade is already {upgradeStatus} for this version. +

+ )} + {upgradeStatus === "failed" && upgradeError && ( +

+ Last failure: {upgradeError} +

+ )} + {serverStatus !== "online" && ( +

+ Server must be online before an upgrade can be queued. +

+ )} - + + }> + Cancel + + + diff --git a/web/db/queries.ts b/web/db/queries.ts index 7a95274d..800895a4 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -112,6 +112,10 @@ export async function getServerDetails(id: string) { networkHealth: servers.networkHealth, containerHealth: servers.containerHealth, agentHealth: servers.agentHealth, + agentUpgradeTargetVersion: servers.agentUpgradeTargetVersion, + agentUpgradeStatus: servers.agentUpgradeStatus, + agentUpgradeStartedAt: servers.agentUpgradeStartedAt, + agentUpgradeError: servers.agentUpgradeError, }) .from(servers) .where(eq(servers.id, id)); @@ -129,6 +133,10 @@ export async function getClusterHealth() { networkHealth: servers.networkHealth, containerHealth: servers.containerHealth, agentHealth: servers.agentHealth, + agentUpgradeTargetVersion: servers.agentUpgradeTargetVersion, + agentUpgradeStatus: servers.agentUpgradeStatus, + agentUpgradeStartedAt: servers.agentUpgradeStartedAt, + agentUpgradeError: servers.agentUpgradeError, }) .from(servers); diff --git a/web/db/schema.ts b/web/db/schema.ts index da3751cd..a6f36982 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -1,4 +1,4 @@ -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { bigint, boolean, @@ -208,6 +208,13 @@ export type AgentHealth = { uptimeSecs: number; }; +export type AgentUpgradeStatus = + | "idle" + | "queued" + | "upgrading" + | "succeeded" + | "failed"; + export const servers = pgTable("servers", { id: text("id").primaryKey(), name: text("name").notNull(), @@ -229,6 +236,16 @@ export const servers = pgTable("servers", { networkHealth: jsonb("network_health").$type(), containerHealth: jsonb("container_health").$type(), agentHealth: jsonb("agent_health").$type(), + agentUpgradeTargetVersion: text("agent_upgrade_target_version"), + agentUpgradeStatus: text("agent_upgrade_status", { + enum: ["idle", "queued", "upgrading", "succeeded", "failed"], + }) + .notNull() + .default("idle"), + agentUpgradeStartedAt: timestamp("agent_upgrade_started_at", { + withTimezone: true, + }), + agentUpgradeError: text("agent_upgrade_error"), agentToken: text("agent_token"), tokenCreatedAt: timestamp("token_created_at", { withTimezone: true }), tokenUsedAt: timestamp("token_used_at", { withTimezone: true }), @@ -538,6 +555,7 @@ export const workQueue = pgTable( "backup_volume", "restore_volume", "create_manifest", + "upgrade_agent", ], }).notNull(), payload: text("payload").notNull(), @@ -554,6 +572,11 @@ export const workQueue = pgTable( }, (table) => [ index("work_queue_server_status_idx").on(table.serverId, table.status), + uniqueIndex("work_queue_one_active_agent_upgrade_idx") + .on(table.serverId) + .where( + sql`${table.type} = 'upgrade_agent' AND ${table.status} IN ('pending', 'processing')`, + ), ], ); diff --git a/web/lib/agent-status.ts b/web/lib/agent-status.ts index b1fadcf1..a6d49beb 100644 --- a/web/lib/agent-status.ts +++ b/web/lib/agent-status.ts @@ -9,6 +9,7 @@ import { rollouts, servers, services, + workQueue, } from "@/db/schema"; import { AUTOHEAL_MAX_RECREATES, @@ -160,6 +161,7 @@ export async function applyStatusReport( lastHeartbeat: new Date(), status: "online", }; + let completedAgentUpgradeTarget: string | null = null; if (report.resources) { if (report.resources.cpuCores !== undefined) { @@ -191,9 +193,40 @@ export async function applyStatusReport( } if (report.agentHealth) { updateData.agentHealth = report.agentHealth; + + const [server] = await db + .select({ + agentUpgradeTargetVersion: servers.agentUpgradeTargetVersion, + agentUpgradeStatus: servers.agentUpgradeStatus, + }) + .from(servers) + .where(eq(servers.id, serverId)) + .limit(1); + + if ( + server?.agentUpgradeTargetVersion === report.agentHealth.version && + server.agentUpgradeStatus !== "succeeded" && + server.agentUpgradeStatus !== "idle" + ) { + updateData.agentUpgradeStatus = "succeeded"; + updateData.agentUpgradeError = null; + completedAgentUpgradeTarget = report.agentHealth.version; + } } await db.update(servers).set(updateData).where(eq(servers.id, serverId)); + if (completedAgentUpgradeTarget) { + await db + .update(workQueue) + .set({ status: "completed" }) + .where( + and( + eq(workQueue.serverId, serverId), + eq(workQueue.type, "upgrade_agent"), + inArray(workQueue.status, ["pending", "processing"]), + ), + ); + } let serverLogName: string | undefined; const getCurrentServerLogName = async () => { diff --git a/web/lib/agent-upgrades.ts b/web/lib/agent-upgrades.ts new file mode 100644 index 00000000..5fbef917 --- /dev/null +++ b/web/lib/agent-upgrades.ts @@ -0,0 +1,136 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { servers, workQueue } from "@/db/schema"; + +const GITHUB_RELEASE_BASE_URL = + "https://github.com/techulus/cloud/releases/download"; +const TARGET_VERSION_PATTERN = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +type ServerMeta = { arch?: string; os?: string } | null; + +export function validateAgentTargetVersion(targetVersion: string) { + const version = targetVersion.trim(); + if (!TARGET_VERSION_PATTERN.test(version)) { + throw new Error("Invalid target version"); + } + return version; +} + +function getReleaseArch(meta: ServerMeta) { + if (meta?.os && meta.os !== "linux") { + throw new Error(`Agent upgrades are only supported for Linux servers`); + } + if (meta?.arch === "amd64" || meta?.arch === "arm64") return meta.arch; + throw new Error("Server architecture is unknown or unsupported"); +} + +async function fetchExpectedSha256(targetVersion: string, arch: string) { + const response = await fetch( + `${GITHUB_RELEASE_BASE_URL}/${targetVersion}/checksums.txt`, + { cache: "no-store" }, + ); + if (!response.ok) { + throw new Error(`Failed to fetch release checksums (${response.status})`); + } + + const assetName = `agent-linux-${arch}`; + const checksums = await response.text(); + for (const line of checksums.split("\n")) { + const [checksum, fileName] = line.trim().split(/\s+/); + if (fileName === assetName && /^[0-9a-f]{64}$/i.test(checksum)) { + return checksum.toLowerCase(); + } + } + + throw new Error(`Checksum for ${assetName} was not found`); +} + +export async function enqueueAgentUpgrade( + serverId: string, + targetVersionInput: string, +) { + const targetVersion = validateAgentTargetVersion(targetVersionInput); + + const [server] = await db + .select({ + id: servers.id, + status: servers.status, + meta: servers.meta, + agentHealth: servers.agentHealth, + }) + .from(servers) + .where(eq(servers.id, serverId)) + .limit(1); + + if (!server) throw new Error("Server not found"); + if (server.status !== "online") throw new Error("Server must be online"); + if (server.agentHealth?.version === targetVersion) { + await db + .update(servers) + .set({ + agentUpgradeTargetVersion: targetVersion, + agentUpgradeStatus: "succeeded", + agentUpgradeStartedAt: null, + agentUpgradeError: null, + }) + .where(eq(servers.id, serverId)); + return { status: "succeeded" as const }; + } + + const arch = getReleaseArch(server.meta); + const expectedSha256 = await fetchExpectedSha256(targetVersion, arch); + + try { + await db.transaction(async (tx) => { + await tx + .update(servers) + .set({ + agentUpgradeTargetVersion: targetVersion, + agentUpgradeStatus: "queued", + agentUpgradeStartedAt: null, + agentUpgradeError: null, + }) + .where(eq(servers.id, serverId)); + + await tx.insert(workQueue).values({ + id: randomUUID(), + serverId, + type: "upgrade_agent", + payload: JSON.stringify({ targetVersion, expectedSha256 }), + }); + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new Error("Agent upgrade already in progress"); + } + throw error; + } + + return { status: "queued" as const }; +} + +function isUniqueViolation(error: unknown) { + return ( + error instanceof Error && + "code" in error && + (error as Error & { code?: string }).code === "23505" + ); +} + +export async function clearCompletedAgentUpgrade(serverId: string) { + await db + .update(servers) + .set({ + agentUpgradeTargetVersion: null, + agentUpgradeStatus: "idle", + agentUpgradeStartedAt: null, + agentUpgradeError: null, + }) + .where( + and( + eq(servers.id, serverId), + eq(servers.agentUpgradeStatus, "succeeded"), + ), + ); +} diff --git a/web/lib/inngest/functions/crons.ts b/web/lib/inngest/functions/crons.ts index 85e4d7ba..78983e04 100644 --- a/web/lib/inngest/functions/crons.ts +++ b/web/lib/inngest/functions/crons.ts @@ -9,6 +9,7 @@ import { checkAndRecoverStaleServers, checkAndRunScheduledDeployments, cleanupStaleItems, + failTimedOutAgentUpgrades, } from "@/lib/scheduler"; import { inngest } from "../client"; @@ -122,3 +123,17 @@ export const staleItemsCleanup = inngest.createFunction( }); }, ); + +export const agentUpgradeTimeoutCheck = inngest.createFunction( + { + id: "cron-agent-upgrade-timeout-check", + triggers: [cron("*/5 * * * *")], + singleton: { mode: "skip" }, + }, + async ({ step }) => { + await step.run("fail-timed-out-agent-upgrades", async () => { + console.log("[cron] checking timed out agent upgrades"); + await failTimedOutAgentUpgrades(); + }); + }, +); diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts index dd6b545e..ef862f53 100644 --- a/web/lib/inngest/functions/index.ts +++ b/web/lib/inngest/functions/index.ts @@ -2,6 +2,7 @@ export { backupWorkflow } from "./backup-workflow"; export { buildTriggerWorkflow } from "./build-trigger-workflow"; export { buildWorkflow } from "./build-workflow"; export { + agentUpgradeTimeoutCheck, certificateRenewal, challengeCleanup, controlPlaneUpdateCheck, diff --git a/web/lib/scheduler.ts b/web/lib/scheduler.ts index e2edab16..ccb5ed3c 100644 --- a/web/lib/scheduler.ts +++ b/web/lib/scheduler.ts @@ -234,6 +234,45 @@ export async function checkAndRunScheduledDeployments(): Promise { } const OLD_ITEM_THRESHOLD_MS = 90 * 24 * 60 * 60 * 1000; +const AGENT_UPGRADE_TIMEOUT_MS = 5 * 60 * 1000; + +export async function failTimedOutAgentUpgrades(): Promise { + const timeoutThreshold = new Date(Date.now() - AGENT_UPGRADE_TIMEOUT_MS); + + const timedOut = await db + .update(servers) + .set({ + agentUpgradeStatus: "failed", + agentUpgradeError: "Agent did not report the target version in time", + }) + .where( + and( + eq(servers.agentUpgradeStatus, "upgrading"), + lt(servers.agentUpgradeStartedAt, timeoutThreshold), + sql`(${servers.agentHealth}->>'version') IS DISTINCT FROM ${servers.agentUpgradeTargetVersion}`, + ), + ) + .returning({ id: servers.id }); + + if (timedOut.length > 0) { + await db + .update(workQueue) + .set({ status: "failed" }) + .where( + and( + inArray( + workQueue.serverId, + timedOut.map((server) => server.id), + ), + eq(workQueue.type, "upgrade_agent"), + inArray(workQueue.status, ["pending", "processing"]), + ), + ); + console.log( + `[scheduler] marked ${timedOut.length} agent upgrade(s) timed out`, + ); + } +} export async function cleanupStaleItems(): Promise { const workItemLeaseThreshold = new Date( diff --git a/web/lib/work-queue.ts b/web/lib/work-queue.ts index c6bb674a..c592442b 100644 --- a/web/lib/work-queue.ts +++ b/web/lib/work-queue.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { db } from "@/db"; -import { deployments, workQueue } from "@/db/schema"; +import { deployments, servers, workQueue } from "@/db/schema"; import type { WorkQueue } from "@/db/types"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; @@ -162,6 +162,9 @@ export async function claimNextWorkItem( const row = rows[0]; if (!row) return null; + if (row.type === "upgrade_agent") { + await markAgentUpgradeStarted(serverId, row.payload); + } return { id: row.id, @@ -171,6 +174,29 @@ export async function claimNextWorkItem( }; } +async function markAgentUpgradeStarted(serverId: string, payloadText: string) { + try { + const payload = JSON.parse(payloadText) as { targetVersion?: string }; + if (!payload.targetVersion) return; + await db + .update(servers) + .set({ + agentUpgradeStatus: "upgrading", + agentUpgradeStartedAt: new Date(), + agentUpgradeError: null, + }) + .where( + and( + eq(servers.id, serverId), + eq(servers.agentUpgradeTargetVersion, payload.targetVersion), + inArray(servers.agentUpgradeStatus, ["queued", "upgrading"]), + ), + ); + } catch (error) { + console.error("[work-queue] failed to mark agent upgrade started:", error); + } +} + async function getRejectionReason( serverId: string, id: string, @@ -205,6 +231,11 @@ async function runWorkItemCompletionSideEffects( return; } + if (item.type === "upgrade_agent" && item.payload) { + await runAgentUpgradeCompletionSideEffects(item, result); + return; + } + if (item.type !== "create_manifest" || !item.payload) { return; } @@ -240,6 +271,60 @@ async function runWorkItemCompletionSideEffects( } } +async function runAgentUpgradeCompletionSideEffects( + item: WorkQueue, + result: WorkItemResult, +): Promise { + try { + const payload = JSON.parse(item.payload) as { targetVersion?: string }; + if (!payload.targetVersion) return; + + if (result.status === "failed") { + await db + .update(servers) + .set({ + agentUpgradeStatus: "failed", + agentUpgradeError: result.error || "Agent upgrade failed", + }) + .where( + and( + eq(servers.id, item.serverId), + eq(servers.agentUpgradeTargetVersion, payload.targetVersion), + ), + ); + return; + } + + const [server] = await db + .select({ agentHealth: servers.agentHealth }) + .from(servers) + .where(eq(servers.id, item.serverId)) + .limit(1); + + await db + .update(servers) + .set({ + agentUpgradeStatus: + server?.agentHealth?.version === payload.targetVersion + ? "succeeded" + : "upgrading", + agentUpgradeStartedAt: item.startedAt ?? new Date(), + agentUpgradeError: null, + }) + .where( + and( + eq(servers.id, item.serverId), + eq(servers.agentUpgradeTargetVersion, payload.targetVersion), + ), + ); + } catch (error) { + console.error( + "[work-queue] failed to run agent upgrade completion side effects:", + error, + ); + } +} + async function runForceCleanupCompletionSideEffects( item: WorkQueue, result: WorkItemResult, From 8e320fa172774dbf168e28c409f2330dd47f9bd9 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:13:26 +1000 Subject: [PATCH 4/8] Tighten CLI auth and log follow behavior --- cli/internal/cli/app.go | 32 +++++-------- cli/internal/cli/app_test.go | 91 ++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 21 deletions(-) diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go index 22c88ffc..f18d7b51 100644 --- a/cli/internal/cli/app.go +++ b/cli/internal/cli/app.go @@ -388,7 +388,7 @@ func (a *App) logsCommand() *cobra.Command { if tail < 1 || tail > 1000 { return errors.New("log line count must be between 1 and 1000") } - tailChanged := cmd.Flags().Changed("tail") || cmd.Flags().Changed("n") + tailChanged := cmd.Flags().Changed("tail") if tailChanged && !cmd.Flags().Changed("follow") { follow = false } @@ -497,9 +497,16 @@ func (a *App) runAuthLogin(ctx context.Context, host string) error { if interval <= 0 { interval = 5 * time.Second } + expiresAt := a.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second) var accessToken string for accessToken == "" { + if deviceCode.ExpiresIn > 0 && !a.Now().Before(expiresAt) { + return errors.New("device authorization expired") + } a.Sleep(interval) + if deviceCode.ExpiresIn > 0 && !a.Now().Before(expiresAt) { + return errors.New("device authorization expired") + } var tokenResponse deviceTokenResponse status, err := api.JSONStatus(ctx, a.HTTPClient, http.MethodPost, host+"/api/auth/device/token", map[string]string{ "grant_type": "urn:ietf:params:oauth:grant-type:device_code", @@ -605,30 +612,17 @@ func (a *App) runLogs(ctx context.Context, config *auth.Config, value manifest.M if after == "" { after = a.Now().UTC().Format(time.RFC3339Nano) } - seen := map[string]bool{} - for _, log := range result.Logs { - seen[getLogKey(log)] = true - } for { a.Sleep(logPollInterval) next, err := fetchLogs(ctx, client, value, defaultLogTail, after) if err != nil { return err } - var unseen []serviceLog - for _, log := range next.Logs { - if !seen[getLogKey(log)] { - unseen = append(unseen, log) - } - } - if len(unseen) == 0 { + if len(next.Logs) == 0 { continue } - printLogs(a.Out, unseen) - for _, log := range unseen { - seen[getLogKey(log)] = true - } - if cursor := getLogCursor(unseen); cursor != "" { + printLogs(a.Out, next.Logs) + if cursor := getLogCursor(next.Logs); cursor != "" { after = cursor } } @@ -739,10 +733,6 @@ func getLogCursor(logs []serviceLog) string { return "" } -func getLogKey(log serviceLog) string { - return fmt.Sprintf("%s:%s:%s:%s", log.Timestamp, log.Stream, log.DeploymentID, log.Message) -} - func selectFromList[T any]( reader *bufio.Reader, out io.Writer, diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go index 23a820be..d79cd4c4 100644 --- a/cli/internal/cli/app_test.go +++ b/cli/internal/cli/app_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -12,6 +13,7 @@ import ( "time" "techulus/cloud-cli/internal/auth" + "techulus/cloud-cli/internal/manifest" ) func TestInitCreatesManifest(t *testing.T) { @@ -125,6 +127,46 @@ func TestAuthLoginDeviceFlow(t *testing.T) { } } +func TestAuthLoginStopsAtDeviceExpiry(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "config")) + polls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/auth/device/code": + w.Write([]byte(`{"device_code":"device","user_code":"ABCD","verification_uri":"https://verify","expires_in":2,"interval":1}`)) + case "/api/auth/device/token": + polls++ + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"authorization_pending"}`)) + default: + t.Fatalf("path = %s", r.URL.Path) + } + })) + defer server.Close() + + now := time.Unix(0, 0) + var stdout bytes.Buffer + var stderr bytes.Buffer + app := NewApp("test", strings.NewReader(""), &stdout, &stderr) + app.HTTPClient = server.Client() + app.Now = func() time.Time { return now } + app.Sleep = func(duration time.Duration) { now = now.Add(duration) } + app.GetCWD = func() (string, error) { return tmp, nil } + cmd := app.rootCommand() + cmd.SetArgs([]string{"auth", "login", "--host", server.URL}) + cmd.SetIn(app.In) + cmd.SetOut(app.Out) + cmd.SetErr(app.Err) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "device authorization expired") { + t.Fatalf("auth login error = %v", err) + } + if polls != 1 { + t.Fatalf("polls = %d, want 1", polls) + } +} + func TestLinkInteractiveFlow(t *testing.T) { tmp := t.TempDir() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -152,6 +194,41 @@ func TestLinkInteractiveFlow(t *testing.T) { } } +func TestRunLogsFollowPrintsDuplicateReturnedLines(t *testing.T) { + tmp := t.TempDir() + writeTestManifest(t, tmp) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/logs" { + t.Fatalf("path = %s", r.URL.Path) + } + requests++ + switch requests { + case 1: + w.Write([]byte(`{"loggingEnabled":true,"logs":[]}`)) + case 2: + w.Write([]byte(`{"loggingEnabled":true,"logs":[{"deploymentId":"d","stream":"stdout","message":"same","timestamp":"2026-01-01T00:00:00Z"},{"deploymentId":"d","stream":"stdout","message":"same","timestamp":"2026-01-01T00:00:00Z"}]}`)) + default: + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"stop"}`)) + } + })) + defer server.Close() + + var stdout bytes.Buffer + app := NewApp("test", strings.NewReader(""), &stdout, &bytes.Buffer{}) + app.HTTPClient = server.Client() + app.Sleep = func(time.Duration) {} + app.Now = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } + err := app.runLogs(context.Background(), &auth.Config{Host: server.URL, APIKey: "secret"}, testManifest(), 100, true) + if err == nil || !strings.Contains(err.Error(), "stop") { + t.Fatalf("runLogs error = %v", err) + } + if count := strings.Count(stdout.String(), " same\n"); count != 2 { + t.Fatalf("printed duplicate count = %d, stdout = %s", count, stdout.String()) + } +} + func runTestCommand(t *testing.T, client *http.Client, cwd string, args ...string) (string, string, error) { t.Helper() return runTestCommandWithInput(t, client, cwd, "", false, args...) @@ -194,6 +271,20 @@ service: } } +func testManifest() manifest.Manifest { + return manifest.Manifest{ + APIVersion: "v1", + Project: "app", + Environment: "production", + Service: manifest.Service{ + Name: "web", + Source: manifest.Source{Type: "image", Image: "nginx:1.27"}, + Replicas: manifest.Replicas{Count: 1}, + Ports: []manifest.Port{}, + }, + } +} + func writeTestConfig(t *testing.T, host string) { t.Helper() configRoot := filepath.Join(t.TempDir(), "config") From f05187a0c99d33fb8906b0f5798f3b26a8f309ee Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Mon, 29 Jun 2026 10:23:41 +1000 Subject: [PATCH 5/8] Add automated release PR workflow Amp-Thread-ID: https://ampcode.com/threads/T-019f10b6-9c49-77f1-956b-cbf0b0eb83cb Co-authored-by: Amp --- .github/workflows/release-pr.yml | 204 +++++++++++++++++++++++++++++++ .github/workflows/release.yml | 5 + VERSION | 1 + 3 files changed, 210 insertions(+) create mode 100644 .github/workflows/release-pr.yml create mode 100644 VERSION diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 00000000..49c2a037 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,204 @@ +name: Release PR + +on: + push: + branches: + - main + - release + pull_request: + types: + - closed + branches: + - release + workflow_dispatch: + inputs: + bump: + description: Version bump to use for the next release PR + type: choice + default: minor + options: + - patch + - minor + - major + +permissions: + actions: write + contents: write + issues: write + pull-requests: write + +env: + AUTOMATION_BRANCH: automation/release + RELEASE_BASE: release + RELEASE_HEAD: main + RELEASE_LABEL: release-pr + +jobs: + maintain-release-pr: + name: Maintain release PR + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release')) + runs-on: ubuntu-latest + concurrency: + group: release-pr-maintain + cancel-in-progress: false + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Create or update release PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUMP_KIND: ${{ github.event.inputs.bump || 'minor' }} + run: | + set -euo pipefail + + git fetch origin "$RELEASE_HEAD" "$RELEASE_BASE" --tags + + commits_ahead=$(git rev-list --count "origin/$RELEASE_BASE..origin/$RELEASE_HEAD") + existing_pr=$(gh pr list \ + --base "$RELEASE_BASE" \ + --head "${{ github.repository_owner }}:$AUTOMATION_BRANCH" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + if [ "$commits_ahead" -eq 0 ]; then + echo "No commits from $RELEASE_HEAD to release." + if [ -n "$existing_pr" ]; then + gh pr close "$existing_pr" \ + --comment "Closing because there are no unreleased commits from $RELEASE_HEAD." \ + --delete-branch + fi + exit 0 + fi + + latest_tag=$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true) + if [ -z "$latest_tag" ]; then + latest_tag="v0.0.0" + fi + + version_without_prefix="${latest_tag#v}" + IFS=. read -r major minor patch <&2 + exit 1 + ;; + esac + + next_version="v${major}.${minor}.${patch}" + echo "Preparing $next_version from latest tag $latest_tag." + + gh label create "$RELEASE_LABEL" \ + --color "0e8a16" \ + --description "Automated release PR" \ + --force + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$AUTOMATION_BRANCH" "origin/$RELEASE_HEAD" + + printf '%s\n' "$next_version" > VERSION + git add VERSION + if ! git diff --cached --quiet; then + git commit -m "chore: prepare release $next_version" + fi + + git push --force origin "$AUTOMATION_BRANCH" + + body=$(cat <- + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'release' && + github.event.pull_request.head.ref == 'automation/release' + runs-on: ubuntu-latest + concurrency: + group: release-tag-${{ github.event.pull_request.merge_commit_sha }} + cancel-in-progress: false + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Tag merge commit and dispatch release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + + git fetch origin "$RELEASE_BASE" --tags + + if [ -z "$MERGE_SHA" ]; then + echo "Merged PR does not have a merge_commit_sha." >&2 + exit 1 + fi + + version=$(git show "$MERGE_SHA:VERSION" | tr -d '[:space:]') + if ! echo "$version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Invalid VERSION at $MERGE_SHA: $version" >&2 + exit 1 + fi + + if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then + echo "Tag $version already exists; not tagging again." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$version" "$MERGE_SHA" -m "Release $version" + git push origin "refs/tags/$version" + + gh workflow run release.yml --ref "$version" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df91d225..265cc26f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,15 @@ name: Release on: + workflow_dispatch: push: tags: - "v*" +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + permissions: contents: write packages: write diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..a86d3df7 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v0.18.0 From 8c4b909a7420e91933c45dc9fbb42f23b8fdf477 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Mon, 29 Jun 2026 10:29:21 +1000 Subject: [PATCH 6/8] Make automated release PRs draft Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019f10b6-9c49-77f1-956b-cbf0b0eb83cb --- .github/workflows/release-pr.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 49c2a037..7a028707 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -149,7 +149,8 @@ jobs: --head "$AUTOMATION_BRANCH" \ --title "Release $next_version" \ --body "$body" \ - --label "$RELEASE_LABEL" + --label "$RELEASE_LABEL" \ + --draft fi tag-merged-release-pr: From 7fdf6aaaaad7993c43e10c1d6647a04ba5f9c8a1 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Mon, 29 Jun 2026 10:34:32 +1000 Subject: [PATCH 7/8] Add agent and updater CI builds Amp-Thread-ID: https://ampcode.com/threads/T-019f10b6-9c49-77f1-956b-cbf0b0eb83cb Co-authored-by: Amp --- .github/workflows/agent-ci.yml | 32 +++++++++++++ .github/workflows/updater-ci.yml | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 .github/workflows/updater-ci.yml diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml index 273bd2aa..9567dd12 100644 --- a/.github/workflows/agent-ci.yml +++ b/.github/workflows/agent-ci.yml @@ -47,3 +47,35 @@ jobs: - name: Staticcheck run: ./.bin/staticcheck ./... + + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + defaults: + run: + working-directory: agent + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: agent/go.mod + cache-dependency-path: agent/go.sum + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + mkdir -p dist + go build -o dist/agent-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/agent diff --git a/.github/workflows/updater-ci.yml b/.github/workflows/updater-ci.yml new file mode 100644 index 00000000..da71d84e --- /dev/null +++ b/.github/workflows/updater-ci.yml @@ -0,0 +1,81 @@ +name: Updater CI + +on: + pull_request: + paths: + - "deployment/updater/**" + - ".github/workflows/updater-ci.yml" + push: + branches: + - main + paths: + - "deployment/updater/**" + - ".github/workflows/updater-ci.yml" + +permissions: + contents: read + +concurrency: + group: updater-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: deployment/updater + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: deployment/updater/go.mod + cache-dependency-path: deployment/updater/go.mod + + - name: Test + run: go test ./... + + - name: Vet + run: go vet ./... + + - name: Install staticcheck + run: GOBIN=$PWD/.bin go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Staticcheck + run: ./.bin/staticcheck ./... + + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + defaults: + run: + working-directory: deployment/updater + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: deployment/updater/go.mod + cache-dependency-path: deployment/updater/go.mod + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + mkdir -p dist + go build -o dist/control-plane-updater-${{ matrix.goos }}-${{ matrix.goarch }} . From d0ef87af817b574cd581f4f4bc4caec0759ad76f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:36:02 +0000 Subject: [PATCH 8/8] chore: prepare release v0.19.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index a86d3df7..96fb87f8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.18.0 +v0.19.0