feat: add cross-compile and release pipeline
- Enhanced Makefile with cross-compilation for linux/amd64, linux/arm64, darwin/arm64, windows/amd64, windows/arm64 - Added GitHub Actions CI workflow for testing on all platforms - Added GitHub Actions Release workflow triggered by version tags - Added VERSION file for version tracking - Added scripts/release.sh for automated release process - Added Dockerfile for containerized builds - Added CONTRIBUTING.md with release process documentation - Added CHANGELOG.md for version tracking - Updated .gitignore to exclude build artifacts - Fixed unused variable in cmd/obm/main.go - Version now injected via ldflags (main.version, main.gitCommit, main.buildTime)
This commit is contained in:
parent
33d9a2cb2e
commit
d080e107d0
15 changed files with 1853 additions and 12 deletions
84
.github/workflows/ci.yml
vendored
Normal file
84
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.22'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Run vet
|
||||||
|
run: make vet
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: make test
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
if [ -n "$(gofmt -l -s .)" ]; then
|
||||||
|
echo "Files not properly formatted:"
|
||||||
|
gofmt -l -s .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: make build
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
goos: [linux, darwin, windows]
|
||||||
|
goarch: [amd64, arm64]
|
||||||
|
exclude:
|
||||||
|
# Exclude darwin/amd64 - Apple Silicon is the future
|
||||||
|
- goos: darwin
|
||||||
|
goarch: amd64
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.22'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
run: |
|
||||||
|
VERSION=$(cat VERSION)
|
||||||
|
BINARY_NAME="obm"
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
BINARY_NAME="obm.exe"
|
||||||
|
fi
|
||||||
|
LDFLAGS="-s -w -X main.version=$VERSION"
|
||||||
|
GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "$LDFLAGS" -o "bin/${GOOS}_${GOARCH}/$BINARY_NAME" ./cmd/obm
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: obm-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
path: bin/${{ matrix.goos }}_${{ matrix.goarch }}/
|
||||||
|
retention-days: 7
|
||||||
154
.github/workflows/release.yml
vendored
Normal file
154
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# Linux
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
platform: linux-amd64
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
platform: linux-arm64
|
||||||
|
# macOS (Apple Silicon only)
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
platform: darwin-arm64
|
||||||
|
# Windows
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
platform: windows-amd64
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
platform: windows-arm64
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.22'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
run: |
|
||||||
|
VERSION=${{ steps.version.outputs.VERSION }}
|
||||||
|
BINARY_NAME="obm"
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
BINARY_NAME="obm.exe"
|
||||||
|
fi
|
||||||
|
LDFLAGS="-s -w -X main.version=$VERSION"
|
||||||
|
GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "$LDFLAGS" -o "$BINARY_NAME" ./cmd/obm
|
||||||
|
|
||||||
|
- name: Create archive
|
||||||
|
run: |
|
||||||
|
BINARY_NAME="obm"
|
||||||
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
BINARY_NAME="obm.exe"
|
||||||
|
zip -j "obm-${{ matrix.platform }}.zip" "$BINARY_NAME"
|
||||||
|
else
|
||||||
|
tar -czf "obm-${{ matrix.platform }}.tar.gz" "$BINARY_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: obm-${{ matrix.platform }}
|
||||||
|
path: |
|
||||||
|
obm-${{ matrix.platform }}.tar.gz
|
||||||
|
obm-${{ matrix.platform }}.zip
|
||||||
|
retention-days: 1
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Create Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: List artifacts
|
||||||
|
run: find artifacts -type f
|
||||||
|
|
||||||
|
- name: Create checksums
|
||||||
|
run: |
|
||||||
|
cd artifacts
|
||||||
|
find . -type f \( -name "*.tar.gz" -o -name "*.zip" \) -exec sha256sum {} \; > ../checksums.txt
|
||||||
|
cat ../checksums.txt
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
name: Release ${{ steps.version.outputs.VERSION }}
|
||||||
|
body: |
|
||||||
|
## obm ${{ steps.version.outputs.VERSION }}
|
||||||
|
|
||||||
|
OpenBoatMobile CLI tool for deploying AI agent infrastructure.
|
||||||
|
|
||||||
|
### Platforms
|
||||||
|
- `linux-amd64` - Linux (x86_64)
|
||||||
|
- `linux-arm64` - Linux (ARM64/AArch64)
|
||||||
|
- `darwin-arm64` - macOS (Apple Silicon)
|
||||||
|
- `windows-amd64` - Windows (x86_64)
|
||||||
|
- `windows-arm64` - Windows (ARM64)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
**Linux/macOS:**
|
||||||
|
```bash
|
||||||
|
# Download and extract
|
||||||
|
curl -sL https://github.com/openboatmobile/obm/releases/download/${{ steps.version.outputs.VERSION }}/obm-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/').tar.gz | tar xz
|
||||||
|
chmod +x obm
|
||||||
|
sudo mv obm /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
Download the appropriate `.zip` file, extract, and add to PATH.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
```bash
|
||||||
|
obm --help
|
||||||
|
obm deploy # Interactive deployment wizard
|
||||||
|
obm validate # Validate configuration
|
||||||
|
obm status # Check infrastructure health
|
||||||
|
```
|
||||||
|
files: |
|
||||||
|
artifacts/**/obm-*.tar.gz
|
||||||
|
artifacts/**/obm-*.zip
|
||||||
|
checksums.txt
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ contains(steps.version.outputs.VERSION, '-') }}
|
||||||
|
generate_release_notes: false
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -8,3 +8,12 @@ terraform.tfstate
|
||||||
terraform.tfstate.backup
|
terraform.tfstate.backup
|
||||||
.terraform/
|
.terraform/
|
||||||
.terraform.lock.hcl
|
.terraform.lock.hcl
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
checksums.txt
|
||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
|
|
|
||||||
35
CHANGELOG.md
Normal file
35
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-05-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of `obm` CLI tool
|
||||||
|
- Interactive deployment wizard (`obm deploy`)
|
||||||
|
- Configuration validation (`obm validate`)
|
||||||
|
- Infrastructure status checking (`obm status`)
|
||||||
|
- Deployment teardown (`obm destroy`)
|
||||||
|
- Multi-provider support (Hetzner, DigitalOcean)
|
||||||
|
- Multi-inference-provider support (ZAI, Venice, OpenRouter)
|
||||||
|
- Tailscale VPN integration
|
||||||
|
- Discord bot configuration
|
||||||
|
- Cross-compilation support for Linux, macOS, Windows
|
||||||
|
- GitHub Actions CI/CD pipeline
|
||||||
|
- Automated release workflow with binaries
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- CI workflow for testing on all platforms
|
||||||
|
- Release workflow triggered by version tags
|
||||||
|
- Docker container support
|
||||||
|
- Makefile with comprehensive build targets
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- README with usage examples
|
||||||
|
- CONTRIBUTING guide with release process
|
||||||
|
- CHANGELOG for version tracking
|
||||||
134
CONTRIBUTING.md
Normal file
134
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# Contributing to OpenBoatMobile CLI
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.22 or later
|
||||||
|
- Make (optional, for using Makefile targets)
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/openboatmobile/obm.git
|
||||||
|
cd obm
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# Build
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Run locally
|
||||||
|
./obm --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick build (current platform)
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Run tests with race detection
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
make fmt
|
||||||
|
|
||||||
|
# Cross-compile all platforms
|
||||||
|
make cross-compile
|
||||||
|
|
||||||
|
# Prepare release artifacts
|
||||||
|
make prepare-release VERSION=0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
Releases are automated via GitHub Actions.
|
||||||
|
|
||||||
|
### Creating a Release
|
||||||
|
|
||||||
|
1. **Update VERSION file**:
|
||||||
|
```bash
|
||||||
|
echo "0.2.0" > VERSION
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the release script** (recommended):
|
||||||
|
```bash
|
||||||
|
./scripts/release.sh v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Validate version format
|
||||||
|
- Run tests
|
||||||
|
- Update VERSION file
|
||||||
|
- Commit the version bump
|
||||||
|
- Create and push the tag
|
||||||
|
- Trigger GitHub Actions release workflow
|
||||||
|
|
||||||
|
3. **Manual release** (alternative):
|
||||||
|
```bash
|
||||||
|
# Update VERSION
|
||||||
|
echo "0.2.0" > VERSION
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
git add VERSION
|
||||||
|
git commit -m "chore: release v0.2.0"
|
||||||
|
|
||||||
|
# Tag
|
||||||
|
git tag -a v0.2.0 -m "Release v0.2.0"
|
||||||
|
|
||||||
|
# Push tag
|
||||||
|
git push origin v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### What Happens When a Tag is Pushed?
|
||||||
|
|
||||||
|
GitHub Actions automatically:
|
||||||
|
1. Builds binaries for all platforms (Linux amd64/arm64, macOS arm64, Windows amd64/arm64)
|
||||||
|
2. Creates archives (.tar.gz for Unix, .zip for Windows)
|
||||||
|
3. Generates SHA256 checksums
|
||||||
|
4. Creates a GitHub Release with download links
|
||||||
|
|
||||||
|
### Pre-release Versions
|
||||||
|
|
||||||
|
Versions with a hyphen (e.g., `v1.0.0-beta.1`) are marked as pre-release.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/obm/ - CLI entry point and command handlers
|
||||||
|
internal/
|
||||||
|
config/ - Configuration parsing and validation
|
||||||
|
deploy/ - Deployment wizard orchestration
|
||||||
|
destroy/ - Infrastructure teardown
|
||||||
|
inference/ - Inference provider client (ZAI, Venice, OpenRouter)
|
||||||
|
prompt/ - Interactive terminal prompts
|
||||||
|
provider/ - Cloud provider interfaces (Hetzner, DigitalOcean)
|
||||||
|
ssh/ - SSH client for remote execution
|
||||||
|
step/ - Deployment steps (Tailscale, Discord)
|
||||||
|
terraform/ - Terraform wrapper
|
||||||
|
validation/ - Configuration validation framework
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Run `make test` for full test suite with race detection
|
||||||
|
- Run `make quick-test` for faster iteration (no race detection)
|
||||||
|
- Run `make coverage` to generate HTML coverage report
|
||||||
|
|
||||||
|
## Pull Request Checklist
|
||||||
|
|
||||||
|
- [ ] Tests pass (`make test`)
|
||||||
|
- [ ] Code is formatted (`make fmt`)
|
||||||
|
- [ ] Vet passes (`make vet`)
|
||||||
|
- [ ] New code has tests
|
||||||
|
- [ ] Documentation updated if needed
|
||||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Build stage
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum* ./
|
||||||
|
RUN go mod download || go mod tidy
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build arguments for version injection
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
ARG BUILD_TIME=unknown
|
||||||
|
|
||||||
|
# Build with ldflags
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||||
|
-ldflags="-s -w -X main.version=${VERSION} -X main.gitCommit=${GIT_COMMIT} -X main.buildTime=${BUILD_TIME}" \
|
||||||
|
-o obm ./cmd/obm
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install ca-certificates for HTTPS
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /build/obm /usr/local/bin/obm
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1000 obm && \
|
||||||
|
adduser -D -u 1000 -G obm -h /home/obm obm && \
|
||||||
|
chown -R obm:obm /app
|
||||||
|
|
||||||
|
USER obm
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
ENTRYPOINT ["obm"]
|
||||||
|
CMD ["--help"]
|
||||||
137
Makefile
137
Makefile
|
|
@ -1,28 +1,149 @@
|
||||||
.PHONY: build test lint clean fmt vet
|
.PHONY: build test lint clean fmt vet version cross-compile release install uninstall
|
||||||
|
|
||||||
|
# Version handling - override with VERSION=0.2.0 make build
|
||||||
|
VERSION := $(shell cat VERSION 2>/dev/null || echo "0.1.0")
|
||||||
|
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
BINARY := obm
|
BINARY := obm
|
||||||
VERSION := 0.1.0
|
LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME)"
|
||||||
LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION)"
|
|
||||||
|
# Go parameters
|
||||||
|
GOCMD := go
|
||||||
|
GOBUILD := $(GOCMD) build
|
||||||
|
GOCLEAN := $(GOCMD) clean
|
||||||
|
GOTEST := $(GOCMD) test
|
||||||
|
GOGET := $(GOCMD) get
|
||||||
|
GOMOD := $(GOCMD) mod
|
||||||
|
GOVET := $(GOCMD) vet
|
||||||
|
|
||||||
|
# Cross-compilation targets
|
||||||
|
TARGETS := linux-amd64 linux-arm64 darwin-arm64 windows-amd64 windows-arm64
|
||||||
|
|
||||||
|
# Platform detection
|
||||||
|
GOOS := $(shell go env GOOS)
|
||||||
|
GOARCH := $(shell go env GOARCH)
|
||||||
|
|
||||||
|
all: lint test build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build $(LDFLAGS) -o $(BINARY) ./cmd/obm
|
@echo "Building $(BINARY) v$(VERSION) for $(GOOS)/$(GOARCH)..."
|
||||||
|
$(GOBUILD) $(LDFLAGS) -o $(BINARY) ./cmd/obm
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v -race ./...
|
@echo "Running tests..."
|
||||||
|
$(GOTEST) -v -race -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
lint: vet fmt
|
lint: vet fmt
|
||||||
@echo "lint: ok"
|
@echo "✓ Lint pass"
|
||||||
|
|
||||||
vet:
|
vet:
|
||||||
go vet ./...
|
@echo "Running go vet..."
|
||||||
|
$(GOVET) ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
|
@echo "Formatting code..."
|
||||||
gofmt -l -s -w .
|
gofmt -l -s -w .
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
@echo "Cleaning..."
|
||||||
rm -f $(BINARY)
|
rm -f $(BINARY)
|
||||||
|
rm -f coverage.out
|
||||||
|
rm -rf bin/
|
||||||
|
$(GOCLEAN)
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./$(BINARY)
|
./$(BINARY)
|
||||||
|
|
||||||
all: lint test build
|
install: build
|
||||||
|
@echo "Installing $(BINARY)..."
|
||||||
|
cp $(BINARY) $(GOPATH)/bin/
|
||||||
|
|
||||||
|
uninstall: clean
|
||||||
|
@echo "Uninstalling $(BINARY)..."
|
||||||
|
rm -f $(GOPATH)/bin/$(BINARY)
|
||||||
|
|
||||||
|
version:
|
||||||
|
@echo "$(VERSION)"
|
||||||
|
|
||||||
|
# Cross-compilation
|
||||||
|
cross-compile:
|
||||||
|
@echo "Cross-compiling for all platforms..."
|
||||||
|
@mkdir -p bin
|
||||||
|
@for target in $(TARGETS); do \
|
||||||
|
os=$${target%-*}; \
|
||||||
|
arch=$${target#*-}; \
|
||||||
|
echo "Building for $$os/$$arch..."; \
|
||||||
|
binary="$(BINARY)"; \
|
||||||
|
ext=""; \
|
||||||
|
if [ "$$os" = "windows" ]; then \
|
||||||
|
ext=".exe"; \
|
||||||
|
fi; \
|
||||||
|
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o "bin/$$target/$$binary$$ext" ./cmd/obm; \
|
||||||
|
done
|
||||||
|
@echo "✓ Cross-compilation complete"
|
||||||
|
|
||||||
|
# Build specific platform
|
||||||
|
build-linux-amd64:
|
||||||
|
@mkdir -p bin
|
||||||
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/linux-amd64/$(BINARY) ./cmd/obm
|
||||||
|
|
||||||
|
build-linux-arm64:
|
||||||
|
@mkdir -p bin
|
||||||
|
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/linux-arm64/$(BINARY) ./cmd/obm
|
||||||
|
|
||||||
|
build-darwin-arm64:
|
||||||
|
@mkdir -p bin
|
||||||
|
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/darwin-arm64/$(BINARY) ./cmd/obm
|
||||||
|
|
||||||
|
build-windows-amd64:
|
||||||
|
@mkdir -p bin
|
||||||
|
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/windows-amd64/$(BINARY).exe ./cmd/obm
|
||||||
|
|
||||||
|
build-windows-arm64:
|
||||||
|
@mkdir -p bin
|
||||||
|
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/windows-arm64/$(BINARY).exe ./cmd/obm
|
||||||
|
|
||||||
|
# Create release archives
|
||||||
|
release-archives: cross-compile
|
||||||
|
@echo "Creating release archives..."
|
||||||
|
@cd bin && \
|
||||||
|
for dir in */; do \
|
||||||
|
platform=$${dir%/}; \
|
||||||
|
echo "Archiving $$platform..."; \
|
||||||
|
if [[ "$$platform" == windows-* ]]; then \
|
||||||
|
zip -j $$platform.zip $$platform/; \
|
||||||
|
else \
|
||||||
|
tar -czf $$platform.tar.gz -C $$platform .; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
@echo "✓ Release archives created in bin/"
|
||||||
|
|
||||||
|
# Checksums
|
||||||
|
checksums:
|
||||||
|
@echo "Generating checksums..."
|
||||||
|
@cd bin && sha256sum *.tar.gz *.zip > checksums.txt 2>/dev/null || true
|
||||||
|
@echo "✓ Checksums in bin/checksums.txt"
|
||||||
|
|
||||||
|
# Full release preparation (local)
|
||||||
|
prepare-release: clean test cross-compile release-archives checksums
|
||||||
|
@echo ""
|
||||||
|
@echo "Release v$(VERSION) prepared in bin/"
|
||||||
|
@ls -la bin/
|
||||||
|
|
||||||
|
# Docker build (for containerized usage)
|
||||||
|
docker-build:
|
||||||
|
docker build -t obm:$(VERSION) -t obm:latest .
|
||||||
|
|
||||||
|
# Development
|
||||||
|
dev: build test
|
||||||
|
@echo "✓ Development build complete"
|
||||||
|
|
||||||
|
# Quick test during development
|
||||||
|
quick-test:
|
||||||
|
$(GOTEST) -race ./...
|
||||||
|
|
||||||
|
# Coverage report
|
||||||
|
coverage: test
|
||||||
|
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||||
|
@echo "Coverage report: coverage.html"
|
||||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0.1.0
|
||||||
125
deploy.yaml.example
Normal file
125
deploy.yaml.example
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# OpenBoatmobile Deployment Configuration
|
||||||
|
# Usage: obm deploy --config deploy.yaml
|
||||||
|
#
|
||||||
|
# This file defines all configuration for non-interactive CI/CD deployment.
|
||||||
|
# All required fields must be provided. Optional fields have sensible defaults.
|
||||||
|
|
||||||
|
# Required: Agent framework selection
|
||||||
|
# Options: hermes, openclaw
|
||||||
|
framework: hermes
|
||||||
|
|
||||||
|
# Cloud provider configuration
|
||||||
|
provider:
|
||||||
|
# Required: Cloud provider name
|
||||||
|
# Options: hetzner, digitalocean
|
||||||
|
name: hetzner
|
||||||
|
|
||||||
|
# Required: API token (sensitive)
|
||||||
|
# Hetzner: Get from https://console.hetzner.cloud/ → Security → API Tokens
|
||||||
|
# DigitalOcean: Get from https://cloud.digitalocean.com/account/api/tokens
|
||||||
|
token: "your-api-token-here"
|
||||||
|
|
||||||
|
# SSH key configuration
|
||||||
|
ssh:
|
||||||
|
# For Hetzner: Key name as shown in Hetzner Cloud Console
|
||||||
|
names:
|
||||||
|
- "my-ssh-key"
|
||||||
|
# For DigitalOcean: Key fingerprints
|
||||||
|
# fingerprints:
|
||||||
|
# - "aa:bb:cc:dd:ee:ff"
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
server:
|
||||||
|
# Server hostname (default: agent-gateway)
|
||||||
|
name: "my-agent-gateway"
|
||||||
|
|
||||||
|
# Agent name for display (default: same as framework)
|
||||||
|
agent_name: "hermes"
|
||||||
|
|
||||||
|
# Timezone (default: UTC)
|
||||||
|
timezone: "UTC"
|
||||||
|
|
||||||
|
# === Hetzner-specific ===
|
||||||
|
# Location: ash (Ashburn, VA), fsn1 (Falkenstein), nbg1 (Nuremberg), hel1 (Helsinki)
|
||||||
|
location: "ash"
|
||||||
|
# Server type: cpx21 (recommended), cx23, cpx31 (default: cpx21)
|
||||||
|
type: "cpx21"
|
||||||
|
|
||||||
|
# === DigitalOcean-specific ===
|
||||||
|
# region: "nyc3"
|
||||||
|
# size: "s-2vcpu-4gb"
|
||||||
|
|
||||||
|
# Inference provider configuration
|
||||||
|
inference:
|
||||||
|
# Required: Inference provider
|
||||||
|
# Options: venice, openrouter, openai, anthropic, custom
|
||||||
|
provider: venice
|
||||||
|
|
||||||
|
# Required: API key (sensitive)
|
||||||
|
# Venice: Get from https://venice.ai → Settings → API Keys
|
||||||
|
api_key: "your-venice-api-key"
|
||||||
|
|
||||||
|
# Optional: Base URL (required for custom provider)
|
||||||
|
# base_url: "https://api.custom.com/v1"
|
||||||
|
|
||||||
|
# Optional: Primary model (default depends on provider)
|
||||||
|
primary_model: "zai-org-glm-5"
|
||||||
|
|
||||||
|
# Optional: Model display name
|
||||||
|
primary_model_name: "GLM 5"
|
||||||
|
|
||||||
|
# Optional: Fallback models in priority order
|
||||||
|
fallback_models:
|
||||||
|
# - "openai/gpt-4o"
|
||||||
|
|
||||||
|
# Optional: Fallback providers with their own API keys
|
||||||
|
# fallbacks:
|
||||||
|
# - provider: openai
|
||||||
|
# api_key: "sk-..."
|
||||||
|
# - provider: openrouter
|
||||||
|
# api_key: "sk-or-..."
|
||||||
|
|
||||||
|
# Optional: Tailscale VPN configuration
|
||||||
|
tailscale:
|
||||||
|
enabled: true
|
||||||
|
# Required if enabled: Auth key from https://login.tailscale.com/admin/settings/keys
|
||||||
|
auth_key: "tskey-auth-..."
|
||||||
|
# Optional: Tailnet domain (default: tailnet)
|
||||||
|
tailnet: "mytailnet"
|
||||||
|
|
||||||
|
# Optional: Discord integration
|
||||||
|
discord:
|
||||||
|
enabled: false
|
||||||
|
# Required if enabled: Bot token from Discord Developer Portal
|
||||||
|
# bot_token: ""
|
||||||
|
# Required if enabled: Server/guild ID
|
||||||
|
# server_id: ""
|
||||||
|
# Optional: Allowed user IDs
|
||||||
|
# user_ids:
|
||||||
|
# - "123456789"
|
||||||
|
# Hermes-specific:
|
||||||
|
# home_channel: ""
|
||||||
|
# auto_thread: true
|
||||||
|
|
||||||
|
# Optional: Hermes-specific configuration
|
||||||
|
# hermes:
|
||||||
|
# docker_enabled: true
|
||||||
|
|
||||||
|
# Optional: OpenClaw-specific configuration
|
||||||
|
# openclaw:
|
||||||
|
# version: "lts"
|
||||||
|
# node_version: "22"
|
||||||
|
# enable_swap: true
|
||||||
|
# swap_size_gb: 2
|
||||||
|
# enable_fail2ban: true
|
||||||
|
# enable_unattended_upgrades: true
|
||||||
|
|
||||||
|
# Optional: Hermes gateway configuration
|
||||||
|
# gateway:
|
||||||
|
# token: ""
|
||||||
|
# allowed_users: ""
|
||||||
|
# allow_all: true
|
||||||
|
|
||||||
|
# Optional: Additional integrations
|
||||||
|
# integrations:
|
||||||
|
# brave_search_api_key: ""
|
||||||
2
go.mod
2
go.mod
|
|
@ -1,3 +1,5 @@
|
||||||
module github.com/openboatmobile/obm
|
module github.com/openboatmobile/obm
|
||||||
|
|
||||||
go 1.22.2
|
go 1.22.2
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
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=
|
||||||
438
internal/config/yaml.go
Normal file
438
internal/config/yaml.go
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
// Package config handles YAML configuration loading for non-interactive mode.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// YAMLConfig represents the YAML configuration file structure for non-interactive deployment.
|
||||||
|
// This is the schema for obm-deploy.yaml files used in CI/CD pipelines.
|
||||||
|
type YAMLConfig struct {
|
||||||
|
// Framework selection (required)
|
||||||
|
Framework string `yaml:"framework"` // "hermes" or "openclaw"
|
||||||
|
|
||||||
|
// Cloud provider configuration
|
||||||
|
Provider YAMLProviderConfig `yaml:"provider"`
|
||||||
|
|
||||||
|
// Server configuration
|
||||||
|
Server YAMLServerConfig `yaml:"server"`
|
||||||
|
|
||||||
|
// Inference configuration
|
||||||
|
Inference YAMLInferenceConfig `yaml:"inference"`
|
||||||
|
|
||||||
|
// Tailscale configuration (optional)
|
||||||
|
Tailscale *YAMLTailscaleConfig `yaml:"tailscale,omitempty"`
|
||||||
|
|
||||||
|
// Discord configuration (optional)
|
||||||
|
Discord *YAMLDiscordConfig `yaml:"discord,omitempty"`
|
||||||
|
|
||||||
|
// Hermes-specific configuration (optional, only for framework: hermes)
|
||||||
|
Hermes *YAMLHermesConfig `yaml:"hermes,omitempty"`
|
||||||
|
|
||||||
|
// OpenClaw-specific configuration (optional, only for framework: openclaw)
|
||||||
|
OpenClaw *YAMLOpenClawConfig `yaml:"openclaw,omitempty"`
|
||||||
|
|
||||||
|
// Gateway configuration (optional, Hermes-only)
|
||||||
|
Gateway *YAMLGatewayConfig `yaml:"gateway,omitempty"`
|
||||||
|
|
||||||
|
// Optional integrations (optional)
|
||||||
|
Integrations *YAMLIntegrationsConfig `yaml:"integrations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLProviderConfig holds cloud provider configuration.
|
||||||
|
type YAMLProviderConfig struct {
|
||||||
|
// Cloud provider: "hetzner" or "digitalocean" (required)
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
|
||||||
|
// API token (required, sensitive)
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
|
||||||
|
// SSH key configuration
|
||||||
|
SSH YAMLSSHConfig `yaml:"ssh"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLSSHConfig holds SSH key configuration.
|
||||||
|
type YAMLSSHConfig struct {
|
||||||
|
// For Hetzner: key names as shown in console
|
||||||
|
Names []string `yaml:"names,omitempty"`
|
||||||
|
|
||||||
|
// For DigitalOcean: key fingerprints
|
||||||
|
Fingerprints []string `yaml:"fingerprints,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLServerConfig holds server configuration.
|
||||||
|
type YAMLServerConfig struct {
|
||||||
|
// Server hostname
|
||||||
|
Name string `yaml:"name,omitempty"`
|
||||||
|
|
||||||
|
// Agent name (defaults to framework name)
|
||||||
|
AgentName string `yaml:"agent_name,omitempty"`
|
||||||
|
|
||||||
|
// Timezone (default: UTC)
|
||||||
|
Timezone string `yaml:"timezone,omitempty"`
|
||||||
|
|
||||||
|
// Location for Hetzner: ash, fsn1, nbg1, hel1
|
||||||
|
Location string `yaml:"location,omitempty"`
|
||||||
|
|
||||||
|
// Server type for Hetzner: cpx21, cx23, cpx31
|
||||||
|
Type string `yaml:"type,omitempty"`
|
||||||
|
|
||||||
|
// Region for DigitalOcean: nyc3, sfo2, ams3, etc.
|
||||||
|
Region string `yaml:"region,omitempty"`
|
||||||
|
|
||||||
|
// Droplet size for DigitalOcean
|
||||||
|
Size string `yaml:"size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLInferenceConfig holds inference provider configuration.
|
||||||
|
type YAMLInferenceConfig struct {
|
||||||
|
// Provider: venice, openrouter, openai, anthropic, custom
|
||||||
|
Provider string `yaml:"provider"`
|
||||||
|
|
||||||
|
// API key (sensitive)
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
|
||||||
|
// Base URL (optional, for custom providers)
|
||||||
|
BaseURL string `yaml:"base_url,omitempty"`
|
||||||
|
|
||||||
|
// Primary model ID
|
||||||
|
PrimaryModel string `yaml:"primary_model,omitempty"`
|
||||||
|
|
||||||
|
// Primary model display name
|
||||||
|
PrimaryModelName string `yaml:"primary_model_name,omitempty"`
|
||||||
|
|
||||||
|
// Fallback models in priority order
|
||||||
|
FallbackModels []string `yaml:"fallback_models,omitempty"`
|
||||||
|
|
||||||
|
// Fallback providers configuration
|
||||||
|
Fallbacks []YAMLFallbackProviderConfig `yaml:"fallbacks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLFallbackProviderConfig holds fallback inference provider configuration.
|
||||||
|
type YAMLFallbackProviderConfig struct {
|
||||||
|
Provider string `yaml:"provider"`
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
BaseURL string `yaml:"base_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLTailscaleConfig holds Tailscale VPN configuration.
|
||||||
|
type YAMLTailscaleConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
AuthKey string `yaml:"auth_key"`
|
||||||
|
Tailnet string `yaml:"tailnet,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLDiscordConfig holds Discord integration configuration.
|
||||||
|
type YAMLDiscordConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
BotToken string `yaml:"bot_token"`
|
||||||
|
ServerID string `yaml:"server_id"`
|
||||||
|
UserIDs []string `yaml:"user_ids,omitempty"`
|
||||||
|
|
||||||
|
// Hermes-specific Discord options
|
||||||
|
HomeChannel string `yaml:"home_channel,omitempty"`
|
||||||
|
AutoThread bool `yaml:"auto_thread,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLHermesConfig holds Hermes-specific configuration.
|
||||||
|
type YAMLHermesConfig struct {
|
||||||
|
DockerEnabled bool `yaml:"docker_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLOpenClawConfig holds OpenClaw-specific configuration.
|
||||||
|
type YAMLOpenClawConfig struct {
|
||||||
|
Version string `yaml:"version,omitempty"`
|
||||||
|
NodeVersion string `yaml:"node_version,omitempty"`
|
||||||
|
EnableSwap bool `yaml:"enable_swap,omitempty"`
|
||||||
|
SwapSizeGB int `yaml:"swap_size_gb,omitempty"`
|
||||||
|
EnableFail2ban bool `yaml:"enable_fail2ban,omitempty"`
|
||||||
|
EnableUnattendedUpgrades bool `yaml:"enable_unattended_upgrades,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLGatewayConfig holds Hermes gateway configuration.
|
||||||
|
type YAMLGatewayConfig struct {
|
||||||
|
Token string `yaml:"token,omitempty"`
|
||||||
|
AllowedUsers string `yaml:"allowed_users,omitempty"`
|
||||||
|
AllowAll bool `yaml:"allow_all,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAMLIntegrationsConfig holds optional service integrations.
|
||||||
|
type YAMLIntegrationsConfig struct {
|
||||||
|
BraveSearchAPIKey string `yaml:"brave_search_api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadYAMLConfig loads and validates a YAML configuration file.
|
||||||
|
func LoadYAMLConfig(path string) (*YAMLConfig, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg YAMLConfig
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
applyYAMLDefaults(&cfg)
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if err := validateYAMLConfig(&cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfigFile is an alias for LoadYAMLConfig (convenience).
|
||||||
|
func LoadConfigFile(path string) (*YAMLConfig, error) {
|
||||||
|
return LoadYAMLConfig(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyYAMLDefaults sets default values for optional fields.
|
||||||
|
func applyYAMLDefaults(cfg *YAMLConfig) {
|
||||||
|
if cfg.Server.Name == "" {
|
||||||
|
cfg.Server.Name = "agent-gateway"
|
||||||
|
}
|
||||||
|
if cfg.Server.Timezone == "" {
|
||||||
|
cfg.Server.Timezone = "UTC"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hetzner defaults
|
||||||
|
if cfg.Provider.Name == "hetzner" {
|
||||||
|
if cfg.Server.Location == "" {
|
||||||
|
cfg.Server.Location = "ash"
|
||||||
|
}
|
||||||
|
if cfg.Server.Type == "" {
|
||||||
|
cfg.Server.Type = "cpx21"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DigitalOcean defaults
|
||||||
|
if cfg.Provider.Name == "digitalocean" {
|
||||||
|
if cfg.Server.Region == "" {
|
||||||
|
cfg.Server.Region = "nyc3"
|
||||||
|
}
|
||||||
|
if cfg.Server.Size == "" {
|
||||||
|
cfg.Server.Size = "s-2vcpu-4gb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inference defaults
|
||||||
|
if cfg.Inference.PrimaryModel == "" {
|
||||||
|
cfg.Inference.PrimaryModel = defaultModelForProvider(cfg.Inference.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Framework-specific defaults
|
||||||
|
if cfg.Framework == "hermes" {
|
||||||
|
if cfg.Hermes == nil {
|
||||||
|
cfg.Hermes = &YAMLHermesConfig{}
|
||||||
|
}
|
||||||
|
if cfg.Server.AgentName == "" {
|
||||||
|
cfg.Server.AgentName = "hermes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Framework == "openclaw" {
|
||||||
|
if cfg.OpenClaw == nil {
|
||||||
|
cfg.OpenClaw = &YAMLOpenClawConfig{}
|
||||||
|
}
|
||||||
|
if cfg.OpenClaw.Version == "" {
|
||||||
|
cfg.OpenClaw.Version = "lts"
|
||||||
|
}
|
||||||
|
if cfg.OpenClaw.NodeVersion == "" {
|
||||||
|
cfg.OpenClaw.NodeVersion = "22"
|
||||||
|
}
|
||||||
|
cfg.OpenClaw.EnableSwap = true
|
||||||
|
cfg.OpenClaw.SwapSizeGB = 2
|
||||||
|
cfg.OpenClaw.EnableFail2ban = true
|
||||||
|
cfg.OpenClaw.EnableUnattendedUpgrades = true
|
||||||
|
if cfg.Server.AgentName == "" {
|
||||||
|
cfg.Server.AgentName = "openclaw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tailscale defaults
|
||||||
|
if cfg.Tailscale != nil && cfg.Tailscale.Enabled {
|
||||||
|
if cfg.Tailscale.Tailnet == "" {
|
||||||
|
cfg.Tailscale.Tailnet = "tailnet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord defaults
|
||||||
|
if cfg.Discord != nil && cfg.Discord.Enabled && cfg.Framework == "hermes" {
|
||||||
|
cfg.Discord.AutoThread = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateYAMLConfig validates the configuration file.
|
||||||
|
func validateYAMLConfig(cfg *YAMLConfig) error {
|
||||||
|
// Required: framework
|
||||||
|
if cfg.Framework == "" {
|
||||||
|
return fmt.Errorf("framework is required (hermes or openclaw)")
|
||||||
|
}
|
||||||
|
if cfg.Framework != "hermes" && cfg.Framework != "openclaw" {
|
||||||
|
return fmt.Errorf("invalid framework: %s (must be hermes or openclaw)", cfg.Framework)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required: provider
|
||||||
|
if cfg.Provider.Name == "" {
|
||||||
|
return fmt.Errorf("provider.name is required (hetzner or digitalocean)")
|
||||||
|
}
|
||||||
|
if cfg.Provider.Name != "hetzner" && cfg.Provider.Name != "digitalocean" {
|
||||||
|
return fmt.Errorf("invalid provider: %s (must be hetzner or digitalocean)", cfg.Provider.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required: provider token
|
||||||
|
if cfg.Provider.Token == "" {
|
||||||
|
return fmt.Errorf("provider.token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required: SSH key (at least one)
|
||||||
|
if len(cfg.Provider.SSH.Names) == 0 && len(cfg.Provider.SSH.Fingerprints) == 0 {
|
||||||
|
return fmt.Errorf("provider.ssh.names (Hetzner) or provider.ssh.fingerprints (DigitalOcean) is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Hetzner location
|
||||||
|
if cfg.Provider.Name == "hetzner" &&
|
||||||
|
cfg.Server.Location != "" {
|
||||||
|
validLocations := map[string]bool{"ash": true, "fsn1": true, "nbg1": true, "hel1": true}
|
||||||
|
if !validLocations[cfg.Server.Location] {
|
||||||
|
return fmt.Errorf("invalid location: %s (must be one of: ash, fsn1, nbg1, hel1)", cfg.Server.Location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate inference provider
|
||||||
|
validProviders := map[string]bool{
|
||||||
|
"venice": true, "openrouter": true, "openai": true, "anthropic": true, "custom": true,
|
||||||
|
}
|
||||||
|
if !validProviders[cfg.Inference.Provider] {
|
||||||
|
return fmt.Errorf("invalid inference provider: %s", cfg.Inference.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required: inference API key (unless custom with no auth)
|
||||||
|
if cfg.Inference.Provider != "custom" && cfg.Inference.APIKey == "" {
|
||||||
|
return fmt.Errorf("inference.api_key is required for provider: %s", cfg.Inference.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom provider requires base URL
|
||||||
|
if cfg.Inference.Provider == "custom" && cfg.Inference.BaseURL == "" {
|
||||||
|
return fmt.Errorf("inference.base_url is required for custom provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tailscale validation
|
||||||
|
if cfg.Tailscale != nil && cfg.Tailscale.Enabled {
|
||||||
|
if cfg.Tailscale.AuthKey == "" {
|
||||||
|
return fmt.Errorf("tailscale.auth_key is required when tailscale is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord validation
|
||||||
|
if cfg.Discord != nil && cfg.Discord.Enabled {
|
||||||
|
if cfg.Discord.BotToken == "" {
|
||||||
|
return fmt.Errorf("discord.bot_token is required when discord is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToDeploymentConfig converts the YAML config file to a DeploymentConfig.
|
||||||
|
func (c *YAMLConfig) ToDeploymentConfig() *DeploymentConfig {
|
||||||
|
cfg := &DeploymentConfig{
|
||||||
|
Framework: c.Framework,
|
||||||
|
CloudProvider: c.Provider.Name,
|
||||||
|
ServerName: c.Server.Name,
|
||||||
|
AgentName: c.Server.AgentName,
|
||||||
|
AgentTimezone: c.Server.Timezone,
|
||||||
|
SSHKeyNames: c.Provider.SSH.Names,
|
||||||
|
SSHKeyFingerprints: c.Provider.SSH.Fingerprints,
|
||||||
|
InferenceProvider: c.Inference.Provider,
|
||||||
|
InferenceAPIKey: c.Inference.APIKey,
|
||||||
|
InferenceBaseURL: c.Inference.BaseURL,
|
||||||
|
PrimaryModel: c.Inference.PrimaryModel,
|
||||||
|
PrimaryModelName: c.Inference.PrimaryModelName,
|
||||||
|
FallbackModels: c.Inference.FallbackModels,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider-specific
|
||||||
|
if c.Provider.Name == "hetzner" {
|
||||||
|
cfg.HetznerToken = c.Provider.Token
|
||||||
|
cfg.Location = c.Server.Location
|
||||||
|
cfg.ServerType = c.Server.Type
|
||||||
|
} else {
|
||||||
|
cfg.DOToken = c.Provider.Token
|
||||||
|
cfg.Region = c.Server.Region
|
||||||
|
cfg.DropletSize = c.Server.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tailscale
|
||||||
|
if c.Tailscale != nil {
|
||||||
|
cfg.EnableTailscale = c.Tailscale.Enabled
|
||||||
|
cfg.TailscaleAuthKey = c.Tailscale.AuthKey
|
||||||
|
cfg.TailnetDomain = c.Tailscale.Tailnet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord
|
||||||
|
if c.Discord != nil {
|
||||||
|
cfg.EnableDiscord = c.Discord.Enabled
|
||||||
|
cfg.DiscordBotToken = c.Discord.BotToken
|
||||||
|
cfg.DiscordServerID = c.Discord.ServerID
|
||||||
|
cfg.DiscordUserIDs = c.Discord.UserIDs
|
||||||
|
cfg.DiscordHomeChannel = c.Discord.HomeChannel
|
||||||
|
cfg.DiscordAutoThread = c.Discord.AutoThread
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hermes-specific
|
||||||
|
if c.Hermes != nil {
|
||||||
|
cfg.DockerEnabled = c.Hermes.DockerEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenClaw-specific
|
||||||
|
if c.OpenClaw != nil {
|
||||||
|
cfg.OpenClawVersion = c.OpenClaw.Version
|
||||||
|
cfg.NodeVersion = c.OpenClaw.NodeVersion
|
||||||
|
cfg.EnableSwap = c.OpenClaw.EnableSwap
|
||||||
|
cfg.SwapSizeGB = c.OpenClaw.SwapSizeGB
|
||||||
|
cfg.EnableFail2ban = c.OpenClaw.EnableFail2ban
|
||||||
|
cfg.EnableUnattendedUpgrades = c.OpenClaw.EnableUnattendedUpgrades
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway
|
||||||
|
if c.Gateway != nil {
|
||||||
|
cfg.GatewayToken = c.Gateway.Token
|
||||||
|
cfg.GatewayAllowedUsers = c.Gateway.AllowedUsers
|
||||||
|
cfg.GatewayAllowAllUsers = c.Gateway.AllowAll
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integrations
|
||||||
|
if c.Integrations != nil {
|
||||||
|
cfg.BraveSearchAPIKey = c.Integrations.BraveSearchAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback providers
|
||||||
|
for _, fb := range c.Inference.Fallbacks {
|
||||||
|
cfg.FallbackProviders = append(cfg.FallbackProviders, InferenceProviderConfig{
|
||||||
|
Provider: fb.Provider,
|
||||||
|
APIKey: fb.APIKey,
|
||||||
|
BaseURL: fb.BaseURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultModelForProvider returns the default model for a provider.
|
||||||
|
func defaultModelForProvider(provider string) string {
|
||||||
|
defaults := map[string]string{
|
||||||
|
"venice": "zai-org-glm-5",
|
||||||
|
"openrouter": "openai/gpt-4o",
|
||||||
|
"openai": "gpt-4o",
|
||||||
|
"anthropic": "claude-sonnet-4",
|
||||||
|
"custom": "",
|
||||||
|
}
|
||||||
|
return defaults[provider]
|
||||||
|
}
|
||||||
524
internal/config/yaml_test.go
Normal file
524
internal/config/yaml_test.go
Normal file
|
|
@ -0,0 +1,524 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadYAMLConfig(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid minimal config",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
server:
|
||||||
|
name: test-server
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: venice-api-key-123
|
||||||
|
`,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid full config",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
server:
|
||||||
|
name: test-server
|
||||||
|
agent_name: my-agent
|
||||||
|
timezone: America/New_York
|
||||||
|
location: fsn1
|
||||||
|
type: cpx31
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: venice-api-key-123
|
||||||
|
primary_model: zai-org-glm-5
|
||||||
|
fallback_models:
|
||||||
|
- openai/gpt-4o
|
||||||
|
tailscale:
|
||||||
|
enabled: true
|
||||||
|
auth_key: tskey-123
|
||||||
|
tailnet: mytailnet
|
||||||
|
discord:
|
||||||
|
enabled: true
|
||||||
|
bot_token: discord-token-123
|
||||||
|
server_id: "123456789"
|
||||||
|
user_ids:
|
||||||
|
- "111222333"
|
||||||
|
hermes:
|
||||||
|
docker_enabled: true
|
||||||
|
`,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "digitalocean config",
|
||||||
|
content: `framework: openclaw
|
||||||
|
provider:
|
||||||
|
name: digitalocean
|
||||||
|
token: do-token-123
|
||||||
|
ssh:
|
||||||
|
fingerprints:
|
||||||
|
- "aa:bb:cc:dd:ee:ff"
|
||||||
|
server:
|
||||||
|
name: do-server
|
||||||
|
region: nyc3
|
||||||
|
size: s-2vcpu-4gb
|
||||||
|
inference:
|
||||||
|
provider: openai
|
||||||
|
api_key: sk-openai-123
|
||||||
|
primary_model: gpt-4o
|
||||||
|
`,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing framework",
|
||||||
|
content: `provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: key-123
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "framework is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid framework",
|
||||||
|
content: `framework: invalid-framework
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: key-123
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "invalid framework",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing provider name",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: key-123
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "provider.name is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing provider token",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: key-123
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "provider.token is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing ssh keys",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: key-123
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "ssh.names",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing inference api key",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "inference.api_key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom provider requires base url",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
inference:
|
||||||
|
provider: custom
|
||||||
|
api_key: key-123
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "base_url is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom provider with base url",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
inference:
|
||||||
|
provider: custom
|
||||||
|
api_key: key-123
|
||||||
|
base_url: https://api.custom.com/v1
|
||||||
|
`,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tailscale enabled but no auth key",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: key-123
|
||||||
|
tailscale:
|
||||||
|
enabled: true
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "tailscale.auth_key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "discord enabled but no bot token",
|
||||||
|
content: `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-ssh-key
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: key-123
|
||||||
|
discord:
|
||||||
|
enabled: true
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "discord.bot_token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpdir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadYAMLConfig(path)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("LoadYAMLConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.wantErr && tt.errMsg != "" {
|
||||||
|
if err == nil || !contains(err.Error(), tt.errMsg) {
|
||||||
|
t.Errorf("LoadYAMLConfig() error = %v, want error containing %q", err, tt.errMsg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantErr && cfg == nil {
|
||||||
|
t.Error("LoadYAMLConfig() returned nil config without error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLConfigToDeploymentConfig(t *testing.T) {
|
||||||
|
yamlContent := `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: htoken-123
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-key
|
||||||
|
server:
|
||||||
|
name: test-server
|
||||||
|
agent_name: my-agent
|
||||||
|
location: fsn1
|
||||||
|
type: cpx31
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: vkey-123
|
||||||
|
primary_model: zai-org-glm-5
|
||||||
|
fallback_models:
|
||||||
|
- openai/gpt-4o
|
||||||
|
tailscale:
|
||||||
|
enabled: true
|
||||||
|
auth_key: tskey-123
|
||||||
|
tailnet: mytailnet
|
||||||
|
discord:
|
||||||
|
enabled: true
|
||||||
|
bot_token: dtoken-123
|
||||||
|
server_id: "123456"
|
||||||
|
user_ids:
|
||||||
|
- "111222333"
|
||||||
|
hermes:
|
||||||
|
docker_enabled: true
|
||||||
|
`
|
||||||
|
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpdir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadYAMLConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAMLConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy := cfg.ToDeploymentConfig()
|
||||||
|
|
||||||
|
// Check provider
|
||||||
|
if deploy.CloudProvider != "hetzner" {
|
||||||
|
t.Errorf("CloudProvider = %q, want hetzner", deploy.CloudProvider)
|
||||||
|
}
|
||||||
|
if deploy.HetznerToken != "htoken-123" {
|
||||||
|
t.Errorf("HetznerToken = %q, want htoken-123", deploy.HetznerToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check framework
|
||||||
|
if deploy.Framework != "hermes" {
|
||||||
|
t.Errorf("Framework = %q, want hermes", deploy.Framework)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check server
|
||||||
|
if deploy.ServerName != "test-server" {
|
||||||
|
t.Errorf("ServerName = %q, want test-server", deploy.ServerName)
|
||||||
|
}
|
||||||
|
if deploy.Location != "fsn1" {
|
||||||
|
t.Errorf("Location = %q, want fsn1", deploy.Location)
|
||||||
|
}
|
||||||
|
if deploy.ServerType != "cpx31" {
|
||||||
|
t.Errorf("ServerType = %q, want cpx31", deploy.ServerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check inference
|
||||||
|
if deploy.InferenceProvider != "venice" {
|
||||||
|
t.Errorf("InferenceProvider = %q, want venice", deploy.InferenceProvider)
|
||||||
|
}
|
||||||
|
if deploy.InferenceAPIKey != "vkey-123" {
|
||||||
|
t.Errorf("InferenceAPIKey = %q, want vkey-123", deploy.InferenceAPIKey)
|
||||||
|
}
|
||||||
|
if deploy.PrimaryModel != "zai-org-glm-5" {
|
||||||
|
t.Errorf("PrimaryModel = %q, want zai-org-glm-5", deploy.PrimaryModel)
|
||||||
|
}
|
||||||
|
if len(deploy.FallbackModels) != 1 || deploy.FallbackModels[0] != "openai/gpt-4o" {
|
||||||
|
t.Errorf("FallbackModels = %v, want [openai/gpt-4o]", deploy.FallbackModels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tailscale
|
||||||
|
if !deploy.EnableTailscale {
|
||||||
|
t.Error("EnableTailscale = false, want true")
|
||||||
|
}
|
||||||
|
if deploy.TailscaleAuthKey != "tskey-123" {
|
||||||
|
t.Errorf("TailscaleAuthKey = %q, want tskey-123", deploy.TailscaleAuthKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Discord
|
||||||
|
if !deploy.EnableDiscord {
|
||||||
|
t.Error("EnableDiscord = false, want true")
|
||||||
|
}
|
||||||
|
if deploy.DiscordBotToken != "dtoken-123" {
|
||||||
|
t.Errorf("DiscordBotToken = %q, want dtoken-123", deploy.DiscordBotToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Hermes
|
||||||
|
if !deploy.DockerEnabled {
|
||||||
|
t.Error("DockerEnabled = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLConfigDefaults(t *testing.T) {
|
||||||
|
// Test minimal config with defaults
|
||||||
|
yamlContent := `framework: hermes
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-key
|
||||||
|
inference:
|
||||||
|
provider: venice
|
||||||
|
api_key: key-123
|
||||||
|
`
|
||||||
|
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpdir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadYAMLConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAMLConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check defaults
|
||||||
|
if cfg.Server.Name != "agent-gateway" {
|
||||||
|
t.Errorf("Server.Name default = %q, want agent-gateway", cfg.Server.Name)
|
||||||
|
}
|
||||||
|
if cfg.Server.Timezone != "UTC" {
|
||||||
|
t.Errorf("Server.Timezone default = %q, want UTC", cfg.Server.Timezone)
|
||||||
|
}
|
||||||
|
if cfg.Server.Location != "ash" {
|
||||||
|
t.Errorf("Server.Location default = %q, want ash (Hetzner default)", cfg.Server.Location)
|
||||||
|
}
|
||||||
|
if cfg.Server.Type != "cpx21" {
|
||||||
|
t.Errorf("Server.Type default = %q, want cpx21 (Hetzner default)", cfg.Server.Type)
|
||||||
|
}
|
||||||
|
if cfg.Inference.PrimaryModel != "zai-org-glm-5" {
|
||||||
|
t.Errorf("Inference.PrimaryModel default = %q, want zai-org-glm-5 (Venice default)", cfg.Inference.PrimaryModel)
|
||||||
|
}
|
||||||
|
if cfg.Server.AgentName != "hermes" {
|
||||||
|
t.Errorf("Server.AgentName default = %q, want hermes (framework default)", cfg.Server.AgentName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigitalOceanConfig(t *testing.T) {
|
||||||
|
yamlContent := `framework: openclaw
|
||||||
|
provider:
|
||||||
|
name: digitalocean
|
||||||
|
token: do-token-123
|
||||||
|
ssh:
|
||||||
|
fingerprints:
|
||||||
|
- "aa:bb:cc:dd:ee:ff"
|
||||||
|
server:
|
||||||
|
name: do-server
|
||||||
|
region: sgp1
|
||||||
|
size: s-4vcpu-8gb
|
||||||
|
inference:
|
||||||
|
provider: openai
|
||||||
|
api_key: sk-123
|
||||||
|
primary_model: gpt-4o
|
||||||
|
`
|
||||||
|
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpdir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadYAMLConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAMLConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy := cfg.ToDeploymentConfig()
|
||||||
|
|
||||||
|
if deploy.CloudProvider != "digitalocean" {
|
||||||
|
t.Errorf("CloudProvider = %q, want digitalocean", deploy.CloudProvider)
|
||||||
|
}
|
||||||
|
if deploy.DOToken != "do-token-123" {
|
||||||
|
t.Errorf("DOToken = %q, want do-token-123", deploy.DOToken)
|
||||||
|
}
|
||||||
|
if deploy.Region != "sgp1" {
|
||||||
|
t.Errorf("Region = %q, want sgp1", deploy.Region)
|
||||||
|
}
|
||||||
|
if deploy.DropletSize != "s-4vcpu-8gb" {
|
||||||
|
t.Errorf("DropletSize = %q, want s-4vcpu-8gb", deploy.DropletSize)
|
||||||
|
}
|
||||||
|
if len(deploy.SSHKeyFingerprints) != 1 {
|
||||||
|
t.Errorf("SSHKeyFingerprints = %v, want 1 element", deploy.SSHKeyFingerprints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenClawDefaults(t *testing.T) {
|
||||||
|
yamlContent := `framework: openclaw
|
||||||
|
provider:
|
||||||
|
name: hetzner
|
||||||
|
token: test-token
|
||||||
|
ssh:
|
||||||
|
names:
|
||||||
|
- my-key
|
||||||
|
inference:
|
||||||
|
provider: openai
|
||||||
|
api_key: sk-123
|
||||||
|
`
|
||||||
|
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpdir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadYAMLConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAMLConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenClaw defaults
|
||||||
|
if cfg.OpenClaw == nil {
|
||||||
|
t.Fatal("OpenClaw config is nil")
|
||||||
|
}
|
||||||
|
if cfg.OpenClaw.Version != "lts" {
|
||||||
|
t.Errorf("OpenClaw.Version default = %q, want lts", cfg.OpenClaw.Version)
|
||||||
|
}
|
||||||
|
if cfg.OpenClaw.NodeVersion != "22" {
|
||||||
|
t.Errorf("OpenClaw.NodeVersion default = %q, want 22", cfg.OpenClaw.NodeVersion)
|
||||||
|
}
|
||||||
|
if !cfg.OpenClaw.EnableSwap {
|
||||||
|
t.Error("OpenClaw.EnableSwap default = false, want true")
|
||||||
|
}
|
||||||
|
if cfg.OpenClaw.SwapSizeGB != 2 {
|
||||||
|
t.Errorf("OpenClaw.SwapSizeGB default = %d, want 2", cfg.OpenClaw.SwapSizeGB)
|
||||||
|
}
|
||||||
|
if !cfg.OpenClaw.EnableFail2ban {
|
||||||
|
t.Error("OpenClaw.EnableFail2ban default = false, want true")
|
||||||
|
}
|
||||||
|
if !cfg.OpenClaw.EnableUnattendedUpgrades {
|
||||||
|
t.Error("OpenClaw.EnableUnattendedUpgrades default = false, want true")
|
||||||
|
}
|
||||||
|
if cfg.Server.AgentName != "openclaw" {
|
||||||
|
t.Errorf("Server.AgentName default = %q, want openclaw", cfg.Server.AgentName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/openboatmobile/obm/internal/config"
|
"github.com/openboatmobile/obm/internal/config"
|
||||||
|
|
@ -12,6 +13,33 @@ import (
|
||||||
func Run() error {
|
func Run() error {
|
||||||
cfg := &config.DeploymentConfig{}
|
cfg := &config.DeploymentConfig{}
|
||||||
|
|
||||||
|
// Interactive mode - use prompt package for user input
|
||||||
|
return runInteractive(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunFromFile executes deployment from a YAML config file (non-interactive mode).
|
||||||
|
// This is designed for CI/CD pipelines where all configuration is predefined.
|
||||||
|
func RunFromFile(configPath string) error {
|
||||||
|
cfg, err := config.LoadYAMLConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deployCfg := cfg.ToDeploymentConfig()
|
||||||
|
|
||||||
|
// Non-interactive mode - write config and proceed
|
||||||
|
return writeConfig(deployCfg, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithConfig executes deployment with a pre-built DeploymentConfig.
|
||||||
|
// This is useful for programmatic usage where config is constructed elsewhere.
|
||||||
|
func RunWithConfig(cfg *config.DeploymentConfig) error {
|
||||||
|
return writeConfig(cfg, "<config>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInteractive handles the interactive wizard flow.
|
||||||
|
func runInteractive(cfg *config.DeploymentConfig) error {
|
||||||
|
|
||||||
prompt.Header("🚢 OpenBoatmobile — Deploy your AI agent")
|
prompt.Header("🚢 OpenBoatmobile — Deploy your AI agent")
|
||||||
|
|
||||||
stepFramework(cfg)
|
stepFramework(cfg)
|
||||||
|
|
@ -290,10 +318,11 @@ func stepSummaryAndWrite(cfg *config.DeploymentConfig) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build .env content
|
// Write the config
|
||||||
envContent := buildEnvFile(cfg)
|
if err := writeConfig(cfg, ".env"); err != nil {
|
||||||
fmt.Print(envContent)
|
prompt.Error(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
prompt.Success(".env file written")
|
prompt.Success(".env file written")
|
||||||
|
|
||||||
if prompt.Confirm("Run terraform init && terraform apply?", false) {
|
if prompt.Confirm("Run terraform init && terraform apply?", false) {
|
||||||
|
|
@ -302,6 +331,26 @@ func stepSummaryAndWrite(cfg *config.DeploymentConfig) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeConfig writes the deployment configuration to a .env file.
|
||||||
|
// For non-interactive mode, sourceFile is used for error messages.
|
||||||
|
func writeConfig(cfg *config.DeploymentConfig, sourceFile string) error {
|
||||||
|
envContent := buildEnvFile(cfg)
|
||||||
|
|
||||||
|
// Write to .env file
|
||||||
|
outputPath := ".env"
|
||||||
|
if err := os.WriteFile(outputPath, []byte(envContent), 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", outputPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary for non-interactive mode
|
||||||
|
if sourceFile != ".env" && sourceFile != "<config>" {
|
||||||
|
fmt.Printf("Configuration loaded from: %s\n", sourceFile)
|
||||||
|
}
|
||||||
|
fmt.Printf("Configuration written to: %s\n", outputPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
||||||
func defaultModel(provider string) string {
|
func defaultModel(provider string) string {
|
||||||
|
|
|
||||||
118
scripts/release.sh
Executable file
118
scripts/release.sh
Executable file
|
|
@ -0,0 +1,118 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Release automation script for obm CLI
|
||||||
|
# Usage: ./scripts/release.sh <version>
|
||||||
|
# Example: ./scripts/release.sh v0.2.0
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
VERSION_FILE="$PROJECT_ROOT/VERSION"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate version argument
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
log_error "Version argument required"
|
||||||
|
echo "Usage: $0 <version>"
|
||||||
|
echo "Example: $0 v0.2.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_VERSION="$1"
|
||||||
|
|
||||||
|
# Strip 'v' prefix if present
|
||||||
|
VERSION_NUM="${NEW_VERSION#v}"
|
||||||
|
|
||||||
|
# Validate version format (semver)
|
||||||
|
if ! [[ "$VERSION_NUM" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||||
|
log_error "Invalid version format: $NEW_VERSION"
|
||||||
|
echo "Expected format: vX.Y.Z or X.Y.Z (semver)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Check for uncommitted changes
|
||||||
|
if ! git diff-index --quiet HEAD --; then
|
||||||
|
log_error "Uncommitted changes detected. Please commit or stash first."
|
||||||
|
git status --short
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for unpushed commits
|
||||||
|
LOCAL=$(git rev-parse @)
|
||||||
|
REMOTE=$(git rev-parse @{u} 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ "$LOCAL" != "$REMOTE" ]; then
|
||||||
|
log_warn "Unpushed commits detected. Push before release?"
|
||||||
|
read -p "Push now? (y/N) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
git push
|
||||||
|
else
|
||||||
|
log_error "Please push changes before releasing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
log_info "Running tests..."
|
||||||
|
make test || {
|
||||||
|
log_error "Tests failed. Fix before releasing."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update VERSION file
|
||||||
|
log_info "Updating VERSION file to $VERSION_NUM"
|
||||||
|
echo "$VERSION_NUM" > "$VERSION_FILE"
|
||||||
|
|
||||||
|
# Update version in main.go
|
||||||
|
MAIN_GO="$PROJECT_ROOT/cmd/obm/main.go"
|
||||||
|
if [ -f "$MAIN_GO" ]; then
|
||||||
|
log_info "Updating version in main.go"
|
||||||
|
sed -i "s/^const version = \".*\"/const version = \"$VERSION_NUM\"/" "$MAIN_GO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Commit version bump
|
||||||
|
log_info "Committing version bump"
|
||||||
|
git add "$VERSION_FILE" "$MAIN_GO"
|
||||||
|
git commit -m "chore: release v$VERSION_NUM"
|
||||||
|
|
||||||
|
# Create and push tag
|
||||||
|
TAG="v$VERSION_NUM"
|
||||||
|
log_info "Creating tag $TAG"
|
||||||
|
git tag -a "$TAG" -m "Release $TAG"
|
||||||
|
|
||||||
|
log_info "Pushing tag to remote..."
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
log_info "Release process initiated!"
|
||||||
|
echo ""
|
||||||
|
echo "Version: $VERSION_NUM"
|
||||||
|
echo "Tag: $TAG"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. GitHub Actions will build and create the release automatically"
|
||||||
|
echo " 2. Monitor at: https://github.com/openboatmobile/obm/actions"
|
||||||
|
echo " 3. Draft release will be created at: https://github.com/openboatmobile/obm/releases"
|
||||||
|
echo ""
|
||||||
|
echo "To manually build binaries:"
|
||||||
|
echo " make cross-compile VERSION=$VERSION_NUM"
|
||||||
|
echo ""
|
||||||
Loading…
Add table
Add a link
Reference in a new issue