Compare commits
13 Commits
main
...
telackey/9
| Author | SHA1 | Date | |
|---|---|---|---|
| 578fd933a7 | |||
| 35d23ca858 | |||
| e06de5f4b7 | |||
| 6e64b60295 | |||
| 3205478878 | |||
| 916724f1e2 | |||
| 56b010f512 | |||
| 023a640252 | |||
| 09c9214e4c | |||
| 2f1cde16b7 | |||
| b449d88b6c | |||
| 07030044ec | |||
| 0a9d68f4e8 |
@ -39,7 +39,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Generate build version file"
|
||||
run: ./scripts/create_build_tag_file.sh
|
||||
- name: "Build local shiv package"
|
||||
|
||||
@ -35,7 +35,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Build local shiv package"
|
||||
id: build
|
||||
run: |
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Generate build version file"
|
||||
run: ./scripts/create_build_tag_file.sh
|
||||
- name: "Build local shiv package"
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Generate build version file"
|
||||
run: ./scripts/create_build_tag_file.sh
|
||||
- name: "Build local shiv package"
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Generate build version file"
|
||||
run: ./scripts/create_build_tag_file.sh
|
||||
- name: "Build local shiv package"
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Generate build version file"
|
||||
run: ./scripts/create_build_tag_file.sh
|
||||
- name: "Build local shiv package"
|
||||
|
||||
@ -35,7 +35,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Generate build version file"
|
||||
run: ./scripts/create_build_tag_file.sh
|
||||
- name: "Build local shiv package"
|
||||
|
||||
@ -35,7 +35,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Generate build version file"
|
||||
run: ./scripts/create_build_tag_file.sh
|
||||
- name: "Build local shiv package"
|
||||
|
||||
@ -32,7 +32,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Generate build version file"
|
||||
run: ./scripts/create_build_tag_file.sh
|
||||
- name: "Build local shiv package"
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
- name: "Print Python version"
|
||||
run: python3 --version
|
||||
- name: "Install shiv"
|
||||
run: pip install shiv==1.0.6
|
||||
run: pip install shiv
|
||||
- name: "Generate build version file"
|
||||
run: ./scripts/create_build_tag_file.sh
|
||||
- name: "Build local shiv package"
|
||||
|
||||
@ -1,151 +0,0 @@
|
||||
# Plan: Make Stack-Orchestrator AI-Friendly
|
||||
|
||||
## Goal
|
||||
|
||||
Make the stack-orchestrator repository easier for AI tools (Claude Code, Cursor, Copilot) to understand and use for generating stacks, including adding a `create-stack` command.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Documentation & Context Files
|
||||
|
||||
### 1.1 Add CLAUDE.md
|
||||
|
||||
Create a root-level context file for AI assistants.
|
||||
|
||||
**File:** `CLAUDE.md`
|
||||
|
||||
Contents:
|
||||
- Project overview (what stack-orchestrator does)
|
||||
- Stack creation workflow (step-by-step)
|
||||
- File naming conventions
|
||||
- Required vs optional fields in stack.yml
|
||||
- Common patterns and anti-patterns
|
||||
- Links to example stacks (simple, medium, complex)
|
||||
|
||||
### 1.2 Add JSON Schema for stack.yml
|
||||
|
||||
Create formal validation schema.
|
||||
|
||||
**File:** `schemas/stack-schema.json`
|
||||
|
||||
Benefits:
|
||||
- AI tools can validate generated stacks
|
||||
- IDEs provide autocomplete
|
||||
- CI can catch errors early
|
||||
|
||||
### 1.3 Add Template Stack with Comments
|
||||
|
||||
Create an annotated template for reference.
|
||||
|
||||
**File:** `stack_orchestrator/data/stacks/_template/stack.yml`
|
||||
|
||||
```yaml
|
||||
# Stack definition template - copy this directory to create a new stack
|
||||
version: "1.2" # Required: 1.0, 1.1, or 1.2
|
||||
name: my-stack # Required: lowercase, hyphens only
|
||||
description: "Human-readable description" # Optional
|
||||
repos: # Git repositories to clone
|
||||
- github.com/org/repo
|
||||
containers: # Container images to build (must have matching container-build/)
|
||||
- cerc/my-container
|
||||
pods: # Deployment units (must have matching docker-compose-{pod}.yml)
|
||||
- my-pod
|
||||
```
|
||||
|
||||
### 1.4 Document Validation Rules
|
||||
|
||||
Create explicit documentation of constraints currently scattered in code.
|
||||
|
||||
**File:** `docs/stack-format.md`
|
||||
|
||||
Contents:
|
||||
- Container names must start with `cerc/`
|
||||
- Pod names must match compose file: `docker-compose-{pod}.yml`
|
||||
- Repository format: `host/org/repo[@ref]`
|
||||
- Stack directory name should match `name` field
|
||||
- Version field options and differences
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Add `create-stack` Command
|
||||
|
||||
### 2.1 Command Overview
|
||||
|
||||
```bash
|
||||
laconic-so create-stack --repo github.com/org/my-app [--name my-app] [--type webapp]
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
1. Parse repo URL to extract app name (if --name not provided)
|
||||
2. Create `stacks/{name}/stack.yml`
|
||||
3. Create `container-build/cerc-{name}/Dockerfile` and `build.sh`
|
||||
4. Create `compose/docker-compose-{name}.yml`
|
||||
5. Update list files (repository-list.txt, container-image-list.txt, pod-list.txt)
|
||||
|
||||
### 2.2 Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `stack_orchestrator/create/__init__.py` | Package init |
|
||||
| `stack_orchestrator/create/create_stack.py` | Command implementation |
|
||||
|
||||
### 2.3 Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `stack_orchestrator/main.py` | Add import and `cli.add_command()` |
|
||||
|
||||
### 2.4 Command Options
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--repo` | Yes | Git repository URL (e.g., github.com/org/repo) |
|
||||
| `--name` | No | Stack name (defaults to repo name) |
|
||||
| `--type` | No | Template type: webapp, service, empty (default: webapp) |
|
||||
| `--force` | No | Overwrite existing files |
|
||||
|
||||
### 2.5 Template Types
|
||||
|
||||
| Type | Base Image | Port | Use Case |
|
||||
|------|------------|------|----------|
|
||||
| webapp | node:20-bullseye-slim | 3000 | React/Vue/Next.js apps |
|
||||
| service | python:3.11-slim | 8080 | Python backend services |
|
||||
| empty | none | none | Custom from scratch |
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Implementation Summary
|
||||
|
||||
### New Files (6)
|
||||
|
||||
1. `CLAUDE.md` - AI assistant context
|
||||
2. `schemas/stack-schema.json` - Validation schema
|
||||
3. `stack_orchestrator/data/stacks/_template/stack.yml` - Annotated template
|
||||
4. `docs/stack-format.md` - Stack format documentation
|
||||
5. `stack_orchestrator/create/__init__.py` - Package init
|
||||
6. `stack_orchestrator/create/create_stack.py` - Command implementation
|
||||
|
||||
### Modified Files (1)
|
||||
|
||||
1. `stack_orchestrator/main.py` - Register create-stack command
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# 1. Command appears in help
|
||||
laconic-so --help | grep create-stack
|
||||
|
||||
# 2. Dry run works
|
||||
laconic-so --dry-run create-stack --repo github.com/org/test-app
|
||||
|
||||
# 3. Creates all expected files
|
||||
laconic-so create-stack --repo github.com/org/test-app
|
||||
ls stack_orchestrator/data/stacks/test-app/
|
||||
ls stack_orchestrator/data/container-build/cerc-test-app/
|
||||
ls stack_orchestrator/data/compose/docker-compose-test-app.yml
|
||||
|
||||
# 4. Build works with generated stack
|
||||
laconic-so --stack test-app build-containers
|
||||
```
|
||||
@ -1,413 +0,0 @@
|
||||
# Implementing `laconic-so create-stack` Command
|
||||
|
||||
A plan for adding a new CLI command to scaffold stack files automatically.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add a `create-stack` command that generates all required files for a new stack:
|
||||
|
||||
```bash
|
||||
laconic-so create-stack --name my-stack --type webapp
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
stack_orchestrator/data/
|
||||
├── stacks/my-stack/stack.yml
|
||||
├── container-build/cerc-my-stack/
|
||||
│ ├── Dockerfile
|
||||
│ └── build.sh
|
||||
└── compose/docker-compose-my-stack.yml
|
||||
|
||||
Updated: repository-list.txt, container-image-list.txt, pod-list.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Architecture Summary
|
||||
|
||||
### Command Registration Pattern
|
||||
|
||||
Commands are Click functions registered in `main.py`:
|
||||
|
||||
```python
|
||||
# main.py (line ~70)
|
||||
from stack_orchestrator.create import create_stack
|
||||
cli.add_command(create_stack.command, "create-stack")
|
||||
```
|
||||
|
||||
### Global Options Access
|
||||
|
||||
```python
|
||||
from stack_orchestrator.opts import opts
|
||||
|
||||
if not opts.o.quiet:
|
||||
print("message")
|
||||
if opts.o.dry_run:
|
||||
print("(would create files)")
|
||||
```
|
||||
|
||||
### Key Utilities
|
||||
|
||||
| Function | Location | Purpose |
|
||||
|----------|----------|---------|
|
||||
| `get_yaml()` | `util.py` | YAML parser (ruamel.yaml) |
|
||||
| `get_stack_path(stack)` | `util.py` | Resolve stack directory path |
|
||||
| `error_exit(msg)` | `util.py` | Print error and exit(1) |
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. Command Module
|
||||
|
||||
**`stack_orchestrator/create/__init__.py`**
|
||||
```python
|
||||
# Empty file to make this a package
|
||||
```
|
||||
|
||||
**`stack_orchestrator/create/create_stack.py`**
|
||||
```python
|
||||
import click
|
||||
import os
|
||||
from pathlib import Path
|
||||
from shutil import copy
|
||||
from stack_orchestrator.opts import opts
|
||||
from stack_orchestrator.util import error_exit, get_yaml
|
||||
|
||||
# Template types
|
||||
STACK_TEMPLATES = {
|
||||
"webapp": {
|
||||
"description": "Web application with Node.js",
|
||||
"base_image": "node:20-bullseye-slim",
|
||||
"port": 3000,
|
||||
},
|
||||
"service": {
|
||||
"description": "Backend service",
|
||||
"base_image": "python:3.11-slim",
|
||||
"port": 8080,
|
||||
},
|
||||
"empty": {
|
||||
"description": "Minimal stack with no defaults",
|
||||
"base_image": None,
|
||||
"port": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Get path to stack_orchestrator/data directory"""
|
||||
return Path(__file__).absolute().parent.parent.joinpath("data")
|
||||
|
||||
|
||||
def validate_stack_name(name: str) -> None:
|
||||
"""Validate stack name follows conventions"""
|
||||
import re
|
||||
if not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', name) and len(name) > 2:
|
||||
error_exit(f"Invalid stack name '{name}'. Use lowercase alphanumeric with hyphens.")
|
||||
if name.startswith("cerc-"):
|
||||
error_exit("Stack name should not start with 'cerc-' (container names will add this prefix)")
|
||||
|
||||
|
||||
def create_stack_yml(stack_dir: Path, name: str, template: dict, repo_url: str) -> None:
|
||||
"""Create stack.yml file"""
|
||||
config = {
|
||||
"version": "1.2",
|
||||
"name": name,
|
||||
"description": template.get("description", f"Stack: {name}"),
|
||||
"repos": [repo_url] if repo_url else [],
|
||||
"containers": [f"cerc/{name}"],
|
||||
"pods": [name],
|
||||
}
|
||||
|
||||
stack_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(stack_dir / "stack.yml", "w") as f:
|
||||
get_yaml().dump(config, f)
|
||||
|
||||
|
||||
def create_dockerfile(container_dir: Path, name: str, template: dict) -> None:
|
||||
"""Create Dockerfile"""
|
||||
base_image = template.get("base_image", "node:20-bullseye-slim")
|
||||
port = template.get("port", 3000)
|
||||
|
||||
dockerfile_content = f'''# Build stage
|
||||
FROM {base_image} AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM {base_image}
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE {port}
|
||||
CMD ["npm", "run", "start"]
|
||||
'''
|
||||
|
||||
container_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(container_dir / "Dockerfile", "w") as f:
|
||||
f.write(dockerfile_content)
|
||||
|
||||
|
||||
def create_build_script(container_dir: Path, name: str) -> None:
|
||||
"""Create build.sh script"""
|
||||
build_script = f'''#!/usr/bin/env bash
|
||||
# Build cerc/{name}
|
||||
|
||||
source ${{CERC_CONTAINER_BASE_DIR}}/build-base.sh
|
||||
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${{BASH_SOURCE[0]}}" )" &> /dev/null && pwd )
|
||||
|
||||
docker build -t cerc/{name}:local \\
|
||||
-f ${{SCRIPT_DIR}}/Dockerfile \\
|
||||
${{build_command_args}} \\
|
||||
${{CERC_REPO_BASE_DIR}}/{name}
|
||||
'''
|
||||
|
||||
build_path = container_dir / "build.sh"
|
||||
with open(build_path, "w") as f:
|
||||
f.write(build_script)
|
||||
|
||||
# Make executable
|
||||
os.chmod(build_path, 0o755)
|
||||
|
||||
|
||||
def create_compose_file(compose_dir: Path, name: str, template: dict) -> None:
|
||||
"""Create docker-compose file"""
|
||||
port = template.get("port", 3000)
|
||||
|
||||
compose_content = {
|
||||
"version": "3.8",
|
||||
"services": {
|
||||
name: {
|
||||
"image": f"cerc/{name}:local",
|
||||
"restart": "unless-stopped",
|
||||
"ports": [f"${{HOST_PORT:-{port}}}:{port}"],
|
||||
"environment": {
|
||||
"NODE_ENV": "${NODE_ENV:-production}",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with open(compose_dir / f"docker-compose-{name}.yml", "w") as f:
|
||||
get_yaml().dump(compose_content, f)
|
||||
|
||||
|
||||
def update_list_file(data_dir: Path, filename: str, entry: str) -> None:
|
||||
"""Add entry to a list file if not already present"""
|
||||
list_path = data_dir / filename
|
||||
|
||||
# Read existing entries
|
||||
existing = set()
|
||||
if list_path.exists():
|
||||
with open(list_path, "r") as f:
|
||||
existing = set(line.strip() for line in f if line.strip())
|
||||
|
||||
# Add new entry
|
||||
if entry not in existing:
|
||||
with open(list_path, "a") as f:
|
||||
f.write(f"{entry}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--name", required=True, help="Name of the new stack (lowercase, hyphens)")
|
||||
@click.option("--type", "stack_type", default="webapp",
|
||||
type=click.Choice(list(STACK_TEMPLATES.keys())),
|
||||
help="Stack template type")
|
||||
@click.option("--repo", help="Git repository URL (e.g., github.com/org/repo)")
|
||||
@click.option("--force", is_flag=True, help="Overwrite existing files")
|
||||
@click.pass_context
|
||||
def command(ctx, name: str, stack_type: str, repo: str, force: bool):
|
||||
"""Create a new stack with all required files.
|
||||
|
||||
Examples:
|
||||
|
||||
laconic-so create-stack --name my-app --type webapp
|
||||
|
||||
laconic-so create-stack --name my-service --type service --repo github.com/org/repo
|
||||
"""
|
||||
# Validate
|
||||
validate_stack_name(name)
|
||||
|
||||
template = STACK_TEMPLATES[stack_type]
|
||||
data_dir = get_data_dir()
|
||||
|
||||
# Define paths
|
||||
stack_dir = data_dir / "stacks" / name
|
||||
container_dir = data_dir / "container-build" / f"cerc-{name}"
|
||||
compose_dir = data_dir / "compose"
|
||||
|
||||
# Check for existing files
|
||||
if not force:
|
||||
if stack_dir.exists():
|
||||
error_exit(f"Stack already exists: {stack_dir}\nUse --force to overwrite")
|
||||
if container_dir.exists():
|
||||
error_exit(f"Container build dir exists: {container_dir}\nUse --force to overwrite")
|
||||
|
||||
# Dry run check
|
||||
if opts.o.dry_run:
|
||||
print(f"Would create stack '{name}' with template '{stack_type}':")
|
||||
print(f" - {stack_dir}/stack.yml")
|
||||
print(f" - {container_dir}/Dockerfile")
|
||||
print(f" - {container_dir}/build.sh")
|
||||
print(f" - {compose_dir}/docker-compose-{name}.yml")
|
||||
print(f" - Update repository-list.txt")
|
||||
print(f" - Update container-image-list.txt")
|
||||
print(f" - Update pod-list.txt")
|
||||
return
|
||||
|
||||
# Create files
|
||||
if not opts.o.quiet:
|
||||
print(f"Creating stack '{name}' with template '{stack_type}'...")
|
||||
|
||||
create_stack_yml(stack_dir, name, template, repo)
|
||||
if opts.o.verbose:
|
||||
print(f" Created {stack_dir}/stack.yml")
|
||||
|
||||
create_dockerfile(container_dir, name, template)
|
||||
if opts.o.verbose:
|
||||
print(f" Created {container_dir}/Dockerfile")
|
||||
|
||||
create_build_script(container_dir, name)
|
||||
if opts.o.verbose:
|
||||
print(f" Created {container_dir}/build.sh")
|
||||
|
||||
create_compose_file(compose_dir, name, template)
|
||||
if opts.o.verbose:
|
||||
print(f" Created {compose_dir}/docker-compose-{name}.yml")
|
||||
|
||||
# Update list files
|
||||
if repo:
|
||||
update_list_file(data_dir, "repository-list.txt", repo)
|
||||
if opts.o.verbose:
|
||||
print(f" Added {repo} to repository-list.txt")
|
||||
|
||||
update_list_file(data_dir, "container-image-list.txt", f"cerc/{name}")
|
||||
if opts.o.verbose:
|
||||
print(f" Added cerc/{name} to container-image-list.txt")
|
||||
|
||||
update_list_file(data_dir, "pod-list.txt", name)
|
||||
if opts.o.verbose:
|
||||
print(f" Added {name} to pod-list.txt")
|
||||
|
||||
# Summary
|
||||
if not opts.o.quiet:
|
||||
print(f"\nStack '{name}' created successfully!")
|
||||
print(f"\nNext steps:")
|
||||
print(f" 1. Edit {stack_dir}/stack.yml")
|
||||
print(f" 2. Customize {container_dir}/Dockerfile")
|
||||
print(f" 3. Run: laconic-so --stack {name} build-containers")
|
||||
print(f" 4. Run: laconic-so --stack {name} deploy-system up")
|
||||
```
|
||||
|
||||
### 2. Register Command in main.py
|
||||
|
||||
**Edit `stack_orchestrator/main.py`**
|
||||
|
||||
Add import:
|
||||
```python
|
||||
from stack_orchestrator.create import create_stack
|
||||
```
|
||||
|
||||
Add command registration (after line ~78):
|
||||
```python
|
||||
cli.add_command(create_stack.command, "create-stack")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create module structure
|
||||
```bash
|
||||
mkdir -p stack_orchestrator/create
|
||||
touch stack_orchestrator/create/__init__.py
|
||||
```
|
||||
|
||||
### Step 2: Create the command file
|
||||
Create `stack_orchestrator/create/create_stack.py` with the code above.
|
||||
|
||||
### Step 3: Register in main.py
|
||||
Add the import and `cli.add_command()` line.
|
||||
|
||||
### Step 4: Test the command
|
||||
```bash
|
||||
# Show help
|
||||
laconic-so create-stack --help
|
||||
|
||||
# Dry run
|
||||
laconic-so --dry-run create-stack --name test-app --type webapp
|
||||
|
||||
# Create a stack
|
||||
laconic-so create-stack --name test-app --type webapp --repo github.com/org/test-app
|
||||
|
||||
# Verify
|
||||
ls -la stack_orchestrator/data/stacks/test-app/
|
||||
cat stack_orchestrator/data/stacks/test-app/stack.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template Types
|
||||
|
||||
| Type | Base Image | Port | Use Case |
|
||||
|------|------------|------|----------|
|
||||
| `webapp` | node:20-bullseye-slim | 3000 | React/Vue/Next.js apps |
|
||||
| `service` | python:3.11-slim | 8080 | Python backend services |
|
||||
| `empty` | none | none | Custom from scratch |
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Interactive mode** - Prompt for values if not provided
|
||||
2. **More templates** - Go, Rust, database stacks
|
||||
3. **Template from existing** - `--from-stack existing-stack`
|
||||
4. **External stack support** - Create in custom directory
|
||||
5. **Validation command** - `laconic-so validate-stack --name my-stack`
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `stack_orchestrator/create/__init__.py` | New (empty) |
|
||||
| `stack_orchestrator/create/create_stack.py` | New (command implementation) |
|
||||
| `stack_orchestrator/main.py` | Add import and `cli.add_command()` |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# 1. Command appears in help
|
||||
laconic-so --help | grep create-stack
|
||||
|
||||
# 2. Dry run works
|
||||
laconic-so --dry-run create-stack --name verify-test --type webapp
|
||||
|
||||
# 3. Full creation works
|
||||
laconic-so create-stack --name verify-test --type webapp
|
||||
ls stack_orchestrator/data/stacks/verify-test/
|
||||
ls stack_orchestrator/data/container-build/cerc-verify-test/
|
||||
ls stack_orchestrator/data/compose/docker-compose-verify-test.yml
|
||||
|
||||
# 4. Build works
|
||||
laconic-so --stack verify-test build-containers
|
||||
|
||||
# 5. Cleanup
|
||||
rm -rf stack_orchestrator/data/stacks/verify-test
|
||||
rm -rf stack_orchestrator/data/container-build/cerc-verify-test
|
||||
rm stack_orchestrator/data/compose/docker-compose-verify-test.yml
|
||||
```
|
||||
@ -1,550 +0,0 @@
|
||||
# Docker Compose Deployment Guide
|
||||
|
||||
## Introduction
|
||||
|
||||
### What is a Deployer?
|
||||
|
||||
In stack-orchestrator, a **deployer** provides a uniform interface for orchestrating containerized applications. This guide focuses on Docker Compose deployments, which is the default and recommended deployment mode.
|
||||
|
||||
While stack-orchestrator also supports Kubernetes (`k8s`) and Kind (`k8s-kind`) deployments, those are out of scope for this guide. See the [Kubernetes Enhancements](./k8s-deployment-enhancements.md) documentation for advanced deployment options.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
To deploy stacks using Docker Compose, you need:
|
||||
|
||||
- Docker Engine (20.10+)
|
||||
- Docker Compose plugin (v2.0+)
|
||||
- Python 3.8+
|
||||
- stack-orchestrator installed (`laconic-so`)
|
||||
|
||||
**That's it!** No additional infrastructure is required. If you have Docker installed, you're ready to deploy.
|
||||
|
||||
## Deployment Workflow
|
||||
|
||||
The typical deployment workflow consists of four main steps:
|
||||
|
||||
1. **Setup repositories and build containers** (first time only)
|
||||
2. **Initialize deployment specification**
|
||||
3. **Create deployment directory**
|
||||
4. **Start and manage services**
|
||||
|
||||
## Quick Start Example
|
||||
|
||||
Here's a complete example using the built-in `test` stack:
|
||||
|
||||
```bash
|
||||
# Step 1: Setup (first time only)
|
||||
laconic-so --stack test setup-repositories
|
||||
laconic-so --stack test build-containers
|
||||
|
||||
# Step 2: Initialize deployment spec
|
||||
laconic-so --stack test deploy init --output test-spec.yml
|
||||
|
||||
# Step 3: Create deployment directory
|
||||
laconic-so --stack test deploy create \
|
||||
--spec-file test-spec.yml \
|
||||
--deployment-dir test-deployment
|
||||
|
||||
# Step 4: Start services
|
||||
laconic-so deployment --dir test-deployment start
|
||||
|
||||
# View running services
|
||||
laconic-so deployment --dir test-deployment ps
|
||||
|
||||
# View logs
|
||||
laconic-so deployment --dir test-deployment logs
|
||||
|
||||
# Stop services (preserves data)
|
||||
laconic-so deployment --dir test-deployment stop
|
||||
```
|
||||
|
||||
## Deployment Workflows
|
||||
|
||||
Stack-orchestrator supports two deployment workflows:
|
||||
|
||||
### 1. Deployment Directory Workflow (Recommended)
|
||||
|
||||
This workflow creates a persistent deployment directory that contains all configuration and data.
|
||||
|
||||
**When to use:**
|
||||
- Production deployments
|
||||
- When you need to preserve configuration
|
||||
- When you want to manage multiple deployments
|
||||
- When you need persistent volume data
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
# Initialize deployment spec
|
||||
laconic-so --stack fixturenet-eth deploy init --output eth-spec.yml
|
||||
|
||||
# Optionally edit eth-spec.yml to customize configuration
|
||||
|
||||
# Create deployment directory
|
||||
laconic-so --stack fixturenet-eth deploy create \
|
||||
--spec-file eth-spec.yml \
|
||||
--deployment-dir my-eth-deployment
|
||||
|
||||
# Start the deployment
|
||||
laconic-so deployment --dir my-eth-deployment start
|
||||
|
||||
# Manage the deployment
|
||||
laconic-so deployment --dir my-eth-deployment ps
|
||||
laconic-so deployment --dir my-eth-deployment logs
|
||||
laconic-so deployment --dir my-eth-deployment stop
|
||||
```
|
||||
|
||||
### 2. Quick Deploy Workflow
|
||||
|
||||
This workflow deploys directly without creating a persistent deployment directory.
|
||||
|
||||
**When to use:**
|
||||
- Quick testing
|
||||
- Temporary deployments
|
||||
- Simple stacks that don't require customization
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
# Start the stack directly
|
||||
laconic-so --stack test deploy up
|
||||
|
||||
# Check service status
|
||||
laconic-so --stack test deploy port test 80
|
||||
|
||||
# View logs
|
||||
laconic-so --stack test deploy logs
|
||||
|
||||
# Stop (preserves volumes)
|
||||
laconic-so --stack test deploy down
|
||||
|
||||
# Stop and remove volumes
|
||||
laconic-so --stack test deploy down --delete-volumes
|
||||
```
|
||||
|
||||
## Real-World Example: Ethereum Fixturenet
|
||||
|
||||
Deploy a local Ethereum testnet with Geth and Lighthouse:
|
||||
|
||||
```bash
|
||||
# Setup (first time only)
|
||||
laconic-so --stack fixturenet-eth setup-repositories
|
||||
laconic-so --stack fixturenet-eth build-containers
|
||||
|
||||
# Initialize with default configuration
|
||||
laconic-so --stack fixturenet-eth deploy init --output eth-spec.yml
|
||||
|
||||
# Create deployment
|
||||
laconic-so --stack fixturenet-eth deploy create \
|
||||
--spec-file eth-spec.yml \
|
||||
--deployment-dir fixturenet-eth-deployment
|
||||
|
||||
# Start the network
|
||||
laconic-so deployment --dir fixturenet-eth-deployment start
|
||||
|
||||
# Check status
|
||||
laconic-so deployment --dir fixturenet-eth-deployment ps
|
||||
|
||||
# Access logs from specific service
|
||||
laconic-so deployment --dir fixturenet-eth-deployment logs fixturenet-eth-geth-1
|
||||
|
||||
# Stop the network (preserves blockchain data)
|
||||
laconic-so deployment --dir fixturenet-eth-deployment stop
|
||||
|
||||
# Start again - blockchain data is preserved
|
||||
laconic-so deployment --dir fixturenet-eth-deployment start
|
||||
|
||||
# Clean up everything including data
|
||||
laconic-so deployment --dir fixturenet-eth-deployment stop --delete-volumes
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Passing Configuration Parameters
|
||||
|
||||
Configuration can be passed in three ways:
|
||||
|
||||
**1. At init time via `--config` flag:**
|
||||
|
||||
```bash
|
||||
laconic-so --stack test deploy init --output spec.yml \
|
||||
--config PARAM1=value1,PARAM2=value2
|
||||
```
|
||||
|
||||
**2. Edit the spec file after init:**
|
||||
|
||||
```bash
|
||||
# Initialize
|
||||
laconic-so --stack test deploy init --output spec.yml
|
||||
|
||||
# Edit spec.yml
|
||||
vim spec.yml
|
||||
```
|
||||
|
||||
Example spec.yml:
|
||||
```yaml
|
||||
stack: test
|
||||
config:
|
||||
PARAM1: value1
|
||||
PARAM2: value2
|
||||
```
|
||||
|
||||
**3. Docker Compose defaults:**
|
||||
|
||||
Environment variables defined in the stack's `docker-compose-*.yml` files are used as defaults. Configuration from the spec file overrides these defaults.
|
||||
|
||||
### Port Mapping
|
||||
|
||||
By default, services are accessible on randomly assigned host ports. To find the mapped port:
|
||||
|
||||
```bash
|
||||
# Find the host port for container port 80 on service 'webapp'
|
||||
laconic-so deployment --dir my-deployment port webapp 80
|
||||
|
||||
# Output example: 0.0.0.0:32768
|
||||
```
|
||||
|
||||
To configure fixed ports, edit the spec file before creating the deployment:
|
||||
|
||||
```yaml
|
||||
network:
|
||||
ports:
|
||||
webapp:
|
||||
- '8080:80' # Maps host port 8080 to container port 80
|
||||
api:
|
||||
- '3000:3000'
|
||||
```
|
||||
|
||||
Then create the deployment:
|
||||
|
||||
```bash
|
||||
laconic-so --stack my-stack deploy create \
|
||||
--spec-file spec.yml \
|
||||
--deployment-dir my-deployment
|
||||
```
|
||||
|
||||
### Volume Persistence
|
||||
|
||||
Volumes are preserved between stop/start cycles by default:
|
||||
|
||||
```bash
|
||||
# Stop but keep data
|
||||
laconic-so deployment --dir my-deployment stop
|
||||
|
||||
# Start again - data is still there
|
||||
laconic-so deployment --dir my-deployment start
|
||||
```
|
||||
|
||||
To completely remove all data:
|
||||
|
||||
```bash
|
||||
# Stop and delete all volumes
|
||||
laconic-so deployment --dir my-deployment stop --delete-volumes
|
||||
```
|
||||
|
||||
Volume data is stored in `<deployment-dir>/data/`.
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# All services, continuous follow
|
||||
laconic-so deployment --dir my-deployment logs --follow
|
||||
|
||||
# Last 100 lines from all services
|
||||
laconic-so deployment --dir my-deployment logs --tail 100
|
||||
|
||||
# Specific service only
|
||||
laconic-so deployment --dir my-deployment logs webapp
|
||||
|
||||
# Combine options
|
||||
laconic-so deployment --dir my-deployment logs --tail 50 --follow webapp
|
||||
```
|
||||
|
||||
### Executing Commands in Containers
|
||||
|
||||
```bash
|
||||
# Execute a command in a running service
|
||||
laconic-so deployment --dir my-deployment exec webapp ls -la
|
||||
|
||||
# Interactive shell
|
||||
laconic-so deployment --dir my-deployment exec webapp /bin/bash
|
||||
|
||||
# Run command with specific environment variables
|
||||
laconic-so deployment --dir my-deployment exec webapp env VAR=value command
|
||||
```
|
||||
|
||||
### Checking Service Status
|
||||
|
||||
```bash
|
||||
# List all running services
|
||||
laconic-so deployment --dir my-deployment ps
|
||||
|
||||
# Check using Docker directly
|
||||
docker ps
|
||||
```
|
||||
|
||||
### Updating a Running Deployment
|
||||
|
||||
If you need to change configuration after deployment:
|
||||
|
||||
```bash
|
||||
# 1. Edit the spec file
|
||||
vim my-deployment/spec.yml
|
||||
|
||||
# 2. Regenerate configuration
|
||||
laconic-so deployment --dir my-deployment update
|
||||
|
||||
# 3. Restart services to apply changes
|
||||
laconic-so deployment --dir my-deployment stop
|
||||
laconic-so deployment --dir my-deployment start
|
||||
```
|
||||
|
||||
## Multi-Service Deployments
|
||||
|
||||
Many stacks deploy multiple services that work together:
|
||||
|
||||
```bash
|
||||
# Deploy a stack with multiple services
|
||||
laconic-so --stack laconicd-with-console deploy init --output spec.yml
|
||||
laconic-so --stack laconicd-with-console deploy create \
|
||||
--spec-file spec.yml \
|
||||
--deployment-dir laconicd-deployment
|
||||
|
||||
laconic-so deployment --dir laconicd-deployment start
|
||||
|
||||
# View all services
|
||||
laconic-so deployment --dir laconicd-deployment ps
|
||||
|
||||
# View logs from specific services
|
||||
laconic-so deployment --dir laconicd-deployment logs laconicd
|
||||
laconic-so deployment --dir laconicd-deployment logs console
|
||||
```
|
||||
|
||||
## ConfigMaps
|
||||
|
||||
ConfigMaps allow you to mount configuration files into containers:
|
||||
|
||||
```bash
|
||||
# 1. Create the config directory in your deployment
|
||||
mkdir -p my-deployment/data/my-config
|
||||
echo "database_url=postgres://localhost" > my-deployment/data/my-config/app.conf
|
||||
|
||||
# 2. Reference in spec file
|
||||
vim my-deployment/spec.yml
|
||||
```
|
||||
|
||||
Add to spec.yml:
|
||||
```yaml
|
||||
configmaps:
|
||||
my-config: ./data/my-config
|
||||
```
|
||||
|
||||
```bash
|
||||
# 3. Restart to apply
|
||||
laconic-so deployment --dir my-deployment stop
|
||||
laconic-so deployment --dir my-deployment start
|
||||
```
|
||||
|
||||
The files will be mounted in the container at `/config/` (or as specified by the stack).
|
||||
|
||||
## Deployment Directory Structure
|
||||
|
||||
A typical deployment directory contains:
|
||||
|
||||
```
|
||||
my-deployment/
|
||||
├── compose/
|
||||
│ └── docker-compose-*.yml # Generated compose files
|
||||
├── config.env # Environment variables
|
||||
├── deployment.yml # Deployment metadata
|
||||
├── spec.yml # Deployment specification
|
||||
└── data/ # Volume mounts and configs
|
||||
├── service-data/ # Persistent service data
|
||||
└── config-maps/ # ConfigMap files
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Problem: "Cannot connect to Docker daemon"**
|
||||
|
||||
```bash
|
||||
# Ensure Docker is running
|
||||
docker ps
|
||||
|
||||
# Start Docker if needed (macOS)
|
||||
open -a Docker
|
||||
|
||||
# Start Docker (Linux)
|
||||
sudo systemctl start docker
|
||||
```
|
||||
|
||||
**Problem: "Port already in use"**
|
||||
|
||||
```bash
|
||||
# Either stop the conflicting service or use different ports
|
||||
# Edit spec.yml before creating deployment:
|
||||
|
||||
network:
|
||||
ports:
|
||||
webapp:
|
||||
- '8081:80' # Use 8081 instead of 8080
|
||||
```
|
||||
|
||||
**Problem: "Image not found"**
|
||||
|
||||
```bash
|
||||
# Build containers first
|
||||
laconic-so --stack your-stack build-containers
|
||||
```
|
||||
|
||||
**Problem: Volumes not persisting**
|
||||
|
||||
```bash
|
||||
# Check if you used --delete-volumes when stopping
|
||||
# Volume data is in: <deployment-dir>/data/
|
||||
|
||||
# Don't use --delete-volumes if you want to keep data:
|
||||
laconic-so deployment --dir my-deployment stop
|
||||
|
||||
# Only use --delete-volumes when you want to reset completely:
|
||||
laconic-so deployment --dir my-deployment stop --delete-volumes
|
||||
```
|
||||
|
||||
**Problem: Services not starting**
|
||||
|
||||
```bash
|
||||
# Check logs for errors
|
||||
laconic-so deployment --dir my-deployment logs
|
||||
|
||||
# Check Docker container status
|
||||
docker ps -a
|
||||
|
||||
# Try stopping and starting again
|
||||
laconic-so deployment --dir my-deployment stop
|
||||
laconic-so deployment --dir my-deployment start
|
||||
```
|
||||
|
||||
### Inspecting Deployment State
|
||||
|
||||
```bash
|
||||
# Check deployment directory structure
|
||||
ls -la my-deployment/
|
||||
|
||||
# Check running containers
|
||||
docker ps
|
||||
|
||||
# Check container details
|
||||
docker inspect <container-name>
|
||||
|
||||
# Check networks
|
||||
docker network ls
|
||||
|
||||
# Check volumes
|
||||
docker volume ls
|
||||
```
|
||||
|
||||
## CLI Commands Reference
|
||||
|
||||
### Stack Operations
|
||||
|
||||
```bash
|
||||
# Clone required repositories
|
||||
laconic-so --stack <name> setup-repositories
|
||||
|
||||
# Build container images
|
||||
laconic-so --stack <name> build-containers
|
||||
```
|
||||
|
||||
### Deployment Initialization
|
||||
|
||||
```bash
|
||||
# Initialize deployment spec with defaults
|
||||
laconic-so --stack <name> deploy init --output <spec-file>
|
||||
|
||||
# Initialize with configuration
|
||||
laconic-so --stack <name> deploy init --output <spec-file> \
|
||||
--config PARAM1=value1,PARAM2=value2
|
||||
```
|
||||
|
||||
### Deployment Creation
|
||||
|
||||
```bash
|
||||
# Create deployment directory from spec
|
||||
laconic-so --stack <name> deploy create \
|
||||
--spec-file <spec-file> \
|
||||
--deployment-dir <dir>
|
||||
```
|
||||
|
||||
### Deployment Management
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
laconic-so deployment --dir <dir> start
|
||||
|
||||
# Stop services (preserves volumes)
|
||||
laconic-so deployment --dir <dir> stop
|
||||
|
||||
# Stop and remove volumes
|
||||
laconic-so deployment --dir <dir> stop --delete-volumes
|
||||
|
||||
# List running services
|
||||
laconic-so deployment --dir <dir> ps
|
||||
|
||||
# View logs
|
||||
laconic-so deployment --dir <dir> logs [--tail N] [--follow] [service]
|
||||
|
||||
# Show mapped port
|
||||
laconic-so deployment --dir <dir> port <service> <private-port>
|
||||
|
||||
# Execute command in service
|
||||
laconic-so deployment --dir <dir> exec <service> <command>
|
||||
|
||||
# Update configuration
|
||||
laconic-so deployment --dir <dir> update
|
||||
```
|
||||
|
||||
### Quick Deploy Commands
|
||||
|
||||
```bash
|
||||
# Start stack directly
|
||||
laconic-so --stack <name> deploy up
|
||||
|
||||
# Stop stack
|
||||
laconic-so --stack <name> deploy down [--delete-volumes]
|
||||
|
||||
# View logs
|
||||
laconic-so --stack <name> deploy logs
|
||||
|
||||
# Show port mapping
|
||||
laconic-so --stack <name> deploy port <service> <port>
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLI Reference](./cli.md) - Complete CLI command documentation
|
||||
- [Adding a New Stack](./adding-a-new-stack.md) - Creating custom stacks
|
||||
- [Specification](./spec.md) - Internal structure and design
|
||||
- [Kubernetes Enhancements](./k8s-deployment-enhancements.md) - Advanced K8s deployment options
|
||||
- [Web App Deployment](./webapp.md) - Deploying web applications
|
||||
|
||||
## Examples
|
||||
|
||||
For more examples, see the test scripts:
|
||||
- `scripts/quick-deploy-test.sh` - Quick deployment example
|
||||
- `tests/deploy/run-deploy-test.sh` - Comprehensive test showing all features
|
||||
|
||||
## Summary
|
||||
|
||||
- Docker Compose is the default and recommended deployment mode
|
||||
- Two workflows: deployment directory (recommended) or quick deploy
|
||||
- The standard workflow is: setup → build → init → create → start
|
||||
- Configuration is flexible with multiple override layers
|
||||
- Volume persistence is automatic unless explicitly deleted
|
||||
- All deployment state is contained in the deployment directory
|
||||
- For Kubernetes deployments, see separate K8s documentation
|
||||
|
||||
You're now ready to deploy stacks using stack-orchestrator with Docker Compose!
|
||||
@ -1,128 +0,0 @@
|
||||
# Deploying to the Laconic Network
|
||||
|
||||
## Overview
|
||||
|
||||
The Laconic network uses a **registry-based deployment model** where everything is published as blockchain records.
|
||||
|
||||
## Key Documentation in stack-orchestrator
|
||||
|
||||
- `docs/laconicd-with-console.md` - Setting up a laconicd network
|
||||
- `docs/webapp.md` - Webapp building/running
|
||||
- `stack_orchestrator/deploy/webapp/` - Implementation (14 modules)
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### LRN (Laconic Resource Name)
|
||||
Format: `lrn://laconic/[namespace]/[name]`
|
||||
|
||||
Examples:
|
||||
- `lrn://laconic/deployers/my-deployer-name`
|
||||
- `lrn://laconic/dns/example.com`
|
||||
- `lrn://laconic/deployments/example.com`
|
||||
|
||||
### Registry Record Types
|
||||
|
||||
| Record Type | Purpose |
|
||||
|-------------|---------|
|
||||
| `ApplicationRecord` | Published app metadata |
|
||||
| `WebappDeployer` | Deployment service offering |
|
||||
| `ApplicationDeploymentRequest` | User's request to deploy |
|
||||
| `ApplicationDeploymentAuction` | Optional bidding for deployers |
|
||||
| `ApplicationDeploymentRecord` | Completed deployment result |
|
||||
|
||||
## Deployment Workflows
|
||||
|
||||
### 1. Direct Deployment
|
||||
|
||||
```
|
||||
User publishes ApplicationDeploymentRequest
|
||||
→ targets specific WebappDeployer (by LRN)
|
||||
→ includes payment TX hash
|
||||
→ Deployer picks up request, builds, deploys, publishes result
|
||||
```
|
||||
|
||||
### 2. Auction-Based Deployment
|
||||
|
||||
```
|
||||
User publishes ApplicationDeploymentAuction
|
||||
→ Deployers bid (commit/reveal phases)
|
||||
→ Winner selected
|
||||
→ User publishes request targeting winner
|
||||
```
|
||||
|
||||
## Key CLI Commands
|
||||
|
||||
### Publish a Deployer Service
|
||||
```bash
|
||||
laconic-so publish-webapp-deployer --laconic-config config.yml \
|
||||
--api-url https://deployer-api.example.com \
|
||||
--name my-deployer \
|
||||
--payment-address laconic1... \
|
||||
--minimum-payment 1000alnt
|
||||
```
|
||||
|
||||
### Request Deployment (User Side)
|
||||
```bash
|
||||
laconic-so request-webapp-deployment --laconic-config config.yml \
|
||||
--app lrn://laconic/apps/my-app \
|
||||
--deployer lrn://laconic/deployers/xyz \
|
||||
--make-payment auto
|
||||
```
|
||||
|
||||
### Run Deployer Service (Deployer Side)
|
||||
```bash
|
||||
laconic-so deploy-webapp-from-registry --laconic-config config.yml --discover
|
||||
```
|
||||
|
||||
## Laconic Config File
|
||||
|
||||
All tools require a laconic config file (`laconic.toml`):
|
||||
|
||||
```toml
|
||||
[cosmos]
|
||||
address_prefix = "laconic"
|
||||
chain_id = "laconic_9000-1"
|
||||
endpoint = "http://localhost:26657"
|
||||
key = "<account-name>"
|
||||
password = "<account-password>"
|
||||
```
|
||||
|
||||
## Setting Up a Local Laconicd Network
|
||||
|
||||
```bash
|
||||
# Clone and build
|
||||
laconic-so --stack fixturenet-laconic-loaded setup-repositories
|
||||
laconic-so --stack fixturenet-laconic-loaded build-containers
|
||||
laconic-so --stack fixturenet-laconic-loaded deploy create
|
||||
laconic-so deployment --dir laconic-loaded-deployment start
|
||||
|
||||
# Check status
|
||||
laconic-so deployment --dir laconic-loaded-deployment exec cli "laconic registry status"
|
||||
```
|
||||
|
||||
## Key Implementation Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `publish_webapp_deployer.py` | Register deployment service on network |
|
||||
| `publish_deployment_auction.py` | Create auction for deployers to bid on |
|
||||
| `handle_deployment_auction.py` | Monitor and bid on auctions (deployer-side) |
|
||||
| `request_webapp_deployment.py` | Create deployment request (user-side) |
|
||||
| `deploy_webapp_from_registry.py` | Process requests and deploy (deployer-side) |
|
||||
| `request_webapp_undeployment.py` | Request app removal |
|
||||
| `undeploy_webapp_from_registry.py` | Process removal requests |
|
||||
| `util.py` | LaconicRegistryClient - all registry interactions |
|
||||
|
||||
## Payment System
|
||||
|
||||
- **Token Denom**: `alnt` (Laconic network tokens)
|
||||
- **Payment Options**:
|
||||
- `--make-payment`: Create new payment with amount (or "auto" for deployer's minimum)
|
||||
- `--use-payment`: Reference existing payment TX
|
||||
|
||||
## What's NOT Well-Documented
|
||||
|
||||
1. No end-to-end tutorial for full deployment workflow
|
||||
2. Stack publishing (vs webapp) process unclear
|
||||
3. LRN naming conventions not formally specified
|
||||
4. Payment economics and token mechanics
|
||||
@ -11,5 +11,3 @@ tomli==2.0.1
|
||||
validators==0.22.0
|
||||
kubernetes>=28.1.0
|
||||
humanfriendly>=10.0
|
||||
python-gnupg>=0.5.2
|
||||
requests>=2.3.2
|
||||
|
||||
@ -26,14 +26,8 @@ fi
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
WORK_DIR="${1:-/app}"
|
||||
|
||||
if [ -f "${WORK_DIR}/build-webapp.sh" ]; then
|
||||
echo "Building webapp with ${WORK_DIR}/build-webapp.sh ..."
|
||||
cd "${WORK_DIR}" || exit 1
|
||||
|
||||
./build-webapp.sh || exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "next.config.mjs" ]; then
|
||||
NEXT_CONFIG_JS="next.config.mjs"
|
||||
IMPORT_OR_REQUIRE="import"
|
||||
|
||||
@ -30,44 +30,36 @@ fi
|
||||
CERC_WEBAPP_FILES_DIR="${CERC_WEBAPP_FILES_DIR:-/app}"
|
||||
cd "$CERC_WEBAPP_FILES_DIR"
|
||||
|
||||
if [ -f "./run-webapp.sh" ]; then
|
||||
echo "Running webapp with run-webapp.sh ..."
|
||||
cd "${WORK_DIR}" || exit 1
|
||||
./run-webapp.sh &
|
||||
tpid=$!
|
||||
wait $tpid
|
||||
else
|
||||
"$SCRIPT_DIR/apply-runtime-env.sh" "`pwd`" .next .next-r
|
||||
mv .next .next.old
|
||||
mv .next-r/.next .
|
||||
"$SCRIPT_DIR/apply-runtime-env.sh" "`pwd`" .next .next-r
|
||||
mv .next .next.old
|
||||
mv .next-r/.next .
|
||||
|
||||
if [ "$CERC_NEXTJS_SKIP_GENERATE" != "true" ]; then
|
||||
jq -e '.scripts.cerc_generate' package.json >/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
npm run cerc_generate > gen.out 2>&1 &
|
||||
tail -f gen.out &
|
||||
tpid=$!
|
||||
if [ "$CERC_NEXTJS_SKIP_GENERATE" != "true" ]; then
|
||||
jq -e '.scripts.cerc_generate' package.json >/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
npm run cerc_generate > gen.out 2>&1 &
|
||||
tail -f gen.out &
|
||||
tpid=$!
|
||||
|
||||
count=0
|
||||
generate_done="false"
|
||||
while [ $count -lt $CERC_MAX_GENERATE_TIME ] && [ "$generate_done" == "false" ]; do
|
||||
sleep 1
|
||||
count=$((count + 1))
|
||||
grep 'rendered as static' gen.out > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
generate_done="true"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $generate_done != "true" ]; then
|
||||
echo "ERROR: 'npm run cerc_generate' not successful within CERC_MAX_GENERATE_TIME" 1>&2
|
||||
exit 1
|
||||
count=0
|
||||
generate_done="false"
|
||||
while [ $count -lt $CERC_MAX_GENERATE_TIME ] && [ "$generate_done" == "false" ]; do
|
||||
sleep 1
|
||||
count=$((count + 1))
|
||||
grep 'rendered as static' gen.out > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
generate_done="true"
|
||||
fi
|
||||
done
|
||||
|
||||
kill $tpid $(ps -ef | grep node | grep next | grep generate | awk '{print $2}') 2>/dev/null
|
||||
tpid=""
|
||||
if [ $generate_done != "true" ]; then
|
||||
echo "ERROR: 'npm run cerc_generate' not successful within CERC_MAX_GENERATE_TIME" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
$CERC_BUILD_TOOL start . -- -p ${CERC_LISTEN_PORT:-80}
|
||||
kill $tpid $(ps -ef | grep node | grep next | grep generate | awk '{print $2}') 2>/dev/null
|
||||
tpid=""
|
||||
fi
|
||||
fi
|
||||
|
||||
$CERC_BUILD_TOOL start . -- -p ${CERC_LISTEN_PORT:-80}
|
||||
|
||||
@ -4,9 +4,5 @@ source ${CERC_CONTAINER_BASE_DIR}/build-base.sh
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Two-stage build is to allow us to pick up both the upstream repo's files, and local files here for config
|
||||
docker build -t cerc/ping-pub-base:local ${build_command_args} -f $SCRIPT_DIR/Dockerfile.base $CERC_REPO_BASE_DIR/cosmos-explorer
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "FATAL: Base container build failed, exiting"
|
||||
exit 1
|
||||
fi
|
||||
docker build -t cerc/ping-pub-base:local ${build_command_args} -f $SCRIPT_DIR/Dockerfile.base $CERC_REPO_BASE_DIR/explorer
|
||||
docker build -t cerc/ping-pub:local ${build_command_args} -f $SCRIPT_DIR/Dockerfile $SCRIPT_DIR
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
|
||||
|
||||
from stack_orchestrator.deploy.deployment_context import DeploymentContext
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
|
||||
def create(context: DeploymentContext, extra_args):
|
||||
@ -22,12 +23,17 @@ def create(context: DeploymentContext, extra_args):
|
||||
# deterministic-deployment-proxy contract, which itself is a prereq for Optimism contract deployment
|
||||
fixturenet_eth_compose_file = context.deployment_dir.joinpath('compose', 'docker-compose-fixturenet-eth.yml')
|
||||
|
||||
with open(fixturenet_eth_compose_file, 'r') as yaml_file:
|
||||
yaml = YAML()
|
||||
yaml_data = yaml.load(yaml_file)
|
||||
|
||||
new_script = '../config/fixturenet-optimism/run-geth.sh:/opt/testnet/run.sh'
|
||||
|
||||
def add_geth_volume(yaml_data):
|
||||
if new_script not in yaml_data['services']['fixturenet-eth-geth-1']['volumes']:
|
||||
yaml_data['services']['fixturenet-eth-geth-1']['volumes'].append(new_script)
|
||||
if new_script not in yaml_data['services']['fixturenet-eth-geth-1']['volumes']:
|
||||
yaml_data['services']['fixturenet-eth-geth-1']['volumes'].append(new_script)
|
||||
|
||||
context.modify_yaml(fixturenet_eth_compose_file, add_geth_volume)
|
||||
with open(fixturenet_eth_compose_file, 'w') as yaml_file:
|
||||
yaml = YAML()
|
||||
yaml.dump(yaml_data, yaml_file)
|
||||
|
||||
return None
|
||||
|
||||
@ -10,7 +10,7 @@ repos:
|
||||
- git.vdb.to/cerc-io/registry-sdk
|
||||
- git.vdb.to/cerc-io/laconic-registry-cli
|
||||
- git.vdb.to/cerc-io/laconic-console
|
||||
- git.vdb.to/cerc-io/cosmos-explorer
|
||||
- github.com/ping-pub/explorer
|
||||
npms:
|
||||
- registry-sdk
|
||||
- laconic-registry-cli
|
||||
|
||||
@ -2,50 +2,4 @@
|
||||
|
||||
The Package Registry Stack supports a build environment that requires a package registry (initially for NPM packages only).
|
||||
|
||||
## Setup
|
||||
|
||||
* Setup required repos and build containers:
|
||||
|
||||
```bash
|
||||
laconic-so --stack package-registry setup-repositories
|
||||
laconic-so --stack package-registry build-containers
|
||||
```
|
||||
|
||||
* Create a deployment:
|
||||
|
||||
```bash
|
||||
laconic-so --stack package-registry deploy init --output package-registry-spec.yml
|
||||
# Update port mapping in the laconic-loaded.spec file to resolve port conflicts on host if any
|
||||
|
||||
laconic-so --stack package-registry deploy create --deployment-dir package-registry-deployment --spec-file package-registry-spec.yml
|
||||
```
|
||||
|
||||
* Start the deployment:
|
||||
|
||||
```bash
|
||||
laconic-so deployment --dir package-registry-deployment start
|
||||
```
|
||||
|
||||
* The local gitea registry can now be accessed at <http://localhost:3000> (the username and password can be taken from the deployment logs)
|
||||
|
||||
* Configure the hostname `gitea.local`:
|
||||
|
||||
Update `/etc/hosts`:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/hosts
|
||||
|
||||
# Add the following line
|
||||
127.0.0.1 gitea.local
|
||||
```
|
||||
|
||||
Check resolution:
|
||||
|
||||
```bash
|
||||
ping gitea.local
|
||||
|
||||
PING gitea.local (127.0.0.1) 56(84) bytes of data.
|
||||
64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.147 ms
|
||||
64 bytes from localhost (127.0.0.1): icmp_seq=2 ttl=64 time=0.033 ms
|
||||
...
|
||||
```
|
||||
Setup instructions can be found [here](../build-support/README.md).
|
||||
|
||||
@ -2,6 +2,7 @@ version: "1.0"
|
||||
name: test
|
||||
description: "A test stack"
|
||||
repos:
|
||||
- git.vdb.to/cerc-io/laconicd
|
||||
- git.vdb.to/cerc-io/test-project@test-branch
|
||||
containers:
|
||||
- cerc/test-container
|
||||
|
||||
@ -45,22 +45,20 @@ class DeploymentContext:
|
||||
def get_compose_dir(self):
|
||||
return self.deployment_dir.joinpath(constants.compose_dir_name)
|
||||
|
||||
def get_compose_file(self, name: str):
|
||||
return self.get_compose_dir() / f"docker-compose-{name}.yml"
|
||||
|
||||
def get_cluster_id(self):
|
||||
return self.id
|
||||
|
||||
def init(self, dir: Path):
|
||||
self.deployment_dir = dir.absolute()
|
||||
def init(self, dir):
|
||||
self.deployment_dir = dir
|
||||
self.spec = Spec()
|
||||
self.spec.init_from_file(self.get_spec_file())
|
||||
self.stack = Stack(self.spec.obj["stack"])
|
||||
self.stack.init_from_file(self.get_stack_file())
|
||||
deployment_file_path = self.get_deployment_file()
|
||||
if deployment_file_path.exists():
|
||||
obj = get_yaml().load(open(deployment_file_path, "r"))
|
||||
self.id = obj[constants.cluster_id_key]
|
||||
with deployment_file_path:
|
||||
obj = get_yaml().load(open(deployment_file_path, "r"))
|
||||
self.id = obj[constants.cluster_id_key]
|
||||
# Handle the case of a legacy deployment with no file
|
||||
# Code below is intended to match the output from _make_default_cluster_name()
|
||||
# TODO: remove when we no longer need to support legacy deployments
|
||||
@ -69,19 +67,3 @@ class DeploymentContext:
|
||||
unique_cluster_descriptor = f"{path},{self.get_stack_file()},None,None"
|
||||
hash = hashlib.md5(unique_cluster_descriptor.encode()).hexdigest()[:16]
|
||||
self.id = f"{constants.cluster_name_prefix}{hash}"
|
||||
|
||||
def modify_yaml(self, file_path: Path, modifier_func):
|
||||
"""
|
||||
Load a YAML from the deployment, apply a modification function, and write it back.
|
||||
"""
|
||||
if not file_path.absolute().is_relative_to(self.deployment_dir):
|
||||
raise ValueError(f"File is not inside deployment directory: {file_path}")
|
||||
|
||||
yaml = get_yaml()
|
||||
with open(file_path, 'r') as f:
|
||||
yaml_data = yaml.load(f)
|
||||
|
||||
modifier_func(yaml_data)
|
||||
|
||||
with open(file_path, 'w') as f:
|
||||
yaml.dump(yaml_data, f)
|
||||
|
||||
@ -443,16 +443,18 @@ def _check_volume_definitions(spec):
|
||||
@click.command()
|
||||
@click.option("--spec-file", required=True, help="Spec file to use to create this deployment")
|
||||
@click.option("--deployment-dir", help="Create deployment files in this directory")
|
||||
@click.argument('extra_args', nargs=-1, type=click.UNPROCESSED)
|
||||
# TODO: Hack
|
||||
@click.option("--network-dir", help="Network configuration supplied in this directory")
|
||||
@click.option("--initial-peers", help="Initial set of persistent peers")
|
||||
@click.pass_context
|
||||
def create(ctx, spec_file, deployment_dir, extra_args):
|
||||
def create(ctx, spec_file, deployment_dir, network_dir, initial_peers):
|
||||
deployment_command_context = ctx.obj
|
||||
return create_operation(deployment_command_context, spec_file, deployment_dir, extra_args)
|
||||
return create_operation(deployment_command_context, spec_file, deployment_dir, network_dir, initial_peers)
|
||||
|
||||
|
||||
# The init command's implementation is in a separate function so that we can
|
||||
# call it from other commands, bypassing the click decoration stuff
|
||||
def create_operation(deployment_command_context, spec_file, deployment_dir, extra_args):
|
||||
def create_operation(deployment_command_context, spec_file, deployment_dir, network_dir, initial_peers):
|
||||
parsed_spec = Spec(os.path.abspath(spec_file), get_parsed_deployment_spec(spec_file))
|
||||
_check_volume_definitions(parsed_spec)
|
||||
stack_name = parsed_spec["stack"]
|
||||
@ -539,7 +541,7 @@ def create_operation(deployment_command_context, spec_file, deployment_dir, extr
|
||||
deployer_config_generator = getDeployerConfigGenerator(deployment_type, deployment_context)
|
||||
# TODO: make deployment_dir_path a Path above
|
||||
deployer_config_generator.generate(deployment_dir_path)
|
||||
call_stack_deploy_create(deployment_context, extra_args)
|
||||
call_stack_deploy_create(deployment_context, [network_dir, initial_peers, deployment_command_context])
|
||||
|
||||
|
||||
# TODO: this code should be in the stack .py files but
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import base64
|
||||
|
||||
from kubernetes import client
|
||||
from typing import Any, List, Set
|
||||
@ -261,12 +260,12 @@ class ClusterInfo:
|
||||
for f in os.listdir(cfg_map_path):
|
||||
full_path = os.path.join(cfg_map_path, f)
|
||||
if os.path.isfile(full_path):
|
||||
data[f] = base64.b64encode(open(full_path, 'rb').read()).decode('ASCII')
|
||||
data[f] = open(full_path, 'rt').read()
|
||||
|
||||
spec = client.V1ConfigMap(
|
||||
metadata=client.V1ObjectMeta(name=f"{self.app_name}-{cfg_map_name}",
|
||||
labels={"configmap-label": cfg_map_name}),
|
||||
binary_data=data
|
||||
data=data
|
||||
)
|
||||
result.append(spec)
|
||||
return result
|
||||
|
||||
@ -92,8 +92,9 @@ class Spec:
|
||||
return self.obj.get(item, default)
|
||||
|
||||
def init_from_file(self, file_path: Path):
|
||||
self.obj = get_yaml().load(open(file_path, "r"))
|
||||
self.file_path = file_path
|
||||
with file_path:
|
||||
self.obj = get_yaml().load(open(file_path, "r"))
|
||||
self.file_path = file_path
|
||||
|
||||
def get_image_registry(self):
|
||||
return self.obj.get(constants.image_registry_key)
|
||||
|
||||
@ -27,4 +27,5 @@ class Stack:
|
||||
self.name = name
|
||||
|
||||
def init_from_file(self, file_path: Path):
|
||||
self.obj = get_yaml().load(open(file_path, "r"))
|
||||
with file_path:
|
||||
self.obj = get_yaml().load(open(file_path, "r"))
|
||||
|
||||
@ -92,6 +92,7 @@ def create_deployment(ctx, deployment_dir, image, url, kube_config, image_regist
|
||||
spec_file_name,
|
||||
deployment_dir,
|
||||
None,
|
||||
None
|
||||
)
|
||||
# Fix up the container tag inside the deployment compose file
|
||||
_fixup_container_tag(deployment_dir, image)
|
||||
|
||||
@ -21,19 +21,15 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import yaml
|
||||
|
||||
import click
|
||||
import gnupg
|
||||
|
||||
from stack_orchestrator.deploy.images import remote_image_exists
|
||||
from stack_orchestrator.deploy.webapp import deploy_webapp
|
||||
from stack_orchestrator.deploy.webapp.util import (
|
||||
AttrDict,
|
||||
LaconicRegistryClient,
|
||||
TimedLogger,
|
||||
build_container_image,
|
||||
confirm_auction,
|
||||
push_container_image,
|
||||
file_hash,
|
||||
deploy_to_k8s,
|
||||
@ -43,7 +39,6 @@ from stack_orchestrator.deploy.webapp.util import (
|
||||
match_owner,
|
||||
skip_by_tag,
|
||||
confirm_payment,
|
||||
load_known_requests,
|
||||
)
|
||||
|
||||
|
||||
@ -54,17 +49,13 @@ def process_app_deployment_request(
|
||||
deployment_record_namespace,
|
||||
dns_record_namespace,
|
||||
default_dns_suffix,
|
||||
dns_value,
|
||||
deployment_parent_dir,
|
||||
kube_config,
|
||||
image_registry,
|
||||
force_rebuild,
|
||||
fqdn_policy,
|
||||
recreate_on_deploy,
|
||||
webapp_deployer_record,
|
||||
gpg,
|
||||
private_key_passphrase,
|
||||
config_upload_dir,
|
||||
payment_address,
|
||||
logger,
|
||||
):
|
||||
logger.log("BEGIN - process_app_deployment_request")
|
||||
@ -116,31 +107,14 @@ def process_app_deployment_request(
|
||||
)
|
||||
|
||||
# 4. get build and runtime config from request
|
||||
env = {}
|
||||
if app_deployment_request.attributes.config:
|
||||
if "ref" in app_deployment_request.attributes.config:
|
||||
with open(
|
||||
f"{config_upload_dir}/{app_deployment_request.attributes.config.ref}",
|
||||
"rb",
|
||||
) as file:
|
||||
record_owner = laconic.get_owner(app_deployment_request)
|
||||
decrypted = gpg.decrypt_file(file, passphrase=private_key_passphrase)
|
||||
parsed = AttrDict(yaml.safe_load(decrypted.data))
|
||||
if record_owner not in parsed.authorized:
|
||||
raise Exception(
|
||||
f"{record_owner} not authorized to access config {app_deployment_request.attributes.config.ref}"
|
||||
)
|
||||
if "env" in parsed.config:
|
||||
env.update(parsed.config.env)
|
||||
|
||||
if "env" in app_deployment_request.attributes.config:
|
||||
env.update(app_deployment_request.attributes.config.env)
|
||||
|
||||
env_filename = None
|
||||
if env:
|
||||
if (
|
||||
app_deployment_request.attributes.config
|
||||
and "env" in app_deployment_request.attributes.config
|
||||
):
|
||||
env_filename = tempfile.mktemp()
|
||||
with open(env_filename, "w") as file:
|
||||
for k, v in env.items():
|
||||
for k, v in app_deployment_request.attributes.config["env"].items():
|
||||
file.write("%s=%s\n" % (k, shlex.quote(str(v))))
|
||||
|
||||
# 5. determine new or existing deployment
|
||||
@ -252,15 +226,20 @@ def process_app_deployment_request(
|
||||
dns_record,
|
||||
dns_lrn,
|
||||
deployment_dir,
|
||||
dns_value,
|
||||
app_deployment_request,
|
||||
webapp_deployer_record,
|
||||
payment_address,
|
||||
logger,
|
||||
)
|
||||
logger.log("Publication complete.")
|
||||
logger.log("END - process_app_deployment_request")
|
||||
|
||||
|
||||
def load_known_requests(filename):
|
||||
if filename and os.path.exists(filename):
|
||||
return json.load(open(filename, "r"))
|
||||
return {}
|
||||
|
||||
|
||||
def dump_known_requests(filename, requests, status="SEEN"):
|
||||
if not filename:
|
||||
return
|
||||
@ -306,13 +285,8 @@ def dump_known_requests(filename, requests, status="SEEN"):
|
||||
help="How to handle requests with an FQDN: prohibit, allow, preexisting",
|
||||
default="prohibit",
|
||||
)
|
||||
@click.option("--ip", help="IP address of the k8s deployment (to be set in DNS record)", default=None)
|
||||
@click.option("--record-namespace-dns", help="eg, lrn://laconic/dns", required=True)
|
||||
@click.option(
|
||||
"--record-namespace-deployments",
|
||||
help="eg, lrn://laconic/deployments",
|
||||
required=True,
|
||||
)
|
||||
@click.option("--record-namespace-dns", help="eg, lrn://laconic/dns")
|
||||
@click.option("--record-namespace-deployments", help="eg, lrn://laconic/deployments")
|
||||
@click.option(
|
||||
"--dry-run", help="Don't do anything, just report what would be done.", is_flag=True
|
||||
)
|
||||
@ -339,38 +313,21 @@ def dump_known_requests(filename, requests, status="SEEN"):
|
||||
)
|
||||
@click.option(
|
||||
"--min-required-payment",
|
||||
help="Requests must have a minimum payment to be processed (in alnt)",
|
||||
help="Requests must have a minimum payment to be processed",
|
||||
default=0,
|
||||
)
|
||||
@click.option("--lrn", help="The LRN of this deployer.", required=True)
|
||||
@click.option(
|
||||
"--payment-address",
|
||||
help="The address to which payments should be made. "
|
||||
"Default is the current laconic account.",
|
||||
default=None,
|
||||
)
|
||||
@click.option(
|
||||
"--all-requests",
|
||||
help="Handle requests addressed to anyone (by default only requests to"
|
||||
"my payment address are examined).",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--auction-requests",
|
||||
help="Handle requests with auction id set (skips payment confirmation).",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--config-upload-dir",
|
||||
help="The directory containing uploaded config.",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--private-key-file", help="The private key for decrypting config.", required=True
|
||||
)
|
||||
@click.option(
|
||||
"--registry-lock-file", help="File path to use for registry mutex lock", default=None
|
||||
)
|
||||
@click.option(
|
||||
"--private-key-passphrase",
|
||||
help="The passphrase for the private key.",
|
||||
required=True,
|
||||
)
|
||||
@click.pass_context
|
||||
def command( # noqa: C901
|
||||
ctx,
|
||||
@ -384,7 +341,6 @@ def command( # noqa: C901
|
||||
only_update_state,
|
||||
dns_suffix,
|
||||
fqdn_policy,
|
||||
ip,
|
||||
record_namespace_dns,
|
||||
record_namespace_deployments,
|
||||
dry_run,
|
||||
@ -394,13 +350,8 @@ def command( # noqa: C901
|
||||
recreate_on_deploy,
|
||||
log_dir,
|
||||
min_required_payment,
|
||||
lrn,
|
||||
config_upload_dir,
|
||||
private_key_file,
|
||||
private_key_passphrase,
|
||||
payment_address,
|
||||
all_requests,
|
||||
auction_requests,
|
||||
registry_lock_file,
|
||||
):
|
||||
if request_id and discover:
|
||||
print("Cannot specify both --request-id and --discover", file=sys.stderr)
|
||||
@ -433,25 +384,6 @@ def command( # noqa: C901
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
if fqdn_policy == "allow" and not ip:
|
||||
print(
|
||||
"--ip is required with 'allow' fqdn-policy",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
tempdir = tempfile.mkdtemp()
|
||||
gpg = gnupg.GPG(gnupghome=tempdir)
|
||||
|
||||
# Import the deployer's public key
|
||||
result = gpg.import_keys(open(private_key_file, "rb").read())
|
||||
if 1 != result.imported:
|
||||
print(
|
||||
f"Failed to load private key file: {private_key_file}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
main_logger = TimedLogger(file=sys.stderr)
|
||||
|
||||
try:
|
||||
@ -459,17 +391,11 @@ def command( # noqa: C901
|
||||
include_tags = [tag.strip() for tag in include_tags.split(",") if tag]
|
||||
exclude_tags = [tag.strip() for tag in exclude_tags.split(",") if tag]
|
||||
|
||||
laconic = LaconicRegistryClient(laconic_config, log_file=sys.stderr, mutex_lock_file=registry_lock_file)
|
||||
webapp_deployer_record = laconic.get_record(lrn, require=True)
|
||||
payment_address = webapp_deployer_record.attributes.paymentAddress
|
||||
main_logger.log(f"Payment address: {payment_address}")
|
||||
laconic = LaconicRegistryClient(laconic_config, log_file=sys.stderr)
|
||||
if not payment_address:
|
||||
payment_address = laconic.whoami().address
|
||||
|
||||
if min_required_payment and not payment_address:
|
||||
print(
|
||||
f"Minimum payment required, but no payment address listed for deployer: {lrn}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
main_logger.log(f"Payment address: {payment_address}")
|
||||
|
||||
# Find deployment requests.
|
||||
# single request
|
||||
@ -482,7 +408,7 @@ def command( # noqa: C901
|
||||
if all_requests:
|
||||
requests = laconic.app_deployment_requests()
|
||||
else:
|
||||
requests = laconic.app_deployment_requests({"deployer": lrn})
|
||||
requests = laconic.app_deployment_requests({"to": payment_address})
|
||||
|
||||
if only_update_state:
|
||||
if not dry_run:
|
||||
@ -561,7 +487,7 @@ def command( # noqa: C901
|
||||
if all_requests:
|
||||
deployments = laconic.app_deployments()
|
||||
else:
|
||||
deployments = laconic.app_deployments({"deployer": lrn})
|
||||
deployments = laconic.app_deployments({"by": payment_address})
|
||||
deployments_by_request = {}
|
||||
for d in deployments:
|
||||
if d.attributes.request:
|
||||
@ -600,36 +526,11 @@ def command( # noqa: C901
|
||||
requests_to_check_for_payment.append(r)
|
||||
|
||||
requests_to_execute = []
|
||||
for r in requests_to_check_for_payment:
|
||||
if r.attributes.auction:
|
||||
if auction_requests:
|
||||
if confirm_auction(
|
||||
laconic,
|
||||
r,
|
||||
lrn,
|
||||
payment_address,
|
||||
main_logger
|
||||
):
|
||||
main_logger.log(f"{r.id}: Auction confirmed.")
|
||||
requests_to_execute.append(r)
|
||||
else:
|
||||
main_logger.log(
|
||||
f"Skipping request {r.id}: unable to verify auction."
|
||||
)
|
||||
dump_known_requests(state_file, [r], status="SKIP")
|
||||
else:
|
||||
main_logger.log(
|
||||
f"Skipping request {r.id}: not handling requests with auction."
|
||||
)
|
||||
dump_known_requests(state_file, [r], status="SKIP")
|
||||
elif min_required_payment:
|
||||
if min_required_payment:
|
||||
for r in requests_to_check_for_payment:
|
||||
main_logger.log(f"{r.id}: Confirming payment...")
|
||||
if confirm_payment(
|
||||
laconic,
|
||||
r,
|
||||
payment_address,
|
||||
min_required_payment,
|
||||
main_logger,
|
||||
laconic, r, payment_address, min_required_payment, main_logger
|
||||
):
|
||||
main_logger.log(f"{r.id}: Payment confirmed.")
|
||||
requests_to_execute.append(r)
|
||||
@ -638,8 +539,8 @@ def command( # noqa: C901
|
||||
f"Skipping request {r.id}: unable to verify payment."
|
||||
)
|
||||
dump_known_requests(state_file, [r], status="UNPAID")
|
||||
else:
|
||||
requests_to_execute.append(r)
|
||||
else:
|
||||
requests_to_execute = requests_to_check_for_payment
|
||||
|
||||
main_logger.log(
|
||||
"Found %d unsatisfied request(s) to process." % len(requests_to_execute)
|
||||
@ -664,7 +565,7 @@ def command( # noqa: C901
|
||||
)
|
||||
run_log_file = open(run_log_file_path, "wt")
|
||||
run_reg_client = LaconicRegistryClient(
|
||||
laconic_config, log_file=run_log_file, mutex_lock_file=registry_lock_file
|
||||
laconic_config, log_file=run_log_file
|
||||
)
|
||||
|
||||
build_logger = TimedLogger(run_id, run_log_file)
|
||||
@ -676,17 +577,13 @@ def command( # noqa: C901
|
||||
record_namespace_deployments,
|
||||
record_namespace_dns,
|
||||
dns_suffix,
|
||||
ip,
|
||||
os.path.abspath(deployment_parent_dir),
|
||||
kube_config,
|
||||
image_registry,
|
||||
force_rebuild,
|
||||
fqdn_policy,
|
||||
recreate_on_deploy,
|
||||
webapp_deployer_record,
|
||||
gpg,
|
||||
private_key_passphrase,
|
||||
config_upload_dir,
|
||||
payment_address,
|
||||
build_logger,
|
||||
)
|
||||
status = "DEPLOYED"
|
||||
@ -707,5 +604,3 @@ def command( # noqa: C901
|
||||
except Exception as e:
|
||||
main_logger.log("UNCAUGHT ERROR:" + str(e))
|
||||
raise e
|
||||
finally:
|
||||
shutil.rmtree(tempdir, ignore_errors=True)
|
||||
|
||||
@ -1,220 +0,0 @@
|
||||
# Copyright ©2023 Vulcanize
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
import json
|
||||
|
||||
import click
|
||||
|
||||
from stack_orchestrator.deploy.webapp.util import (
|
||||
AttrDict,
|
||||
LaconicRegistryClient,
|
||||
TimedLogger,
|
||||
load_known_requests,
|
||||
AUCTION_KIND_PROVIDER,
|
||||
AuctionStatus,
|
||||
)
|
||||
|
||||
|
||||
def process_app_deployment_auction(
|
||||
ctx,
|
||||
laconic: LaconicRegistryClient,
|
||||
request,
|
||||
current_status,
|
||||
reveal_file_path,
|
||||
bid_amount,
|
||||
logger,
|
||||
):
|
||||
# Fetch auction details
|
||||
auction_id = request.attributes.auction
|
||||
auction = laconic.get_auction(auction_id)
|
||||
if not auction:
|
||||
raise Exception(f"Unable to locate auction: {auction_id}")
|
||||
|
||||
# Check auction kind
|
||||
if auction.kind != AUCTION_KIND_PROVIDER:
|
||||
raise Exception(f"Auction kind needs to be ${AUCTION_KIND_PROVIDER}, got {auction.kind}")
|
||||
|
||||
if current_status == "PENDING":
|
||||
# Skip if pending auction not in commit state
|
||||
if auction.status != AuctionStatus.COMMIT:
|
||||
logger.log(f"Skipping pending request, auction {auction_id} status: {auction.status}")
|
||||
return "SKIP", ""
|
||||
|
||||
# Check max_price
|
||||
bid_amount_int = int(bid_amount)
|
||||
max_price_int = int(auction.maxPrice.quantity)
|
||||
if max_price_int < bid_amount_int:
|
||||
logger.log(f"Skipping auction {auction_id} with max_price ({max_price_int}) less than bid_amount ({bid_amount_int})")
|
||||
return "SKIP", ""
|
||||
|
||||
# Bid on the auction
|
||||
reveal_file_path = laconic.commit_bid(auction_id, bid_amount_int)
|
||||
logger.log(f"Commited bid on auction {auction_id} with amount {bid_amount_int}")
|
||||
|
||||
return "COMMIT", reveal_file_path
|
||||
|
||||
if current_status == "COMMIT":
|
||||
# Return if auction still in commit state
|
||||
if auction.status == AuctionStatus.COMMIT:
|
||||
logger.log(f"Auction {auction_id} status: {auction.status}")
|
||||
return current_status, reveal_file_path
|
||||
|
||||
# Reveal bid
|
||||
if auction.status == AuctionStatus.REVEAL:
|
||||
laconic.reveal_bid(auction_id, reveal_file_path)
|
||||
logger.log(f"Revealed bid on auction {auction_id}")
|
||||
|
||||
return "REVEAL", reveal_file_path
|
||||
|
||||
raise Exception(f"Unexpected auction {auction_id} status: {auction.status}")
|
||||
|
||||
if current_status == "REVEAL":
|
||||
# Return if auction still in reveal state
|
||||
if auction.status == AuctionStatus.REVEAL:
|
||||
logger.log(f"Auction {auction_id} status: {auction.status}")
|
||||
return current_status, reveal_file_path
|
||||
|
||||
# Return if auction is completed
|
||||
if auction.status == AuctionStatus.COMPLETED:
|
||||
logger.log(f"Auction {auction_id} completed")
|
||||
return "COMPLETED", ""
|
||||
|
||||
raise Exception(f"Unexpected auction {auction_id} status: {auction.status}")
|
||||
|
||||
raise Exception(f"Got request with unexpected status: {current_status}")
|
||||
|
||||
|
||||
def dump_known_auction_requests(filename, requests, status="SEEN"):
|
||||
if not filename:
|
||||
return
|
||||
known_requests = load_known_requests(filename)
|
||||
for r in requests:
|
||||
known_requests[r.id] = {"revealFile": r.revealFile, "status": status}
|
||||
with open(filename, "w") as f:
|
||||
json.dump(known_requests, f)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--laconic-config", help="Provide a config file for laconicd", required=True
|
||||
)
|
||||
@click.option(
|
||||
"--state-file",
|
||||
help="File to store state about previously seen auction requests.",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--bid-amount",
|
||||
help="Bid to place on application deployment auctions (in alnt)",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--registry-lock-file", help="File path to use for registry mutex lock", default=None
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run", help="Don't do anything, just report what would be done.", is_flag=True
|
||||
)
|
||||
@click.pass_context
|
||||
def command(
|
||||
ctx,
|
||||
laconic_config,
|
||||
state_file,
|
||||
bid_amount,
|
||||
registry_lock_file,
|
||||
dry_run,
|
||||
):
|
||||
if int(bid_amount) < 0:
|
||||
print("--bid-amount cannot be less than 0", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
logger = TimedLogger(file=sys.stderr)
|
||||
|
||||
try:
|
||||
laconic = LaconicRegistryClient(laconic_config, log_file=sys.stderr, mutex_lock_file=registry_lock_file)
|
||||
auctions_requests = laconic.app_deployment_auctions()
|
||||
|
||||
previous_requests = {}
|
||||
logger.log(f"Loading known auctions from {state_file}...")
|
||||
previous_requests = load_known_requests(state_file)
|
||||
|
||||
# Process new requests first
|
||||
auctions_requests.sort(key=lambda r: r.createTime)
|
||||
auctions_requests.reverse()
|
||||
|
||||
requests_to_execute = []
|
||||
|
||||
for r in auctions_requests:
|
||||
logger.log(f"BEGIN: Examining request {r.id}")
|
||||
result_status = "PENDING"
|
||||
reveal_file_path = ""
|
||||
try:
|
||||
application = r.attributes.application
|
||||
|
||||
# Handle already seen requests
|
||||
if r.id in previous_requests:
|
||||
# If it's not in commit or reveal status, skip the request as we've already seen it
|
||||
current_status = previous_requests[r.id].get("status", "")
|
||||
result_status = current_status
|
||||
if current_status not in ["COMMIT", "REVEAL"]:
|
||||
logger.log(f"Skipping request {r.id}, we've already seen it.")
|
||||
continue
|
||||
|
||||
reveal_file_path = previous_requests[r.id].get("revealFile", "")
|
||||
logger.log(f"Found existing auction request {r.id} for application {application}, status {current_status}.")
|
||||
else:
|
||||
# It's a fresh request, check application record
|
||||
app = laconic.get_record(application)
|
||||
if not app:
|
||||
logger.log(f"Skipping request {r.id}, cannot locate app.")
|
||||
result_status = "ERROR"
|
||||
continue
|
||||
|
||||
logger.log(f"Found pending auction request {r.id} for application {application}.")
|
||||
|
||||
# Add requests to be processed
|
||||
requests_to_execute.append((r, result_status, reveal_file_path))
|
||||
|
||||
except Exception as e:
|
||||
result_status = "ERROR"
|
||||
logger.log(f"ERROR: examining request {r.id}: " + str(e))
|
||||
finally:
|
||||
logger.log(f"DONE: Examining request {r.id} with result {result_status}.")
|
||||
if result_status in ["ERROR"]:
|
||||
dump_known_auction_requests(state_file, [AttrDict({"id": r.id, "revealFile": reveal_file_path})], result_status)
|
||||
|
||||
logger.log(f"Found {len(requests_to_execute)} request(s) to process.")
|
||||
|
||||
if not dry_run:
|
||||
for r, current_status, reveal_file_path in requests_to_execute:
|
||||
logger.log(f"Processing {r.id}: BEGIN")
|
||||
result_status = "ERROR"
|
||||
try:
|
||||
result_status, reveal_file_path = process_app_deployment_auction(
|
||||
ctx,
|
||||
laconic,
|
||||
r,
|
||||
current_status,
|
||||
reveal_file_path,
|
||||
bid_amount,
|
||||
logger,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.log(f"ERROR {r.id}:" + str(e))
|
||||
finally:
|
||||
logger.log(f"Processing {r.id}: END - {result_status}")
|
||||
dump_known_auction_requests(state_file, [AttrDict({"id": r.id, "revealFile": reveal_file_path})], result_status)
|
||||
except Exception as e:
|
||||
logger.log("UNCAUGHT ERROR:" + str(e))
|
||||
raise e
|
||||
@ -1,124 +0,0 @@
|
||||
# Copyright ©2023 Vulcanize
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
from stack_orchestrator.deploy.webapp.util import (
|
||||
AUCTION_KIND_PROVIDER,
|
||||
TOKEN_DENOM,
|
||||
LaconicRegistryClient,
|
||||
)
|
||||
|
||||
|
||||
def fatal(msg: str):
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--laconic-config", help="Provide a config file for laconicd", required=True
|
||||
)
|
||||
@click.option(
|
||||
"--app",
|
||||
help="The LRN of the application to deploy.",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--commits-duration",
|
||||
help="Auction commits duration (in seconds) (default: 600).",
|
||||
default=600,
|
||||
)
|
||||
@click.option(
|
||||
"--reveals-duration",
|
||||
help="Auction reveals duration (in seconds) (default: 600).",
|
||||
default=600,
|
||||
)
|
||||
@click.option(
|
||||
"--commit-fee",
|
||||
help="Auction bid commit fee (in alnt) (default: 100000).",
|
||||
default=100000,
|
||||
)
|
||||
@click.option(
|
||||
"--reveal-fee",
|
||||
help="Auction bid reveal fee (in alnt) (default: 100000).",
|
||||
default=100000,
|
||||
)
|
||||
@click.option(
|
||||
"--max-price",
|
||||
help="Max acceptable bid price (in alnt).",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--num-providers",
|
||||
help="Max acceptable bid price (in alnt).",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
help="Don't publish anything, just report what would be done.",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_context
|
||||
def command(
|
||||
ctx,
|
||||
laconic_config,
|
||||
app,
|
||||
commits_duration,
|
||||
reveals_duration,
|
||||
commit_fee,
|
||||
reveal_fee,
|
||||
max_price,
|
||||
num_providers,
|
||||
dry_run,
|
||||
):
|
||||
laconic = LaconicRegistryClient(laconic_config)
|
||||
|
||||
app_record = laconic.get_record(app)
|
||||
if not app_record:
|
||||
fatal(f"Unable to locate app: {app}")
|
||||
|
||||
provider_auction_params = {
|
||||
"kind": AUCTION_KIND_PROVIDER,
|
||||
"commits_duration": commits_duration,
|
||||
"reveals_duration": reveals_duration,
|
||||
"denom": TOKEN_DENOM,
|
||||
"commit_fee": commit_fee,
|
||||
"reveal_fee": reveal_fee,
|
||||
"max_price": max_price,
|
||||
"num_providers": num_providers,
|
||||
}
|
||||
auction_id = laconic.create_deployment_auction(provider_auction_params)
|
||||
print("Deployment auction created:", auction_id)
|
||||
|
||||
if not auction_id:
|
||||
fatal("Unable to create a provider auction")
|
||||
|
||||
deployment_auction = {
|
||||
"record": {
|
||||
"type": "ApplicationDeploymentAuction",
|
||||
"application": app,
|
||||
"auction": auction_id,
|
||||
}
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
print(yaml.dump(deployment_auction))
|
||||
return
|
||||
|
||||
# Publish the deployment auction record
|
||||
laconic.publish(deployment_auction)
|
||||
@ -1,91 +0,0 @@
|
||||
# Copyright ©2023 Vulcanize
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
|
||||
|
||||
import base64
|
||||
import click
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from stack_orchestrator.deploy.webapp.util import LaconicRegistryClient
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--laconic-config", help="Provide a config file for laconicd", required=True
|
||||
)
|
||||
@click.option("--api-url", help="The API URL of the deployer.", required=True)
|
||||
@click.option(
|
||||
"--public-key-file",
|
||||
help="The public key to use. This should be a binary file.",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--lrn", help="eg, lrn://laconic/deployers/my.deployer.name", required=True
|
||||
)
|
||||
@click.option(
|
||||
"--payment-address",
|
||||
help="The address to which payments should be made. "
|
||||
"Default is the current laconic account.",
|
||||
default=None,
|
||||
)
|
||||
@click.option(
|
||||
"--min-required-payment",
|
||||
help="List the minimum required payment (in alnt) to process a deployment request.",
|
||||
default=0,
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
help="Don't publish anything, just report what would be done.",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_context
|
||||
def command( # noqa: C901
|
||||
ctx,
|
||||
laconic_config,
|
||||
api_url,
|
||||
public_key_file,
|
||||
lrn,
|
||||
payment_address,
|
||||
min_required_payment,
|
||||
dry_run,
|
||||
):
|
||||
laconic = LaconicRegistryClient(laconic_config)
|
||||
if not payment_address:
|
||||
payment_address = laconic.whoami().address
|
||||
|
||||
pub_key = base64.b64encode(open(public_key_file, "rb").read()).decode("ASCII")
|
||||
hostname = urlparse(api_url).hostname
|
||||
webapp_deployer_record = {
|
||||
"record": {
|
||||
"type": "WebappDeployer",
|
||||
"version": "1.0.0",
|
||||
"apiUrl": api_url,
|
||||
"name": hostname,
|
||||
"publicKey": pub_key,
|
||||
"paymentAddress": payment_address,
|
||||
}
|
||||
}
|
||||
|
||||
if min_required_payment:
|
||||
webapp_deployer_record["record"][
|
||||
"minimumPayment"
|
||||
] = f"{min_required_payment}alnt"
|
||||
|
||||
if dry_run:
|
||||
yaml.dump(webapp_deployer_record, sys.stdout)
|
||||
return
|
||||
|
||||
laconic.publish(webapp_deployer_record, [lrn])
|
||||
@ -1,77 +0,0 @@
|
||||
from functools import wraps
|
||||
import os
|
||||
import time
|
||||
|
||||
# Define default file path for the lock
|
||||
DEFAULT_LOCK_FILE_PATH = "/tmp/registry_mutex_lock_file"
|
||||
LOCK_TIMEOUT = 30
|
||||
LOCK_RETRY_INTERVAL = 3
|
||||
|
||||
|
||||
def acquire_lock(client, lock_file_path, timeout):
|
||||
# Lock alreay acquired by the current client
|
||||
if client.mutex_lock_acquired:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Check if lock file exists and is potentially stale
|
||||
if os.path.exists(lock_file_path):
|
||||
with open(lock_file_path, 'r') as lock_file:
|
||||
timestamp = float(lock_file.read().strip())
|
||||
|
||||
# If lock is stale, remove the lock file
|
||||
if time.time() - timestamp > timeout:
|
||||
print(f"Stale lock detected, removing lock file {lock_file_path}")
|
||||
os.remove(lock_file_path)
|
||||
else:
|
||||
print(f"Lock file {lock_file_path} exists and is recent, waiting...")
|
||||
time.sleep(LOCK_RETRY_INTERVAL)
|
||||
continue
|
||||
|
||||
# Try to create a new lock file with the current timestamp
|
||||
fd = os.open(lock_file_path, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
with os.fdopen(fd, 'w') as lock_file:
|
||||
lock_file.write(str(time.time()))
|
||||
|
||||
client.mutex_lock_acquired = True
|
||||
print(f"Registry lock acquired, {lock_file_path}")
|
||||
|
||||
# Lock successfully acquired
|
||||
return
|
||||
|
||||
except FileExistsError:
|
||||
print(f"Lock file {lock_file_path} exists, waiting...")
|
||||
time.sleep(LOCK_RETRY_INTERVAL)
|
||||
|
||||
|
||||
def release_lock(client, lock_file_path):
|
||||
try:
|
||||
os.remove(lock_file_path)
|
||||
|
||||
client.mutex_lock_acquired = False
|
||||
print(f"Registry lock released, {lock_file_path}")
|
||||
except FileNotFoundError:
|
||||
# Lock file already removed
|
||||
pass
|
||||
|
||||
|
||||
def registry_mutex():
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
lock_file_path = DEFAULT_LOCK_FILE_PATH
|
||||
if self.mutex_lock_file:
|
||||
lock_file_path = self.mutex_lock_file
|
||||
|
||||
# Acquire the lock before running the function
|
||||
acquire_lock(self, lock_file_path, LOCK_TIMEOUT)
|
||||
try:
|
||||
return func(self, *args, **kwargs)
|
||||
finally:
|
||||
# Release the lock after the function completes
|
||||
release_lock(self, lock_file_path)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@ -1,262 +0,0 @@
|
||||
# Copyright ©2023 Vulcanize
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
import base64
|
||||
|
||||
import gnupg
|
||||
import click
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
from stack_orchestrator.deploy.webapp.util import (
|
||||
AUCTION_KIND_PROVIDER,
|
||||
AuctionStatus,
|
||||
LaconicRegistryClient,
|
||||
)
|
||||
from dotenv import dotenv_values
|
||||
|
||||
|
||||
def fatal(msg: str):
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--laconic-config", help="Provide a config file for laconicd", required=True
|
||||
)
|
||||
@click.option(
|
||||
"--app",
|
||||
help="The LRN of the application to deploy.",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--auction-id",
|
||||
help="Deployment auction id. Can be used instead of deployer and payment.",
|
||||
)
|
||||
@click.option(
|
||||
"--deployer",
|
||||
help="The LRN of the deployer to process this request.",
|
||||
)
|
||||
@click.option("--env-file", help="environment file for webapp")
|
||||
@click.option("--config-ref", help="The ref of an existing config upload to use.")
|
||||
@click.option(
|
||||
"--make-payment",
|
||||
help="The payment to make (in alnt). The value should be a number or 'auto' to use the deployer's minimum required payment.",
|
||||
)
|
||||
@click.option(
|
||||
"--use-payment", help="The TX id of an existing, unused payment", default=None
|
||||
)
|
||||
@click.option("--dns", help="the DNS name to request (default is autogenerated)")
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
help="Don't publish anything, just report what would be done.",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_context
|
||||
def command( # noqa: C901
|
||||
ctx,
|
||||
laconic_config,
|
||||
app,
|
||||
auction_id,
|
||||
deployer,
|
||||
env_file,
|
||||
config_ref,
|
||||
make_payment,
|
||||
use_payment,
|
||||
dns,
|
||||
dry_run,
|
||||
):
|
||||
if auction_id and deployer:
|
||||
print("Cannot specify both --auction-id and --deployer", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
if not auction_id and not deployer:
|
||||
print("Must specify either --auction-id or --deployer", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
if auction_id and (make_payment or use_payment):
|
||||
print("Cannot specify --auction-id with --make-payment or --use-payment", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
if env_file and config_ref:
|
||||
fatal("Cannot use --env-file and --config-ref at the same time.")
|
||||
|
||||
laconic = LaconicRegistryClient(laconic_config)
|
||||
|
||||
app_record = laconic.get_record(app)
|
||||
if not app_record:
|
||||
fatal(f"Unable to locate app: {app}")
|
||||
|
||||
# Deployers to send requests to
|
||||
deployer_records = []
|
||||
|
||||
auction = None
|
||||
auction_winners = None
|
||||
if auction_id:
|
||||
# Fetch auction record for given auction
|
||||
auction_records_by_id = laconic.app_deployment_auctions({"auction": auction_id})
|
||||
if len(auction_records_by_id) == 0:
|
||||
fatal(f"Unable to locate record for auction: {auction_id}")
|
||||
|
||||
# Cross check app against application in the auction record
|
||||
auction_app = auction_records_by_id[0].attributes.application
|
||||
if auction_app != app:
|
||||
fatal(f"Requested application {app} does not match application from auction record {auction_app}")
|
||||
|
||||
# Fetch auction details
|
||||
auction = laconic.get_auction(auction_id)
|
||||
if not auction:
|
||||
fatal(f"Unable to locate auction: {auction_id}")
|
||||
|
||||
# Check auction owner
|
||||
if auction.ownerAddress != laconic.whoami().address:
|
||||
fatal(f"Auction {auction_id} owner mismatch")
|
||||
|
||||
# Check auction kind
|
||||
if auction.kind != AUCTION_KIND_PROVIDER:
|
||||
fatal(f"Auction kind needs to be ${AUCTION_KIND_PROVIDER}, got {auction.kind}")
|
||||
|
||||
# Check auction status
|
||||
if auction.status != AuctionStatus.COMPLETED:
|
||||
fatal(f"Auction {auction_id} not completed yet, status {auction.status}")
|
||||
|
||||
# Check that winner list is not empty
|
||||
if len(auction.winnerAddresses) == 0:
|
||||
fatal(f"Auction {auction_id} has no winners")
|
||||
|
||||
auction_winners = auction.winnerAddresses
|
||||
|
||||
# Get deployer record for all the auction winners
|
||||
for auction_winner in auction_winners:
|
||||
# TODO: Match auction winner address with provider address?
|
||||
deployer_records_by_owner = laconic.webapp_deployers({"paymentAddress": auction_winner})
|
||||
if len(deployer_records_by_owner) == 0:
|
||||
print(f"WARNING: Unable to locate deployer for auction winner {auction_winner}")
|
||||
|
||||
# Take first record with name set
|
||||
target_deployer_record = deployer_records_by_owner[0]
|
||||
for r in deployer_records_by_owner:
|
||||
if len(r.names) > 0:
|
||||
target_deployer_record = r
|
||||
break
|
||||
deployer_records.append(target_deployer_record)
|
||||
else:
|
||||
deployer_record = laconic.get_record(deployer)
|
||||
if not deployer_record:
|
||||
fatal(f"Unable to locate deployer: {deployer}")
|
||||
|
||||
deployer_records.append(deployer_record)
|
||||
|
||||
# Create and send request to each deployer
|
||||
deployment_requests = []
|
||||
for deployer_record in deployer_records:
|
||||
# Upload config to deployers if env_file is passed
|
||||
if env_file:
|
||||
tempdir = tempfile.mkdtemp()
|
||||
try:
|
||||
gpg = gnupg.GPG(gnupghome=tempdir)
|
||||
|
||||
# Import the deployer's public key
|
||||
result = gpg.import_keys(
|
||||
base64.b64decode(deployer_record.attributes.publicKey)
|
||||
)
|
||||
if 1 != result.imported:
|
||||
fatal("Failed to import deployer's public key.")
|
||||
|
||||
recip = gpg.list_keys()[0]["uids"][0]
|
||||
|
||||
# Wrap the config
|
||||
config = {
|
||||
# Include account (and payment?) details
|
||||
"authorized": [laconic.whoami().address],
|
||||
"config": {"env": dict(dotenv_values(env_file))},
|
||||
}
|
||||
serialized = yaml.dump(config)
|
||||
|
||||
# Encrypt
|
||||
result = gpg.encrypt(serialized, recip, always_trust=True, armor=False)
|
||||
if not result.ok:
|
||||
fatal("Failed to encrypt config.")
|
||||
|
||||
# Upload it to the deployer's API
|
||||
response = requests.post(
|
||||
f"{deployer_record.attributes.apiUrl}/upload/config",
|
||||
data=result.data,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
|
||||
config_ref = response.json()["id"]
|
||||
finally:
|
||||
shutil.rmtree(tempdir, ignore_errors=True)
|
||||
|
||||
target_deployer = deployer
|
||||
if (not deployer) and len(deployer_record.names):
|
||||
target_deployer = deployer_record.names[0]
|
||||
|
||||
deployment_request = {
|
||||
"record": {
|
||||
"type": "ApplicationDeploymentRequest",
|
||||
"application": app,
|
||||
"version": "1.0.0",
|
||||
"name": f"{app_record.attributes.name}@{app_record.attributes.version}",
|
||||
"deployer": target_deployer,
|
||||
"meta": {"when": str(datetime.utcnow())},
|
||||
}
|
||||
}
|
||||
|
||||
if auction_id:
|
||||
deployment_request["record"]["auction"] = auction_id
|
||||
|
||||
if config_ref:
|
||||
deployment_request["record"]["config"] = {"ref": config_ref}
|
||||
|
||||
if dns:
|
||||
deployment_request["record"]["dns"] = dns.lower()
|
||||
|
||||
if make_payment:
|
||||
amount = 0
|
||||
if dry_run:
|
||||
deployment_request["record"]["payment"] = "DRY_RUN"
|
||||
elif "auto" == make_payment:
|
||||
if "minimumPayment" in deployer_record.attributes:
|
||||
amount = int(
|
||||
deployer_record.attributes.minimumPayment.replace("alnt", "")
|
||||
)
|
||||
else:
|
||||
amount = make_payment
|
||||
if amount:
|
||||
receipt = laconic.send_tokens(
|
||||
deployer_record.attributes.paymentAddress, amount
|
||||
)
|
||||
deployment_request["record"]["payment"] = receipt.tx.hash
|
||||
print("Payment TX:", receipt.tx.hash)
|
||||
elif use_payment:
|
||||
deployment_request["record"]["payment"] = use_payment
|
||||
|
||||
deployment_requests.append(deployment_request)
|
||||
|
||||
# Send all requests
|
||||
for deployment_request in deployment_requests:
|
||||
if dry_run:
|
||||
print(yaml.dump(deployment_request))
|
||||
continue
|
||||
|
||||
laconic.publish(deployment_request)
|
||||
@ -1,106 +0,0 @@
|
||||
# Copyright ©2023 Vulcanize
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
from stack_orchestrator.deploy.webapp.util import (LaconicRegistryClient)
|
||||
|
||||
|
||||
def fatal(msg: str):
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--laconic-config", help="Provide a config file for laconicd", required=True
|
||||
)
|
||||
@click.option(
|
||||
"--deployer",
|
||||
help="The LRN of the deployer to process this request.",
|
||||
required=True
|
||||
)
|
||||
@click.option(
|
||||
"--deployment",
|
||||
help="Deployment record (ApplicationDeploymentRecord) id of the deployment to remove.",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--make-payment",
|
||||
help="The payment to make (in alnt). The value should be a number or 'auto' to use the deployer's minimum required payment.",
|
||||
)
|
||||
@click.option(
|
||||
"--use-payment", help="The TX id of an existing, unused payment", default=None
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
help="Don't publish anything, just report what would be done.",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_context
|
||||
def command(
|
||||
ctx,
|
||||
laconic_config,
|
||||
deployer,
|
||||
deployment,
|
||||
make_payment,
|
||||
use_payment,
|
||||
dry_run,
|
||||
):
|
||||
if make_payment and use_payment:
|
||||
fatal("Cannot use --make-payment and --use-payment at the same time.")
|
||||
|
||||
laconic = LaconicRegistryClient(laconic_config)
|
||||
|
||||
deployer_record = laconic.get_record(deployer)
|
||||
if not deployer_record:
|
||||
fatal(f"Unable to locate deployer: {deployer}")
|
||||
|
||||
undeployment_request = {
|
||||
"record": {
|
||||
"type": "ApplicationDeploymentRemovalRequest",
|
||||
"version": "1.0.0",
|
||||
"deployer": deployer,
|
||||
"deployment": deployment,
|
||||
}
|
||||
}
|
||||
|
||||
if make_payment:
|
||||
amount = 0
|
||||
if dry_run:
|
||||
undeployment_request["record"]["payment"] = "DRY_RUN"
|
||||
elif "auto" == make_payment:
|
||||
if "minimumPayment" in deployer_record.attributes:
|
||||
amount = int(
|
||||
deployer_record.attributes.minimumPayment.replace("alnt", "")
|
||||
)
|
||||
else:
|
||||
amount = make_payment
|
||||
if amount:
|
||||
receipt = laconic.send_tokens(
|
||||
deployer_record.attributes.paymentAddress, amount
|
||||
)
|
||||
undeployment_request["record"]["payment"] = receipt.tx.hash
|
||||
print("Payment TX:", receipt.tx.hash)
|
||||
elif use_payment:
|
||||
undeployment_request["record"]["payment"] = use_payment
|
||||
|
||||
if dry_run:
|
||||
print(yaml.dump(undeployment_request))
|
||||
return
|
||||
|
||||
laconic.publish(undeployment_request)
|
||||
@ -38,7 +38,7 @@ def process_app_removal_request(
|
||||
deployment_parent_dir,
|
||||
delete_volumes,
|
||||
delete_names,
|
||||
webapp_deployer_record,
|
||||
payment_address,
|
||||
):
|
||||
deployment_record = laconic.get_record(
|
||||
app_removal_request.attributes.deployment, require=True
|
||||
@ -84,7 +84,7 @@ def process_app_removal_request(
|
||||
"version": "1.0.0",
|
||||
"request": app_removal_request.id,
|
||||
"deployment": deployment_record.id,
|
||||
"deployer": webapp_deployer_record.names[0],
|
||||
"by": payment_address,
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,19 +168,21 @@ def dump_known_requests(filename, requests):
|
||||
)
|
||||
@click.option(
|
||||
"--min-required-payment",
|
||||
help="Requests must have a minimum payment to be processed (in alnt)",
|
||||
help="Requests must have a minimum payment to be processed",
|
||||
default=0,
|
||||
)
|
||||
@click.option("--lrn", help="The LRN of this deployer.", required=True)
|
||||
@click.option(
|
||||
"--payment-address",
|
||||
help="The address to which payments should be made. "
|
||||
"Default is the current laconic account.",
|
||||
default=None,
|
||||
)
|
||||
@click.option(
|
||||
"--all-requests",
|
||||
help="Handle requests addressed to anyone (by default only requests to"
|
||||
"my payment address are examined).",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--registry-lock-file", help="File path to use for registry mutex lock", default=None
|
||||
)
|
||||
@click.pass_context
|
||||
def command( # noqa: C901
|
||||
ctx,
|
||||
@ -196,9 +198,8 @@ def command( # noqa: C901
|
||||
include_tags,
|
||||
exclude_tags,
|
||||
min_required_payment,
|
||||
lrn,
|
||||
payment_address,
|
||||
all_requests,
|
||||
registry_lock_file,
|
||||
):
|
||||
if request_id and discover:
|
||||
print("Cannot specify both --request-id and --discover", file=sys.stderr)
|
||||
@ -216,17 +217,9 @@ def command( # noqa: C901
|
||||
include_tags = [tag.strip() for tag in include_tags.split(",") if tag]
|
||||
exclude_tags = [tag.strip() for tag in exclude_tags.split(",") if tag]
|
||||
|
||||
laconic = LaconicRegistryClient(laconic_config, log_file=sys.stderr, mutex_lock_file=registry_lock_file)
|
||||
deployer_record = laconic.get_record(lrn, require=True)
|
||||
payment_address = deployer_record.attributes.paymentAddress
|
||||
main_logger.log(f"Payment address: {payment_address}")
|
||||
|
||||
if min_required_payment and not payment_address:
|
||||
print(
|
||||
f"Minimum payment required, but no payment address listed for deployer: {lrn}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
laconic = LaconicRegistryClient(laconic_config, log_file=sys.stderr)
|
||||
if not payment_address:
|
||||
payment_address = laconic.whoami().address
|
||||
|
||||
# Find deployment removal requests.
|
||||
# single request
|
||||
@ -240,7 +233,7 @@ def command( # noqa: C901
|
||||
if all_requests:
|
||||
requests = laconic.app_deployment_removal_requests()
|
||||
else:
|
||||
requests = laconic.app_deployment_removal_requests({"deployer": lrn})
|
||||
requests = laconic.app_deployment_removal_requests({"to": payment_address})
|
||||
|
||||
if only_update_state:
|
||||
if not dry_run:
|
||||
@ -315,16 +308,11 @@ def command( # noqa: C901
|
||||
main_logger.log(f"ERROR examining {r.id}: {e}")
|
||||
|
||||
requests_to_execute = []
|
||||
# TODO: Handle requests with auction
|
||||
if min_required_payment:
|
||||
for r in requests_to_check_for_payment:
|
||||
main_logger.log(f"{r.id}: Confirming payment...")
|
||||
if confirm_payment(
|
||||
laconic,
|
||||
r,
|
||||
payment_address,
|
||||
min_required_payment,
|
||||
main_logger,
|
||||
laconic, r, payment_address, min_required_payment, main_logger
|
||||
):
|
||||
main_logger.log(f"{r.id}: Payment confirmed.")
|
||||
requests_to_execute.append(r)
|
||||
@ -348,7 +336,7 @@ def command( # noqa: C901
|
||||
os.path.abspath(deployment_parent_dir),
|
||||
delete_volumes,
|
||||
delete_names,
|
||||
deployer_record,
|
||||
payment_address,
|
||||
)
|
||||
except Exception as e:
|
||||
main_logger.log(f"ERROR processing removal request {r.id}: {e}")
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# = str(min_required_payment) Copyright © 2023 Vulcanize
|
||||
# Copyright © 2023 Vulcanize
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
@ -24,21 +24,6 @@ import tempfile
|
||||
import uuid
|
||||
import yaml
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from stack_orchestrator.deploy.webapp.registry_mutex import registry_mutex
|
||||
|
||||
|
||||
class AuctionStatus(str, Enum):
|
||||
COMMIT = "commit"
|
||||
REVEAL = "reveal"
|
||||
COMPLETED = "completed"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
TOKEN_DENOM = "alnt"
|
||||
AUCTION_KIND_PROVIDER = "provider"
|
||||
|
||||
|
||||
class AttrDict(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -73,12 +58,6 @@ class TimedLogger:
|
||||
self.last = datetime.datetime.now()
|
||||
|
||||
|
||||
def load_known_requests(filename):
|
||||
if filename and os.path.exists(filename):
|
||||
return json.load(open(filename, "r"))
|
||||
return {}
|
||||
|
||||
|
||||
def logged_cmd(log_file, *vargs):
|
||||
result = None
|
||||
try:
|
||||
@ -113,8 +92,76 @@ def is_id(name_or_id: str):
|
||||
return not is_lrn(name_or_id)
|
||||
|
||||
|
||||
def confirm_payment(laconic, record, payment_address, min_amount, logger):
|
||||
req_owner = laconic.get_owner(record)
|
||||
if req_owner == payment_address:
|
||||
# No need to confirm payment if the sender and recipient are the same account.
|
||||
return True
|
||||
|
||||
if not record.attributes.payment:
|
||||
logger.log(f"{record.id}: no payment tx info")
|
||||
return False
|
||||
|
||||
tx = laconic.get_tx(record.attributes.payment)
|
||||
if not tx:
|
||||
logger.log(f"{record.id}: cannot locate payment tx")
|
||||
return False
|
||||
|
||||
if tx.code != 0:
|
||||
logger.log(
|
||||
f"{record.id}: payment tx {tx.hash} was not successful - code: {tx.code}, log: {tx.log}"
|
||||
)
|
||||
return False
|
||||
|
||||
if tx.sender != req_owner:
|
||||
logger.log(
|
||||
f"{record.id}: payment sender {tx.sender} in tx {tx.hash} does not match deployment "
|
||||
f"request owner {req_owner}"
|
||||
)
|
||||
return False
|
||||
|
||||
if tx.recipient != payment_address:
|
||||
logger.log(
|
||||
f"{record.id}: payment recipient {tx.recipient} in tx {tx.hash} does not match {payment_address}"
|
||||
)
|
||||
return False
|
||||
|
||||
pay_denom = "".join([i for i in tx.amount if not i.isdigit()])
|
||||
if pay_denom != "alnt":
|
||||
logger.log(
|
||||
f"{record.id}: {pay_denom} in tx {tx.hash} is not an expected payment denomination"
|
||||
)
|
||||
return False
|
||||
|
||||
pay_amount = int("".join([i for i in tx.amount if i.isdigit()]))
|
||||
if pay_amount < min_amount:
|
||||
logger.log(
|
||||
f"{record.id}: payment amount {tx.amount} is less than minimum {min_amount}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Check if the payment was already used on a
|
||||
used = laconic.app_deployments(
|
||||
{"by": payment_address, "payment": tx.hash}, all=True
|
||||
)
|
||||
if len(used):
|
||||
logger.log(f"{record.id}: payment {tx.hash} already used on deployment {used}")
|
||||
return False
|
||||
|
||||
used = laconic.app_deployment_removals(
|
||||
{"by": payment_address, "payment": tx.hash}, all=True
|
||||
)
|
||||
if len(used):
|
||||
logger.log(
|
||||
f"{record.id}: payment {tx.hash} already used on deployment removal {used}"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class LaconicRegistryClient:
|
||||
def __init__(self, config_file, log_file=None, mutex_lock_file=None):
|
||||
def __init__(self, config_file, log_file=None):
|
||||
self.config_file = config_file
|
||||
self.log_file = log_file
|
||||
self.cache = AttrDict(
|
||||
@ -125,9 +172,6 @@ class LaconicRegistryClient:
|
||||
}
|
||||
)
|
||||
|
||||
self.mutex_lock_file = mutex_lock_file
|
||||
self.mutex_lock_acquired = False
|
||||
|
||||
def whoami(self, refresh=False):
|
||||
if not refresh and "whoami" in self.cache:
|
||||
return self.cache["whoami"]
|
||||
@ -326,34 +370,6 @@ class LaconicRegistryClient:
|
||||
if require:
|
||||
raise Exception("Cannot locate tx:", hash)
|
||||
|
||||
def get_auction(self, auction_id, require=False):
|
||||
args = [
|
||||
"laconic",
|
||||
"-c",
|
||||
self.config_file,
|
||||
"registry",
|
||||
"auction",
|
||||
"get",
|
||||
"--id",
|
||||
auction_id,
|
||||
]
|
||||
|
||||
results = None
|
||||
try:
|
||||
results = [
|
||||
AttrDict(r) for r in json.loads(logged_cmd(self.log_file, *args)) if r
|
||||
]
|
||||
except: # noqa: E722
|
||||
pass
|
||||
|
||||
if results and len(results):
|
||||
return results[0]
|
||||
|
||||
if require:
|
||||
raise Exception("Cannot locate auction:", auction_id)
|
||||
|
||||
return None
|
||||
|
||||
def app_deployment_requests(self, criteria=None, all=True):
|
||||
if criteria is None:
|
||||
criteria = {}
|
||||
@ -382,21 +398,6 @@ class LaconicRegistryClient:
|
||||
criteria["type"] = "ApplicationDeploymentRemovalRecord"
|
||||
return self.list_records(criteria, all)
|
||||
|
||||
def webapp_deployers(self, criteria=None, all=True):
|
||||
if criteria is None:
|
||||
criteria = {}
|
||||
criteria = criteria.copy()
|
||||
criteria["type"] = "WebappDeployer"
|
||||
return self.list_records(criteria, all)
|
||||
|
||||
def app_deployment_auctions(self, criteria=None, all=True):
|
||||
if criteria is None:
|
||||
criteria = {}
|
||||
criteria = criteria.copy()
|
||||
criteria["type"] = "ApplicationDeploymentAuction"
|
||||
return self.list_records(criteria, all)
|
||||
|
||||
@registry_mutex()
|
||||
def publish(self, record, names=None):
|
||||
if names is None:
|
||||
names = []
|
||||
@ -427,7 +428,6 @@ class LaconicRegistryClient:
|
||||
finally:
|
||||
logged_cmd(self.log_file, "rm", "-rf", tmpdir)
|
||||
|
||||
@registry_mutex()
|
||||
def set_name(self, name, record_id):
|
||||
logged_cmd(
|
||||
self.log_file,
|
||||
@ -441,7 +441,6 @@ class LaconicRegistryClient:
|
||||
record_id,
|
||||
)
|
||||
|
||||
@registry_mutex()
|
||||
def delete_name(self, name):
|
||||
logged_cmd(
|
||||
self.log_file,
|
||||
@ -454,86 +453,6 @@ class LaconicRegistryClient:
|
||||
name,
|
||||
)
|
||||
|
||||
@registry_mutex()
|
||||
def send_tokens(self, address, amount, type="alnt"):
|
||||
args = [
|
||||
"laconic",
|
||||
"-c",
|
||||
self.config_file,
|
||||
"registry",
|
||||
"tokens",
|
||||
"send",
|
||||
"--address",
|
||||
address,
|
||||
"--quantity",
|
||||
str(amount),
|
||||
"--type",
|
||||
type,
|
||||
]
|
||||
|
||||
return AttrDict(json.loads(logged_cmd(self.log_file, *args)))
|
||||
|
||||
@registry_mutex()
|
||||
def create_deployment_auction(self, auction):
|
||||
args = [
|
||||
"laconic",
|
||||
"-c",
|
||||
self.config_file,
|
||||
"registry",
|
||||
"auction",
|
||||
"create",
|
||||
"--kind",
|
||||
auction["kind"],
|
||||
"--commits-duration",
|
||||
str(auction["commits_duration"]),
|
||||
"--reveals-duration",
|
||||
str(auction["reveals_duration"]),
|
||||
"--denom",
|
||||
auction["denom"],
|
||||
"--commit-fee",
|
||||
str(auction["commit_fee"]),
|
||||
"--reveal-fee",
|
||||
str(auction["reveal_fee"]),
|
||||
"--max-price",
|
||||
str(auction["max_price"]),
|
||||
"--num-providers",
|
||||
str(auction["num_providers"])
|
||||
]
|
||||
|
||||
return json.loads(logged_cmd(self.log_file, *args))["auctionId"]
|
||||
|
||||
@registry_mutex()
|
||||
def commit_bid(self, auction_id, amount, type="alnt"):
|
||||
args = [
|
||||
"laconic",
|
||||
"-c",
|
||||
self.config_file,
|
||||
"registry",
|
||||
"auction",
|
||||
"bid",
|
||||
"commit",
|
||||
auction_id,
|
||||
str(amount),
|
||||
type,
|
||||
]
|
||||
|
||||
return json.loads(logged_cmd(self.log_file, *args))["reveal_file"]
|
||||
|
||||
@registry_mutex()
|
||||
def reveal_bid(self, auction_id, reveal_file_path):
|
||||
logged_cmd(
|
||||
self.log_file,
|
||||
"laconic",
|
||||
"-c",
|
||||
self.config_file,
|
||||
"registry",
|
||||
"auction",
|
||||
"bid",
|
||||
"reveal",
|
||||
auction_id,
|
||||
reveal_file_path,
|
||||
)
|
||||
|
||||
|
||||
def file_hash(filename):
|
||||
return hashlib.sha1(open(filename).read().encode()).hexdigest()
|
||||
@ -689,9 +608,8 @@ def publish_deployment(
|
||||
dns_record,
|
||||
dns_lrn,
|
||||
deployment_dir,
|
||||
dns_value=None,
|
||||
app_deployment_request=None,
|
||||
webapp_deployer_record=None,
|
||||
payment_address=None,
|
||||
logger=None,
|
||||
):
|
||||
if not deploy_record:
|
||||
@ -722,8 +640,6 @@ def publish_deployment(
|
||||
}
|
||||
if app_deployment_request:
|
||||
new_dns_record["record"]["request"] = app_deployment_request.id
|
||||
if dns_value:
|
||||
new_dns_record["record"]["value"] = dns_value
|
||||
|
||||
if logger:
|
||||
logger.log("Publishing DnsRecord.")
|
||||
@ -743,18 +659,15 @@ def publish_deployment(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if app_deployment_request:
|
||||
new_deployment_record["record"]["request"] = app_deployment_request.id
|
||||
if app_deployment_request.attributes.payment:
|
||||
new_deployment_record["record"][
|
||||
"payment"
|
||||
] = app_deployment_request.attributes.payment
|
||||
|
||||
# Set auction or payment id from request
|
||||
if app_deployment_request.attributes.auction:
|
||||
new_deployment_record["record"]["auction"] = app_deployment_request.attributes.auction
|
||||
elif app_deployment_request.attributes.payment:
|
||||
new_deployment_record["record"]["payment"] = app_deployment_request.attributes.payment
|
||||
|
||||
if webapp_deployer_record:
|
||||
new_deployment_record["record"]["deployer"] = webapp_deployer_record.names[0]
|
||||
if payment_address:
|
||||
new_deployment_record["record"]["by"] = payment_address
|
||||
|
||||
if logger:
|
||||
logger.log("Publishing ApplicationDeploymentRecord.")
|
||||
@ -799,108 +712,3 @@ def skip_by_tag(r, include_tags, exclude_tags):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def confirm_payment(laconic: LaconicRegistryClient, record, payment_address, min_amount, logger):
|
||||
req_owner = laconic.get_owner(record)
|
||||
if req_owner == payment_address:
|
||||
# No need to confirm payment if the sender and recipient are the same account.
|
||||
return True
|
||||
|
||||
if not record.attributes.payment:
|
||||
logger.log(f"{record.id}: no payment tx info")
|
||||
return False
|
||||
|
||||
tx = laconic.get_tx(record.attributes.payment)
|
||||
if not tx:
|
||||
logger.log(f"{record.id}: cannot locate payment tx")
|
||||
return False
|
||||
|
||||
if tx.code != 0:
|
||||
logger.log(
|
||||
f"{record.id}: payment tx {tx.hash} was not successful - code: {tx.code}, log: {tx.log}"
|
||||
)
|
||||
return False
|
||||
|
||||
if tx.sender != req_owner:
|
||||
logger.log(
|
||||
f"{record.id}: payment sender {tx.sender} in tx {tx.hash} does not match deployment "
|
||||
f"request owner {req_owner}"
|
||||
)
|
||||
return False
|
||||
|
||||
if tx.recipient != payment_address:
|
||||
logger.log(
|
||||
f"{record.id}: payment recipient {tx.recipient} in tx {tx.hash} does not match {payment_address}"
|
||||
)
|
||||
return False
|
||||
|
||||
pay_denom = "".join([i for i in tx.amount if not i.isdigit()])
|
||||
if pay_denom != "alnt":
|
||||
logger.log(
|
||||
f"{record.id}: {pay_denom} in tx {tx.hash} is not an expected payment denomination"
|
||||
)
|
||||
return False
|
||||
|
||||
pay_amount = int("".join([i for i in tx.amount if i.isdigit()]))
|
||||
if pay_amount < min_amount:
|
||||
logger.log(
|
||||
f"{record.id}: payment amount {tx.amount} is less than minimum {min_amount}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Check if the payment was already used on a deployment
|
||||
used = laconic.app_deployments(
|
||||
{"deployer": record.attributes.deployer, "payment": tx.hash}, all=True
|
||||
)
|
||||
if len(used):
|
||||
# Fetch the app name from request record
|
||||
used_request = laconic.get_record(used[0].attributes.request, require=True)
|
||||
|
||||
# Check that payment was used for deployment of same application
|
||||
if record.attributes.application != used_request.attributes.application:
|
||||
logger.log(f"{record.id}: payment {tx.hash} already used on a different application deployment {used}")
|
||||
return False
|
||||
|
||||
used = laconic.app_deployment_removals(
|
||||
{"deployer": record.attributes.deployer, "payment": tx.hash}, all=True
|
||||
)
|
||||
if len(used):
|
||||
logger.log(
|
||||
f"{record.id}: payment {tx.hash} already used on deployment removal {used}"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def confirm_auction(laconic: LaconicRegistryClient, record, deployer_lrn, payment_address, logger):
|
||||
auction_id = record.attributes.auction
|
||||
auction = laconic.get_auction(auction_id)
|
||||
|
||||
# Fetch auction record for given auction
|
||||
auction_records_by_id = laconic.app_deployment_auctions({"auction": auction_id})
|
||||
if len(auction_records_by_id) == 0:
|
||||
logger.log(f"{record.id}: unable to locate record for auction {auction_id}")
|
||||
return False
|
||||
|
||||
# Cross check app against application in the auction record
|
||||
requested_app = laconic.get_record(record.attributes.application, require=True)
|
||||
auction_app = laconic.get_record(auction_records_by_id[0].attributes.application, require=True)
|
||||
if requested_app.id != auction_app.id:
|
||||
logger.log(
|
||||
f"{record.id}: requested application {record.attributes.application} does not match application from "
|
||||
f"auction record {auction_records_by_id[0].attributes.application}"
|
||||
)
|
||||
return False
|
||||
|
||||
if not auction:
|
||||
logger.log(f"{record.id}: unable to locate auction {auction_id}")
|
||||
return False
|
||||
|
||||
# Check if the deployer payment address is in auction winners list
|
||||
if payment_address not in auction.winnerAddresses:
|
||||
logger.log(f"{record.id}: deployer payment address not in auction winners.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@ -24,12 +24,7 @@ from stack_orchestrator.build import build_webapp
|
||||
from stack_orchestrator.deploy.webapp import (run_webapp,
|
||||
deploy_webapp,
|
||||
deploy_webapp_from_registry,
|
||||
undeploy_webapp_from_registry,
|
||||
publish_webapp_deployer,
|
||||
publish_deployment_auction,
|
||||
handle_deployment_auction,
|
||||
request_webapp_deployment,
|
||||
request_webapp_undeployment)
|
||||
undeploy_webapp_from_registry)
|
||||
from stack_orchestrator.deploy import deploy
|
||||
from stack_orchestrator import version
|
||||
from stack_orchestrator.deploy import deployment
|
||||
@ -66,11 +61,6 @@ cli.add_command(run_webapp.command, "run-webapp")
|
||||
cli.add_command(deploy_webapp.command, "deploy-webapp")
|
||||
cli.add_command(deploy_webapp_from_registry.command, "deploy-webapp-from-registry")
|
||||
cli.add_command(undeploy_webapp_from_registry.command, "undeploy-webapp-from-registry")
|
||||
cli.add_command(publish_webapp_deployer.command, "publish-deployer-to-registry")
|
||||
cli.add_command(publish_deployment_auction.command, "publish-deployment-auction")
|
||||
cli.add_command(handle_deployment_auction.command, "handle-deployment-auction")
|
||||
cli.add_command(request_webapp_deployment.command, "request-webapp-deployment")
|
||||
cli.add_command(request_webapp_undeployment.command, "request-webapp-undeployment")
|
||||
cli.add_command(deploy.command, "deploy") # deploy is an alias for deploy-system
|
||||
cli.add_command(deploy.command, "deploy-system")
|
||||
cli.add_command(deployment.command, "deployment")
|
||||
|
||||
@ -180,7 +180,9 @@ def get_k8s_dir():
|
||||
def get_parsed_deployment_spec(spec_file):
|
||||
spec_file_path = Path(spec_file)
|
||||
try:
|
||||
return get_yaml().load(open(spec_file_path, "r"))
|
||||
with spec_file_path:
|
||||
deploy_spec = get_yaml().load(open(spec_file_path, "r"))
|
||||
return deploy_spec
|
||||
except FileNotFoundError as error:
|
||||
# We try here to generate a useful diagnostic error
|
||||
print(f"Error: spec file: {spec_file_path} does not exist")
|
||||
|
||||
@ -14,13 +14,8 @@ delete_cluster_exit () {
|
||||
|
||||
# Test basic stack-orchestrator deploy
|
||||
echo "Running stack-orchestrator deploy test"
|
||||
|
||||
if [ "$1" == "from-path" ]; then
|
||||
TEST_TARGET_SO="laconic-so"
|
||||
else
|
||||
TEST_TARGET_SO=$( ls -t1 ./package/laconic-so* | head -1 )
|
||||
fi
|
||||
|
||||
# Bit of a hack, test the most recent package
|
||||
TEST_TARGET_SO=$( ls -t1 ./package/laconic-so* | head -1 )
|
||||
# Set a non-default repo dir
|
||||
export CERC_REPO_BASE_DIR=~/stack-orchestrator-test/repo-base-dir
|
||||
echo "Testing this package: $TEST_TARGET_SO"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user