ref: up
This commit is contained in:
8
fission-python/.claude-plugin/plugin.json
Normal file
8
fission-python/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id" : "fission-python",
|
||||
"name" : "FissionPython",
|
||||
"description": "Skill for creating, analyzing, and managing Fission Python projects.",
|
||||
"type" : "skill",
|
||||
"tools" : ["create-project", "analyze-config", "update-docstring"],
|
||||
"version" : "1.0.1"
|
||||
}
|
||||
83
fission-python/SKILL.md
Normal file
83
fission-python/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Fission Python Skill
|
||||
|
||||
Skill for creating, analyzing, and managing Fission Python projects.
|
||||
|
||||
---
|
||||
|
||||
## When to Use
|
||||
|
||||
- Creating new Fission Python projects from templates
|
||||
- Analyzing Fission configuration (.fission files) in existing projects
|
||||
- Parsing and updating embedded Fission configuration in function docstrings
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Fission Project Structure**: Standard layout with src/, .fission/, specs/ directories
|
||||
- **Function Docstring Configuration**: Fission configuration embedded in Python function docstrings between ```fission markers
|
||||
- **Configuration Files**: .fission/deployment.json defines environments, packages, functions, secrets, and configmaps
|
||||
|
||||
## Patterns
|
||||
|
||||
### Project Template
|
||||
Standard Fission Python project includes:
|
||||
- `src/` - Python function source files with build.sh and requirements.txt
|
||||
- `.fission/` - Fission configuration (deployment.json, etc.)
|
||||
- `specs/` - Generated Fission specs
|
||||
- `test/` - Unit tests
|
||||
- `manifests/` - Kubernetes manifests
|
||||
- `migrates/` - Database migrations
|
||||
- `.devcontainer/` - Development container configuration
|
||||
- `.gitea/workflows/` - CI/CD deployment workflows
|
||||
|
||||
All functions should use pydantic models for request/response validation and include comprehensive docstrings.
|
||||
|
||||
### Docstring Format
|
||||
Fission configuration in docstrings follows this pattern:
|
||||
```python
|
||||
def main():
|
||||
"""
|
||||
Function description
|
||||
|
||||
```fission
|
||||
{
|
||||
"name": "function-name",
|
||||
"http_triggers": {
|
||||
"trigger-name": {
|
||||
"url": "/endpoint",
|
||||
"methods": ["GET", "POST"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
# function implementation
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `create-project.sh` | Create new Fission Python project from template |
|
||||
| `analyze-config.sh` | Analyze .fission configuration in a project |
|
||||
| `update-docstring.sh` | Parse and update docstrings in fission function methods |
|
||||
|
||||
## Examples
|
||||
|
||||
### Create a new project
|
||||
```bash
|
||||
fission-python-skill create-project my-new-function ./projects/
|
||||
```
|
||||
|
||||
### Analyze project configuration
|
||||
```bash
|
||||
fission-python-skill analyze-config ./my-fission-project
|
||||
```
|
||||
|
||||
### Update function docstring
|
||||
```bash
|
||||
fission-python-skill update-docstring ./src/my_function.py main
|
||||
```
|
||||
|
||||
## Related Skills
|
||||
|
||||
- None - this is a specialized skill for Fission Python projects
|
||||
151
fission-python/analyze-config.sh
Executable file
151
fission-python/analyze-config.sh
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Fission Configuration Analyzer
|
||||
# Analyzes and displays fission configuration from .fission directory
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <project-path>"
|
||||
echo " project-path: Path to the fission project directory (should contain .fission/ subdirectory)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
PROJECT_PATH="$1"
|
||||
|
||||
# Validate project path
|
||||
if [[ ! -d "$PROJECT_PATH" ]]; then
|
||||
echo "Error: Project path '$PROJECT_PATH' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FISSION_DIR="$PROJECT_PATH/.fission"
|
||||
|
||||
if [[ ! -d "$FISSION_DIR" ]]; then
|
||||
echo "Error: .fission directory not found in '$PROJECT_PATH'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔍 Analyzing Fission configuration in '$PROJECT_PATH'"
|
||||
echo "=================================================="
|
||||
|
||||
# Analyze deployment.json (main configuration)
|
||||
if [[ -f "$FISSION_DIR/deployment.json" ]]; then
|
||||
echo ""
|
||||
echo "📋 DEPLOYMENT CONFIGURATION"
|
||||
echo "--------------------------"
|
||||
|
||||
# Extract namespaces
|
||||
namespace=$(jq -r '.namespace // "default"' "$FISSION_DIR/deployment.json")
|
||||
echo "Namespace: $namespace"
|
||||
|
||||
# Analyze environments
|
||||
echo ""
|
||||
echo "🌐 ENVIRONMENTS:"
|
||||
if jq -e '.environments' "$FISSION_DIR/deployment.json" >/dev/null; then
|
||||
jq -r '.environments | to_entries[] | " \(.key):" +
|
||||
"\n Image: \(.value.image // "N/A")" +
|
||||
"\n Builder: \(.value.builder // "N/A")" +
|
||||
"\n CPU: \(.value.mincpu // 0)m-\(.value.maxcpu // 0)m" +
|
||||
"\n Memory: \(.value.minmemory // 0)Mi-\(.value.maxmemory // 0)Mi" +
|
||||
"\n Pool Size: \(.value.poolsize // 1)"' "$FISSION_DIR/deployment.json"
|
||||
else
|
||||
echo " No environments defined"
|
||||
fi
|
||||
|
||||
# Analyze packages
|
||||
echo ""
|
||||
echo "📦 PACKAGES:"
|
||||
if jq -e '.packages' "$FISSION_DIR/deployment.json" >/dev/null; then
|
||||
jq -r '.packages | to_entries[] | " \(.key):" +
|
||||
"\n Build Command: \(.value.buildcmd // "N/A")" +
|
||||
"\n Source Archive: \(.value.sourcearchive // "N/A")" +
|
||||
"\n Environment: \(.value.env // "N/A")"' "$FISSION_DIR/deployment.json"
|
||||
else
|
||||
echo " No packages defined"
|
||||
fi
|
||||
|
||||
# Analyze functions (from function_common and individual functions)
|
||||
echo ""
|
||||
echo "⚙️ FUNCTIONS:"
|
||||
if jq -e '.function_common' "$FISSION_DIR/deployment.json" >/dev/null; then
|
||||
echo " Common Configuration:"
|
||||
jq -r '.function_common |
|
||||
" Package: \(.pkg // "N/A")" +
|
||||
"\n Executor Type: \(.executor.select // "N/A")" +
|
||||
"\n CPU: \(.mincpu // 0)m-\(.maxcpu // 0)m" +
|
||||
"\n Memory: \(.minmemory // 0)Mi-\(.maxmemory // 0)Mi"' "$FISSION_DIR/deployment.json"
|
||||
fi
|
||||
|
||||
# Look for individual function definitions
|
||||
if jq -e '.functions' "$FISSION_DIR/deployment.json" >/dev/null; then
|
||||
echo ""
|
||||
echo " Individual Functions:"
|
||||
jq -r '.functions | to_entries[] | " \(.key):" +
|
||||
"\n Executor: \(.value.executor // "N/A")"' "$FISSION_DIR/deployment.json"
|
||||
else
|
||||
echo " No individual functions defined (using function_common)"
|
||||
fi
|
||||
|
||||
# Analyze secrets
|
||||
echo ""
|
||||
echo "🔐 SECRETS:"
|
||||
if jq -e '.secrets' "$FISSION_DIR/deployment.json" >/dev/null; then
|
||||
jq -r '.secrets | to_entries[] | " \(.key):" +
|
||||
"\n Type: \(.value.kind // "literal")" +
|
||||
"\n Literal Count: \(.value.literals | length // 0)"' "$FISSION_DIR/deployment.json"
|
||||
else
|
||||
echo " No secrets defined"
|
||||
fi
|
||||
|
||||
# Analyze configmaps
|
||||
echo ""
|
||||
echo "⚙️ CONFIGMAPS:"
|
||||
if jq -e '.configmaps' "$FISSION_DIR/deployment.json" >/dev/null; then
|
||||
jq -r '.configmaps | to_entries[] | " \(.key):" +
|
||||
"\n Literal Count: \(.value.literals | length // 0)"' "$FISSION_DIR/deployment.json"
|
||||
else
|
||||
echo " No configmaps defined"
|
||||
fi
|
||||
|
||||
# Analyze archives
|
||||
echo ""
|
||||
echo "📦 ARCHIVES:"
|
||||
if jq -e '.archives' "$FISSION_DIR/deployment.json" >/dev/null; then
|
||||
jq -r '.archives | to_entries[] | " \(.key):" +
|
||||
"\n Source Path: \(.value.sourcepath // "N/A")"' "$FISSION_DIR/deployment.json"
|
||||
else
|
||||
echo " No archives defined"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ deployment.json not found in .fission directory"
|
||||
fi
|
||||
|
||||
# Check for other fission files
|
||||
echo ""
|
||||
echo "📄 OTHER FISSION FILES:"
|
||||
shopt -s nullglob
|
||||
fission_files=("$FISSION_DIR"/*.json)
|
||||
if [[ ${#fission_files[@]} -gt 0 ]]; then
|
||||
for file in "${fission_files[@]}"; do
|
||||
filename=$(basename "$file")
|
||||
if [[ "$filename" != "deployment.json" ]]; then
|
||||
echo " $filename"
|
||||
# Show brief content
|
||||
if [[ -s "$file" ]]; then
|
||||
line_count=$(wc -l < "$file")
|
||||
echo " ($line_count lines)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo " No other .json files found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Analysis complete"
|
||||
225
fission-python/create-project.sh
Executable file
225
fission-python/create-project.sh
Executable file
@@ -0,0 +1,225 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Fission Python Project Creator
|
||||
# Creates a new fission python project from template
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TEMPLATE_DIR="$SCRIPT_DIR/template"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <project-name> [destination-directory]"
|
||||
echo " project-name: Name for the new fission project"
|
||||
echo " destination-directory: Optional directory where project should be created (default: current directory)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
PROJECT_NAME="$1"
|
||||
DEST_DIR="${2:-./}"
|
||||
|
||||
# Validate project name
|
||||
if [[ ! "$PROJECT_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
echo "Error: Project name can only contain letters, numbers, hyphens, and underscores"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create destination directory if it doesn't exist
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
PROJECT_PATH="$DEST_DIR/$PROJECT_NAME"
|
||||
|
||||
# Check if project already exists
|
||||
if [[ -d "$PROJECT_PATH" ]]; then
|
||||
echo "Error: Project directory '$PROJECT_PATH' already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if template exists
|
||||
if [[ ! -d "$TEMPLATE_DIR" ]]; then
|
||||
echo "Error: Template directory not found at '$TEMPLATE_DIR'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating fission python project '$PROJECT_NAME' in '$PROJECT_PATH'..."
|
||||
|
||||
# Copy template excluding unwanted files/directories
|
||||
rsync -av --exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='.env' \
|
||||
"$TEMPLATE_DIR/" "$PROJECT_PATH/"
|
||||
|
||||
# Replace placeholder values in configuration files
|
||||
|
||||
# 1. deployment.json - replace ${PROJECT_NAME} and old eom-quota references
|
||||
if [[ -f "$PROJECT_PATH/.fission/deployment.json" ]]; then
|
||||
sed -i "s/\${PROJECT_NAME}/$PROJECT_NAME/g" "$PROJECT_PATH/.fission/deployment.json"
|
||||
sed -i "s/eom-quota/$PROJECT_NAME/g" "$PROJECT_PATH/.fission/deployment.json"
|
||||
sed -i "s/fission-eom-quota/fission-$PROJECT_NAME/g" "$PROJECT_PATH/.fission/deployment.json"
|
||||
fi
|
||||
|
||||
# 2. Override files - dev-deployment.json and local-deployment.json
|
||||
for override in dev-deployment.json local-deployment.json; do
|
||||
if [[ -f "$PROJECT_PATH/.fission/$override" ]]; then
|
||||
sed -i "s/\${PROJECT_NAME}/$PROJECT_NAME/g" "$PROJECT_PATH/.fission/$override"
|
||||
sed -i "s/eom-quota/$PROJECT_NAME/g" "$PROJECT_PATH/.fission/$override"
|
||||
sed -i "s/fission-eom-quota/fission-$PROJECT_NAME/g" "$PROJECT_PATH/.fission/$override"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. helpers.py - update SECRET_NAME and CONFIG_NAME
|
||||
if [[ -f "$PROJECT_PATH/src/helpers.py" ]]; then
|
||||
sed -i "s/\${PROJECT_NAME}/$PROJECT_NAME/g" "$PROJECT_PATH/src/helpers.py"
|
||||
fi
|
||||
|
||||
# 4. README.md - update with project name and clean up
|
||||
if [[ -f "$PROJECT_PATH/README.md" ]]; then
|
||||
sed -i "s/\${PROJECT_NAME}/$PROJECT_NAME/g" "$PROJECT_PATH/README.md"
|
||||
sed -i "s/^# Fission Python Template/# $PROJECT_NAME/g" "$PROJECT_PATH/README.md"
|
||||
sed -i "s/your-service-py/$PROJECT_NAME-py/g" "$PROJECT_PATH/README.md"
|
||||
sed -i "s/your-package/$PROJECT_NAME/g" "$PROJECT_PATH/README.md"
|
||||
fi
|
||||
|
||||
# ========== VALIDATION ==========
|
||||
echo ""
|
||||
echo "🔍 Validating project structure..."
|
||||
|
||||
validation_errors=0
|
||||
validation_warnings=0
|
||||
|
||||
# 1. Check build.sh exists and is executable
|
||||
if [[ -f "$PROJECT_PATH/src/build.sh" ]]; then
|
||||
if [[ -x "$PROJECT_PATH/src/build.sh" ]]; then
|
||||
echo " ✓ build.sh exists and is executable"
|
||||
else
|
||||
echo " ⚠ build.sh exists but is not executable. Fixing with chmod +x..."
|
||||
chmod +x "$PROJECT_PATH/src/build.sh"
|
||||
validation_warnings=$((validation_warnings + 1))
|
||||
fi
|
||||
else
|
||||
echo " ✗ ERROR: src/build.sh is missing (required)"
|
||||
validation_errors=$((validation_errors + 1))
|
||||
fi
|
||||
|
||||
# 2. Check deployment.json references ./build.sh
|
||||
if [[ -f "$PROJECT_PATH/.fission/deployment.json" ]]; then
|
||||
if grep -q '"./build.sh"' "$PROJECT_PATH/.fission/deployment.json" 2>/dev/null; then
|
||||
echo " ✓ deployment.json references ./build.sh"
|
||||
else
|
||||
echo " ⚠ deployment.json does not reference './build.sh' in buildcmd"
|
||||
validation_warnings=$((validation_warnings + 1))
|
||||
fi
|
||||
else
|
||||
echo " ✗ ERROR: .fission/deployment.json is missing"
|
||||
validation_errors=$((validation_errors + 1))
|
||||
fi
|
||||
|
||||
# 3. Check requirements.txt exists
|
||||
if [[ -f "$PROJECT_PATH/src/requirements.txt" ]]; then
|
||||
echo " ✓ requirements.txt exists"
|
||||
|
||||
# Check for essential dependencies
|
||||
missing_deps=()
|
||||
if ! grep -qi 'pydantic' "$PROJECT_PATH/src/requirements.txt"; then
|
||||
missing_deps+=("pydantic")
|
||||
fi
|
||||
if ! grep -qi 'flask' "$PROJECT_PATH/src/requirements.txt"; then
|
||||
missing_deps+=("flask")
|
||||
fi
|
||||
|
||||
if [[ ${#missing_deps[@]} -gt 0 ]]; then
|
||||
echo " ⚠ requirements.txt missing recommended dependencies: ${missing_deps[*]}"
|
||||
validation_warnings=$((validation_warnings + 1))
|
||||
else
|
||||
echo " ✓ Contains essential dependencies (pydantic, flask)"
|
||||
fi
|
||||
else
|
||||
echo " ✗ ERROR: src/requirements.txt is missing (required)"
|
||||
validation_errors=$((validation_errors + 1))
|
||||
fi
|
||||
|
||||
# 4. Check .gitea/workflows directory exists
|
||||
if [[ -d "$PROJECT_PATH/.gitea/workflows" ]]; then
|
||||
echo " ✓ .gitea/workflows directory exists"
|
||||
|
||||
# Count workflow files
|
||||
workflow_count=$(find "$PROJECT_PATH/.gitea/workflows" -type f -name "*.yaml" 2>/dev/null | wc -l)
|
||||
if [[ $workflow_count -ge 4 ]]; then
|
||||
echo " ✓ Found $workflow_count workflow files"
|
||||
else
|
||||
echo " ⚠ Only found $workflow_count workflow files (expected at least 4)"
|
||||
validation_warnings=$((validation_warnings + 1))
|
||||
fi
|
||||
else
|
||||
echo " ⚠ .gitea/workflows directory is missing (recommended for CI/CD)"
|
||||
validation_warnings=$((validation_warnings + 1))
|
||||
fi
|
||||
|
||||
# 5. Check for Python files with docstrings (basic check, excluding __init__.py)
|
||||
python_files=$(find "$PROJECT_PATH/src" -type f -name "*.py" ! -name "__init__.py" 2>/dev/null | wc -l)
|
||||
if [[ $python_files -gt 0 ]]; then
|
||||
files_with_docstrings=$(grep -l '"""' "$PROJECT_PATH/src/"*.py 2>/dev/null | grep -v '__init__.py' | wc -l)
|
||||
if [[ $files_with_docstrings -eq $python_files ]]; then
|
||||
echo " ✓ All Python files contain docstrings"
|
||||
else
|
||||
echo " ⚠ Only $files_with_docstrings/$python_files Python files have docstrings"
|
||||
validation_warnings=$((validation_warnings + 1))
|
||||
fi
|
||||
else
|
||||
echo " ⚠ No Python files found in src/ to check docstrings"
|
||||
fi
|
||||
|
||||
# 6. Check for pydantic BaseModel usage in models.py (if exists)
|
||||
if [[ -f "$PROJECT_PATH/src/models.py" ]]; then
|
||||
if grep -q "pydantic.BaseModel" "$PROJECT_PATH/src/models.py"; then
|
||||
echo " ✓ models.py uses pydantic.BaseModel"
|
||||
else
|
||||
echo " ⚠ models.py does not appear to use pydantic.BaseModel (recommended for HTTP triggers)"
|
||||
validation_warnings=$((validation_warnings + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
if [[ $validation_errors -eq 0 && $validation_warnings -eq 0 ]]; then
|
||||
echo "✅ All validations passed!"
|
||||
elif [[ $validation_errors -eq 0 ]]; then
|
||||
echo "⚠️ $validation_warnings validation warning(s). Project is usable but review above."
|
||||
else
|
||||
echo "❌ $validation_errors validation error(s) and $validation_warnings warning(s)."
|
||||
echo " The project was created but may have issues. Review the messages above."
|
||||
fi
|
||||
|
||||
# Create basic .env file if it doesn't exist
|
||||
if [[ ! -f "$PROJECT_PATH/.env" ]]; then
|
||||
cat > "$PROJECT_PATH/.env" << EOF
|
||||
# Environment variables for $PROJECT_NAME
|
||||
# Copy this to .env.local for local overrides
|
||||
FISSION_ROUTE_SERVICE_ENDPOINT=http://router.fission.svc.cluster.local
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Project '$PROJECT_NAME' created!"
|
||||
|
||||
if [[ $validation_errors -eq 0 && $validation_warnings -eq 0 ]]; then
|
||||
echo "✅ All validations passed!"
|
||||
elif [[ $validation_errors -eq 0 ]]; then
|
||||
echo "⚠️ $validation_warnings warning(s) found - review above."
|
||||
else
|
||||
echo "❌ $validation_errors error(s) and $validation_warnings warning(s) - review above."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo "1. cd $PROJECT_PATH"
|
||||
echo "2. Review and update configuration in .fission/deployment.json"
|
||||
echo "3. Install dependencies: pip install --upgrade --force-reinstall -r dev-requirements.txt"
|
||||
echo "4. Customize your functions in the src/ directory (see examples/ for patterns)"
|
||||
echo "5. Ensure HTTP trigger functions have proper fission config in docstrings"
|
||||
echo "6. Write tests in test/ directory"
|
||||
echo "7. Create Kubernetes secrets: kubectl create secret generic fission-$PROJECT_NAME-env --from-literal=... (see docs/SECRETS.md)"
|
||||
echo "8. Build and deploy: ./src/build.sh && fission deploy"
|
||||
185
fission-python/reference.md
Normal file
185
fission-python/reference.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Fission Python Skill Reference
|
||||
|
||||
Detailed reference for the fission-python-skill tools.
|
||||
|
||||
---
|
||||
|
||||
## create-project.sh
|
||||
|
||||
Create a new Fission Python project from the standard template.
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
fission-python-skill create-project <project-name> [destination-directory]
|
||||
```
|
||||
|
||||
### Arguments
|
||||
- `project-name`: Name for the new fission project (used for directories and configuration)
|
||||
- `destination-directory`: Optional directory where the project should be created (defaults to current directory)
|
||||
|
||||
### Description
|
||||
Creates a new Fission Python project by copying the template from the plugin's `template/` directory. The plugin is portable and works from any location - the template is stored relative to the plugin itself.
|
||||
|
||||
The template includes:
|
||||
- Standard directory structure (src/, .fission/, specs/, test/, manifests/, migrates/)
|
||||
- Example Python functions with fission configuration in docstrings
|
||||
- Configuration files (.fission/deployment.json, etc.)
|
||||
- Development setup (devcontainer, requirements, etc.)
|
||||
- CI/CD workflows (.gitea/workflows/)
|
||||
- build.sh script for packaging
|
||||
|
||||
After project creation, the script validates that:
|
||||
- `src/build.sh` exists and is executable
|
||||
- `.fission/deployment.json` references `./build.sh`
|
||||
- `src/requirements.txt` exists and contains essential dependencies (pydantic, flask)
|
||||
- `.gitea/workflows/` directory exists with at least 4 workflow files
|
||||
- Python files have docstrings (excluding __init__.py)
|
||||
- models.py uses pydantic.BaseModel
|
||||
|
||||
Validation warnings or errors are displayed to ensure the project follows best practices.
|
||||
|
||||
### Examples
|
||||
```bash
|
||||
# Create project in current directory
|
||||
fission-python-skill create-project my-function
|
||||
|
||||
# Create project in specific directory
|
||||
fission-python-skill create-project my-function ./projects/
|
||||
|
||||
# Create project with different name
|
||||
fission-python-skill create-project data-processing-tool ./services/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## analyze-config.sh
|
||||
|
||||
Analyze and display Fission configuration from a project's .fission directory.
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
fission-python-skill analyze-config <project-path>
|
||||
```
|
||||
|
||||
### Arguments
|
||||
- `project-path`: Path to the fission project directory (should contain .fission/ subdirectory)
|
||||
|
||||
### Description
|
||||
Reads and parses the fission configuration files (.fission/deployment.json and related files) to provide a structured summary of:
|
||||
- Environments and their resource allocation (CPU, memory, scaling)
|
||||
- Packages and their build commands
|
||||
- Functions and their HTTP triggers
|
||||
- Secrets and ConfigMaps
|
||||
- Archives and source configuration
|
||||
|
||||
### Output Format
|
||||
The analysis is displayed in a human-readable format showing:
|
||||
- Project overview
|
||||
- Environment configurations
|
||||
- Package details
|
||||
- Function specifications
|
||||
- Security configurations (secrets/configmaps)
|
||||
|
||||
### Examples
|
||||
```bash
|
||||
# Analyze current directory project
|
||||
fission-python-skill analyze-config .
|
||||
|
||||
# Analyze specific project
|
||||
fission-python-skill analyze-config ./my-fission-project
|
||||
|
||||
# Analyze project in parent directory
|
||||
fission-python-skill analyze-config ../data-processing-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## update-docstring.sh
|
||||
|
||||
Parse and update the embedded Fission configuration in Python function docstrings.
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
fission-python-skill update-docstring <file-path> [function-name] [--set "<json>"] [--get] [--help]
|
||||
```
|
||||
|
||||
### Arguments
|
||||
- `file-path`: Path to the Python file containing the function
|
||||
- `function-name`: Optional specific function name to target (if not provided, processes all functions with fission configuration)
|
||||
- `--set "<json>"`: Set the fission configuration to the provided JSON string
|
||||
- `--get`: Get/display the current fission configuration (default action if neither --set nor --get provided)
|
||||
- `--help`: Show help message
|
||||
|
||||
### Description
|
||||
This tool extracts, displays, and can modify the Fission configuration embedded in Python function docstrings. The configuration is expected to be between ```fission and ``` markers in the docstring.
|
||||
|
||||
The tool preserves:
|
||||
- All function code outside the docstring
|
||||
- Docstring content outside the fission configuration blocks
|
||||
- Formatting and indentation of the existing code
|
||||
- Only modifies the content between ```fission markers
|
||||
|
||||
### Examples
|
||||
```bash
|
||||
# View current fission configuration in a function
|
||||
fission-python-skill update-docstring ./src/my_function.py main --get
|
||||
|
||||
# Update fission configuration with new JSON
|
||||
fission-python-skill update-docstring ./src/my_function.py main --set '{"name": "updated-function", "http_triggers": {"updated-trigger": {"url": "/new-endpoint", "methods": ["POST"]}}}'
|
||||
|
||||
# Process all functions with fission configuration in a file
|
||||
fission-python-skill update-docstring ./src/functions.py --get
|
||||
|
||||
# View help
|
||||
fission-python-skill update-docstring --help
|
||||
```
|
||||
|
||||
### Configuration Format
|
||||
The fission configuration should be valid JSON representing the function's fission definition, typically including:
|
||||
- `name`: Function name
|
||||
- `environment`: Optional environment override
|
||||
- `http_triggers`: HTTP endpoint configuration
|
||||
- `schedule_triggers`: Cron-based triggers (if applicable)
|
||||
- `message_queue_triggers`: Message queue triggers (if applicable)
|
||||
|
||||
### Error Handling
|
||||
- Returns error if file doesn't exist
|
||||
- Returns error if function not found (when specified)
|
||||
- Returns error if no fission configuration found in function docstring
|
||||
- Returns error if provided JSON is invalid (when using --set)
|
||||
- Preserves original file on error (no partial writes)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The fission-python-skill is automatically available when the fission-plugin is installed. To use the tools directly:
|
||||
|
||||
1. Navigate to the plugin directory and run tools directly:
|
||||
```bash
|
||||
cd /path/to/fission-python-skill
|
||||
./create-project.sh my-project
|
||||
```
|
||||
|
||||
2. Or add the plugin directory to your PATH:
|
||||
```bash
|
||||
export PATH="$PATH:/path/to/fission-python-skill"
|
||||
fission-python-skill create-project my-project
|
||||
```
|
||||
|
||||
The plugin is portable and does not require any hardcoded paths - it will locate its template relative to its own location.
|
||||
|
||||
---
|
||||
|
||||
## Template Source
|
||||
|
||||
The project template is sourced from: `fission-python-skill/template/` (relative to the plugin location).
|
||||
|
||||
When creating new projects, the following files/directories are excluded from copying:
|
||||
- .git/
|
||||
- __pycache__/
|
||||
- *.pyc
|
||||
- .env
|
||||
- test/ (test files are copied but may need project-specific updates)
|
||||
|
||||
The plugin is fully portable - it uses relative paths based on the script location, so it can be moved to different directories or systems without breaking.
|
||||
20
fission-python/template/.devcontainer/.env.example
Normal file
20
fission-python/template/.devcontainer/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# For download Rake tool
|
||||
PRIVATE_GIT_TOKEN=
|
||||
|
||||
# Rake tool's profile
|
||||
FISSION_PROFILE=local
|
||||
|
||||
# Rancher K3S version (docker-compose)
|
||||
K3S_VERSION=v1.32.4-k3s1
|
||||
K3S_TOKEN=
|
||||
|
||||
FISSION_VER=v1.21.0
|
||||
FISSION_NAMESPACE=fission
|
||||
|
||||
# Nginx ingress
|
||||
NGINX_INGRESS_VER=v1.7.1
|
||||
|
||||
# Metrics
|
||||
METRICS_NAMESPACE=monitoring
|
||||
OPENTELEMETRY_NAMESPACE=opentelemetry-operator-system
|
||||
JAEGER_NAMESPACE=jaeger
|
||||
42
fission-python/template/.devcontainer/devcontainer.json
Normal file
42
fission-python/template/.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,42 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/rust
|
||||
{
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
// "image": "mcr.microsoft.com/devcontainers/rust:0-1-bullseye",
|
||||
// Use docker compose file
|
||||
"dockerComposeFile": ["docker-compose.yaml", "docker-compose-k3s.yaml"],
|
||||
"service": "devcontainer",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
"settings": {"terminal.integrated.defaultProfile.linux": "bash"},
|
||||
"extensions": [
|
||||
// VS Code specific
|
||||
"ms-azuretools.vscode-docker",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"j-brooke.fracturedjsonvsc",
|
||||
// Python specific
|
||||
"ms-python.python",
|
||||
"charliermarsh.ruff",
|
||||
// Markdown specific
|
||||
"yzhang.markdown-all-in-one",
|
||||
// YAML formatter
|
||||
"kennylong.kubernetes-yaml-formatter",
|
||||
// hightlight and format `pyproject.toml`
|
||||
"tamasfe.even-better-toml"
|
||||
]
|
||||
}
|
||||
},
|
||||
"mounts": [],
|
||||
// "runArgs": [
|
||||
// "--env-file",
|
||||
// ".devcontainer/.env"
|
||||
// ],
|
||||
"postStartCommand": "/workspaces/${localWorkspaceFolderBasename}/.devcontainer/initscript.sh",
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": []
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
k3s-server:
|
||||
image: "rancher/k3s:${K3S_VERSION:-latest}"
|
||||
# command: server --disable traefik --disable servicelb
|
||||
command: server --disable traefik
|
||||
hostname: k3s-server
|
||||
dns:
|
||||
- 10.10.20.100
|
||||
tmpfs: [ "/run", "/var/run" ]
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
privileged: true
|
||||
restart: always
|
||||
environment:
|
||||
- K3S_TOKEN=${K3S_TOKEN:-secret}
|
||||
- K3S_KUBECONFIG_OUTPUT=/output/kubeconfig.yaml
|
||||
- K3S_KUBECONFIG_MODE=666
|
||||
volumes:
|
||||
- k3s-server:/var/lib/rancher/k3s
|
||||
# This is just so that we get the kubeconfig file out
|
||||
- .:/output
|
||||
ports:
|
||||
- 6443 # Kubernetes API Server
|
||||
- 80 # Ingress controller port 80
|
||||
- 443 # Ingress controller port 443
|
||||
|
||||
k3s-agent:
|
||||
image: "rancher/k3s:${K3S_VERSION:-latest}"
|
||||
hostname: k3s-agent
|
||||
dns:
|
||||
- 10.10.20.100
|
||||
tmpfs: [ "/run", "/var/run" ]
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
privileged: true
|
||||
restart: always
|
||||
environment:
|
||||
- K3S_URL=https://k3s-server:6443
|
||||
- K3S_TOKEN=${K3S_TOKEN:-secret}
|
||||
volumes:
|
||||
- k3s-agent:/var/lib/rancher/k3s
|
||||
profiles: [ "cluster" ] # only start agent if run with profile `cluster`
|
||||
|
||||
volumes:
|
||||
k3s-server: {}
|
||||
k3s-agent: {}
|
||||
13
fission-python/template/.devcontainer/docker-compose.yaml
Normal file
13
fission-python/template/.devcontainer/docker-compose.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
devcontainer:
|
||||
# All tags avaiable at: https://mcr.microsoft.com/v2/devcontainers/rust/tags/list
|
||||
# image: mcr.microsoft.com/vscode/devcontainers/python:3.10-bullseye
|
||||
image: registry.vegastar.vn/vegacloud/fission-python:3.10-bullseye
|
||||
volumes:
|
||||
- ../..:/workspaces:cached
|
||||
command: sleep infinity
|
||||
env_file:
|
||||
- .env
|
||||
# Comment out depend if you only run devcontainer
|
||||
depends_on:
|
||||
- k3s-server
|
||||
166
fission-python/template/.devcontainer/initscript.sh
Executable file
166
fission-python/template/.devcontainer/initscript.sh
Executable file
@@ -0,0 +1,166 @@
|
||||
#!/bin/bash
|
||||
|
||||
## For debugging
|
||||
# set -eux
|
||||
|
||||
|
||||
# wait few seconds to ensure k3s server is ready
|
||||
sleep 60
|
||||
|
||||
|
||||
#############################
|
||||
### DEV PACKAGES
|
||||
#############################
|
||||
export RAKE_VER=0.1.7
|
||||
|
||||
curl -L https://$PRIVATE_GIT_TOKEN@registry.vegastar.vn/vegacloud/make/releases/download/$RAKE_VER/rake-$RAKE_VER-x86_64-unknown-linux-musl.tar.gz | tar xzv -C /tmp/
|
||||
sudo install -o root -g root -m 0755 /tmp/rake-$RAKE_VER-x86_64-unknown-linux-musl/rake /usr/local/bin/rake
|
||||
|
||||
#############################
|
||||
### KUBECTL
|
||||
#############################
|
||||
|
||||
## Config kubectl
|
||||
mkdir -p ~/.kube
|
||||
cp ${PWD}/.devcontainer/kubeconfig.yaml ~/.kube/config
|
||||
sed -i 's/127.0.0.1/k3s-server/g' ~/.kube/config
|
||||
|
||||
## allow insecure connection
|
||||
shopt -s expand_aliases
|
||||
echo 'alias kubectl="kubectl --insecure-skip-tls-verify"' >> ~/.bashrc
|
||||
echo 'alias k="kubectl --insecure-skip-tls-verify"' >> ~/.bashrc
|
||||
|
||||
#############################
|
||||
### K9S
|
||||
#############################
|
||||
|
||||
# install k9s
|
||||
wget https://github.com/derailed/k9s/releases/download/v0.50.6/k9s_linux_amd64.deb -O /tmp/k9s_linux_amd64.deb
|
||||
sudo dpkg -i /tmp/k9s_linux_amd64.deb
|
||||
|
||||
#############################
|
||||
### NGINX INGRESS
|
||||
#############################
|
||||
|
||||
# kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-$NGINX_INGRESS_VER/deploy/static/provider/cloud/deploy.yaml
|
||||
# cat <<EOT >> /tmp/nginx-service.yaml
|
||||
# apiVersion: v1
|
||||
# kind: Service
|
||||
# metadata:
|
||||
# name: ingress-nginx-controller-loadbalancer
|
||||
# namespace: ingress-nginx
|
||||
# spec:
|
||||
# selector:
|
||||
# app.kubernetes.io/component: controller
|
||||
# app.kubernetes.io/instance: ingress-nginx
|
||||
# app.kubernetes.io/name: ingress-nginx
|
||||
# ports:
|
||||
# - name: http
|
||||
# port: 80
|
||||
# protocol: TCP
|
||||
# targetPort: 80
|
||||
# - name: https
|
||||
# port: 443
|
||||
# protocol: TCP
|
||||
# targetPort: 443
|
||||
# type: LoadBalancer
|
||||
# EOT
|
||||
# kubectl apply -f /tmp/nginx-service.yaml
|
||||
# rm -f /tmp/nginx-service.yaml
|
||||
|
||||
#############################
|
||||
### OPEN TELEMETRY
|
||||
#############################
|
||||
# kubectl create namespace $JAEGER_NAMESPACE
|
||||
# kubectl create namespace $OPENTELEMETRY_NAMESPACE
|
||||
|
||||
# ## cert-manager
|
||||
# kubectl apply -f https://github.com/jetstack/cert-manager/releases/latest/download/cert-manager.yaml
|
||||
|
||||
# ## install jaeger
|
||||
# helm repo add jaegertracing https://jaegertracing.github.io/helm-charts
|
||||
# helm install jaeger jaegertracing/jaeger -n $JAEGER_NAMESPACE
|
||||
# kubectl -n $JAEGER_NAMESPACE get po
|
||||
|
||||
# ## open telemetry operator
|
||||
# kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/latest/download/opentelemetry-operator.yaml
|
||||
|
||||
# ## create an OpenTelemetry Collector instance
|
||||
# kubectl -n $OPENTELEMETRY_NAMESPACE apply -f .devcontainer/helm/opentelemetry-collector.yaml
|
||||
|
||||
#############################
|
||||
### FISSION PODs
|
||||
#############################
|
||||
kubectl create namespace $FISSION_NAMESPACE
|
||||
|
||||
# ## install with helm
|
||||
# kubectl create -k "github.com/fission/fission/crds/v1?ref=${FISSION_VER}"
|
||||
# helm repo add fission-charts https://fission.github.io/fission-charts/ && helm repo update
|
||||
# kubectl apply -f - <<EOF
|
||||
# apiVersion: v1
|
||||
# kind: Namespace
|
||||
# metadata:
|
||||
# name: fission
|
||||
# ---
|
||||
# apiVersion: v1
|
||||
# kind: Namespace
|
||||
# metadata:
|
||||
# name: gh-eom
|
||||
# EOF
|
||||
# kubectl apply -f - <<EOF
|
||||
# type: kubernetes.io/dockerconfigjson
|
||||
# apiVersion: v1
|
||||
# kind: Secret
|
||||
# metadata:
|
||||
# name: vega-container-registry
|
||||
# namespace: fission
|
||||
# data:
|
||||
# .dockerconfigjson: >-
|
||||
# eyJhdXRocyI6eyJyZWdpc3RyeS52ZWdhc3Rhci52biI6eyJ1c2VybmFtZSI6InRpZW5kZCIsInBhc3N3b3JkIjoiYTBjY2JjMDVjNzMyYzExMjU3OTg1NjMwNjY5ZTFjNjEyNDg0NzU1MyIsImF1dGgiOiJkR2xsYm1Sa09tRXdZMk5pWXpBMVl6Y3pNbU14TVRJMU56azROVFl6TURZMk9XVXhZell4TWpRNE5EYzFOVE09In19fQ==
|
||||
# EOF
|
||||
# helm upgrade --install fission fission-charts/fission-all --namespace $FISSION_NAMESPACE -f - <<EOF
|
||||
# imagePullSecrets:
|
||||
# - name: vega-container-registry
|
||||
# defaultNamespace: default
|
||||
# additionalFissionNamespaces:
|
||||
# - gh-eom
|
||||
# EOF
|
||||
|
||||
## install without helm
|
||||
kubectl create -k "github.com/fission/fission/crds/v1?ref=${FISSION_VER}"
|
||||
kubectl create namespace $FISSION_NAMESPACE
|
||||
kubectl config set-context --current --namespace=$FISSION_NAMESPACE
|
||||
kubectl apply -f https://github.com/fission/fission/releases/download/${FISSION_VER}/fission-all-${FISSION_VER}-minikube.yaml
|
||||
kubectl config set-context --current --namespace=default #to change context to default namespace after installation
|
||||
|
||||
|
||||
#############################
|
||||
### PROMETHEUS AND GRAFANA
|
||||
#############################
|
||||
# kubectl create namespace $METRICS_NAMESPACE
|
||||
|
||||
# helm repo add prometheus-community https://prometheus-community.github.io/helm-charts && helm repo update
|
||||
# helm install prometheus prometheus-community/kube-prometheus-stack -n $METRICS_NAMESPACE
|
||||
|
||||
#############################
|
||||
### UPDATE FISSION
|
||||
#############################
|
||||
|
||||
# helm upgrade fission fission-charts/fission-all --namespace $FISSION_NAMESPACE -f .devcontainer/helm/fission-values.yaml
|
||||
|
||||
#############################
|
||||
### PORT FORWARDING
|
||||
#############################
|
||||
|
||||
## To access jaeger-query, you can use Kubernetes port forwarding
|
||||
# kubectl -n jaeger port-forward svc/jaeger-query 8080:80 --address='0.0.0.0'
|
||||
## To access kabana, you can use Kubernetes port forwarding
|
||||
# kubectl --namespace monitoring port-forward svc/prometheus-grafana 3000:80
|
||||
## For password, you'll need to run the following command:
|
||||
# kubectl get secret --namespace monitoring prometheus-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo
|
||||
|
||||
#############################
|
||||
### INSTALLING PYTHON PACKAGES
|
||||
#############################
|
||||
|
||||
pip install -r dev-requirements.txt -r src/requirements.txt
|
||||
24
fission-python/template/.env.example
Normal file
24
fission-python/template/.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# PostgreSQL Database Configuration
|
||||
PG_HOST=
|
||||
PG_PORT=5432
|
||||
PG_DB=
|
||||
PG_USER=
|
||||
PG_PASS=
|
||||
PG_DBSCHEMA=public
|
||||
|
||||
# Optional: Service-specific configuration (via ConfigMap)
|
||||
# YOUR_SERVICE_CONFIG_ENDPOINT=
|
||||
|
||||
# Optional: Vault encryption key (32-byte hex string)
|
||||
# Required if using encrypted secrets (vault:v1:...)
|
||||
CRYPTO_KEY=
|
||||
|
||||
# Example: If using MinIO/S3
|
||||
# S3_ENDPOINT=
|
||||
# S3_ACCESS_KEY=
|
||||
# S3_SECRET_KEY=
|
||||
# S3_BUCKET=
|
||||
|
||||
# Example: If using external APIs
|
||||
# API_ENDPOINT=
|
||||
# API_KEY=
|
||||
65
fission-python/template/.fission/deployment.json
Normal file
65
fission-python/template/.fission/deployment.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"namespace": "default",
|
||||
"environments": {
|
||||
"${PROJECT_NAME}-py": {
|
||||
"image": "ghcr.io/fission/python-env",
|
||||
"builder": "ghcr.io/fission/python-builder",
|
||||
"mincpu": 50,
|
||||
"maxcpu": 100,
|
||||
"minmemory": 50,
|
||||
"maxmemory": 500,
|
||||
"poolsize": 1
|
||||
}
|
||||
},
|
||||
"archives": { "package.zip": {"sourcepath": "src"} },
|
||||
"packages": {
|
||||
"${PROJECT_NAME}": {
|
||||
"buildcmd": "./build.sh",
|
||||
"sourcearchive": "package.zip",
|
||||
"env": "${PROJECT_NAME}-py"
|
||||
}
|
||||
},
|
||||
"function_common": {
|
||||
"pkg": "${PROJECT_NAME}",
|
||||
"secrets": ["fission-${PROJECT_NAME}-env"],
|
||||
"configmaps": ["fission-${PROJECT_NAME}-config"],
|
||||
"executor": {
|
||||
"select": "poolmgr",
|
||||
"poolmgr": {
|
||||
"concurrency": 1,
|
||||
"requestsperpod": 1,
|
||||
"onceonly": false
|
||||
},
|
||||
"newdeploy": {
|
||||
"minscale": 1,
|
||||
"maxscale": 1,
|
||||
"targetcpu": 80
|
||||
}
|
||||
},
|
||||
"mincpu": 50,
|
||||
"maxcpu": 100,
|
||||
"minmemory": 50,
|
||||
"maxmemory": 500
|
||||
},
|
||||
"secrets": {
|
||||
"fission-${PROJECT_NAME}-env": {
|
||||
"literals": [
|
||||
"PG_HOST=YOUR_DB_HOST",
|
||||
"PG_PORT=5432",
|
||||
"PG_DB=YOUR_DB_NAME",
|
||||
"PG_USER=YOUR_DB_USER",
|
||||
"PG_PASS=YOUR_DB_PASSWORD",
|
||||
"PG_DBSCHEMA=public"
|
||||
]
|
||||
}
|
||||
},
|
||||
"configmaps": {
|
||||
"fission-${PROJECT_NAME}-config": {
|
||||
"literals": [
|
||||
"FN_OPTIONAL_CONFIG=http://example.com/config"
|
||||
]
|
||||
}
|
||||
},
|
||||
"imagepullsecret": "",
|
||||
"runtime_envs": {}
|
||||
}
|
||||
22
fission-python/template/.fission/dev-deployment.json
Normal file
22
fission-python/template/.fission/dev-deployment.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"namespace": "fission-dev",
|
||||
"secrets": {
|
||||
"fission-${PROJECT_NAME}-env": {
|
||||
"literals": [
|
||||
"PG_HOST=dev-db.example.com",
|
||||
"PG_PORT=5432",
|
||||
"PG_DB=devdb",
|
||||
"PG_USER=${PROJECT_NAME}-dev",
|
||||
"PG_PASS=dev-password"
|
||||
]
|
||||
}
|
||||
},
|
||||
"configmaps": {
|
||||
"fission-${PROJECT_NAME}-config": {
|
||||
"literals": [
|
||||
"LOG_LEVEL=DEBUG",
|
||||
"FISSION_ROUTE_SERVICE_ENDPOINT=http://router.fission.svc.cluster.local"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
32
fission-python/template/.fission/local-deployment.json
Normal file
32
fission-python/template/.fission/local-deployment.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"namespace": "default",
|
||||
"environments": {
|
||||
"${PROJECT_NAME}-py": {
|
||||
"image": "ghcr.io/fission/python-env:3.11",
|
||||
"builder": "ghcr.io/fission/python-builder:3.11",
|
||||
"mincpu": 100,
|
||||
"maxcpu": 200,
|
||||
"minmemory": 128,
|
||||
"maxmemory": 256,
|
||||
"poolsize": 1
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"fission-${PROJECT_NAME}-env": {
|
||||
"literals": [
|
||||
"PG_HOST=localhost",
|
||||
"PG_PORT=5432",
|
||||
"PG_DB=testdb",
|
||||
"PG_USER=postgres",
|
||||
"PG_PASS=test"
|
||||
]
|
||||
}
|
||||
},
|
||||
"configmaps": {
|
||||
"fission-${PROJECT_NAME}-config": {
|
||||
"literals": [
|
||||
"LOG_LEVEL=DEBUG"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: "K8S Fission Code Analystics"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: 🔍 SonarQube Scan
|
||||
id: scan
|
||||
uses: sonarsource/sonarqube-scan-action@master
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.projectKey=${{ github.event.repository.name }} -Dsonar.sources=.
|
||||
- name: 🔔 Send notification
|
||||
uses: appleboy/telegram-action@master
|
||||
if: always()
|
||||
with:
|
||||
to: ${{ secrets.TELEGRAM_TO }}
|
||||
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
format: markdown
|
||||
socks5: ${{ secrets.TELEGRAM_PROXY_URL != '' && secrets.TELEGRAM_PROXY_URL || '' }}
|
||||
message: |
|
||||
${{ steps.scan.outcome == 'success' && '🟢 (=^ ◡ ^=)' || '🔴 (。•́︿•̀。)' }} Scanned ${{ github.event.repository.name }}
|
||||
*Msg*: `${{ github.event.commits[0].message }}`
|
||||
72
fission-python/template/.gitea/workflows/dev-deployment.yaml
Normal file
72
fission-python/template/.gitea/workflows/dev-deployment.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
name: "Development Deployment"
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to development
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
steps:
|
||||
- name: ☸️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🐍 Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
pip install -r dev-requirements.txt
|
||||
|
||||
- name: 🔍 Lint with flake8
|
||||
run: flake8 src/ --max-line-length=88 --extend-ignore=E203,W503
|
||||
|
||||
- name: 🎨 Check formatting with black
|
||||
run: black --check src/
|
||||
|
||||
- name: 🧪 Run tests
|
||||
run: pytest --cov=src --cov-report=xml
|
||||
|
||||
- name: 📤 Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: ☸️ Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
with:
|
||||
version: 'v1.28.0'
|
||||
|
||||
- name: 🔐 Configure Kubeconfig
|
||||
uses: azure/k8s-set-context@v4
|
||||
with:
|
||||
method: kubeconfig
|
||||
kubeconfig: ${{ secrets.KUBECONFIG_DEV }}
|
||||
|
||||
- name: 🚀 Install Fission CLI
|
||||
run: |
|
||||
curl -L https://github.com/fission/fission/releases/latest/download/fission-linux-amd64 -o /tmp/fission
|
||||
sudo install /tmp/fission /usr/local/bin/fission
|
||||
fission check
|
||||
|
||||
- name: 📦 Build and Deploy (dev)
|
||||
run: |
|
||||
echo "Deploying to development environment..."
|
||||
fission deploy --dev
|
||||
|
||||
- name: 🔔 Notify success
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const status = '${{ job.status }}';
|
||||
const emoji = status === 'success' ? '🟢' : '🔴';
|
||||
const message = `${emoji} Dev deployment ${status} for ${{ github.repository }}@${{ github.sha }}\nCommit: ${{ github.event.commits[0].message }}`;
|
||||
// Send to Slack/Telegram/etc - customize as needed
|
||||
console.log(message);
|
||||
@@ -0,0 +1,56 @@
|
||||
name: "Manual Deployment"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Deployment environment (dev, staging, prod)'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- dev
|
||||
- staging
|
||||
- prod
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to ${{ github.event.inputs.environment }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.environment }}
|
||||
steps:
|
||||
- name: ☸️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: ☸️ Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
with:
|
||||
version: 'v1.28.0'
|
||||
|
||||
- name: 🔐 Configure Kubeconfig
|
||||
uses: azure/k8s-set-context@v4
|
||||
with:
|
||||
method: kubeconfig
|
||||
kubeconfig: ${{ secrets[format('KUBECONFIG_{0}', github.event.inputs.environment)] }}
|
||||
|
||||
- name: 🚀 Install Fission CLI
|
||||
run: |
|
||||
curl -L https://github.com/fission/fission/releases/latest/download/fission-linux-amd64 -o /tmp/fission
|
||||
sudo install /tmp/fission /usr/local/bin/fission
|
||||
fission check
|
||||
|
||||
- name: 📦 Deploy
|
||||
run: |
|
||||
echo "Deploying to ${{ github.event.inputs.environment }} environment..."
|
||||
if [ "${{ github.event.inputs.environment }}" = "dev" ]; then
|
||||
fission deploy --dev
|
||||
else
|
||||
fission deploy
|
||||
fi
|
||||
|
||||
- name: 🔔 Notify
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const env = '${{ github.event.inputs.environment }}';
|
||||
const status = '${{ job.status }}';
|
||||
console.log(`Deployment to ${env} completed with status: ${status}`);
|
||||
@@ -0,0 +1,54 @@
|
||||
name: "Manual Uninstall"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Environment to uninstall from (dev, staging, prod)'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- dev
|
||||
- staging
|
||||
- prod
|
||||
|
||||
jobs:
|
||||
uninstall:
|
||||
name: Uninstall from ${{ github.event.inputs.environment }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.environment }}
|
||||
steps:
|
||||
- name: ☸️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: ☸️ Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
with:
|
||||
version: 'v1.28.0'
|
||||
|
||||
- name: 🔐 Configure Kubeconfig
|
||||
uses: azure/k8s-set-context@v4
|
||||
with:
|
||||
method: kubeconfig
|
||||
kubeconfig: ${{ secrets[format('KUBECONFIG_{0}', github.event.inputs.environment)] }}
|
||||
|
||||
- name: 🚀 Install Fission CLI
|
||||
run: |
|
||||
curl -L https://github.com/fission/fission/releases/latest/download/fission-linux-amd64 -o /tmp/fission
|
||||
sudo install /tmp/fission /usr/local/bin/fission
|
||||
fission check
|
||||
|
||||
- name: 🗑️ Uninstall functions
|
||||
run: |
|
||||
echo "Uninstalling from ${{ github.event.inputs.environment }} environment..."
|
||||
# Delete all functions in this repository/package
|
||||
# Note: This will remove functions defined in deployment.json
|
||||
fission function list --all-namespaces | grep "${{ github.event.repository.name }}" | awk '{print $1}' | xargs -r fission function delete --name
|
||||
|
||||
- name: 🔔 Notify
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const env = '${{ github.event.inputs.environment }}';
|
||||
const status = '${{ job.status }}';
|
||||
console.log(`Uninstall from ${env} completed with status: ${status}`);
|
||||
190
fission-python/template/.gitignore
vendored
Normal file
190
fission-python/template/.gitignore
vendored
Normal file
@@ -0,0 +1,190 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
# *.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
## Ignore Temporary directory of Dagster
|
||||
/tmp*
|
||||
|
||||
## Devcontainer cache files, that will make devcontainer start faster after first run
|
||||
/.vscache/.vscode-server/*
|
||||
!/.vscache/.vscode-server/.gitkeep
|
||||
/.vscache/.devcontainer/*
|
||||
!/.vscache/.devcontainer/.gitkeep
|
||||
|
||||
## Ignore K3S config file
|
||||
/.devcontainer/kubeconfig.yaml
|
||||
|
||||
## Ignore packaged files
|
||||
/*.zip
|
||||
# !/package.zip
|
||||
/*.bak
|
||||
|
||||
# No Makefile in this template - uses build.sh instead
|
||||
|
||||
## Ignore fission's specs files
|
||||
/specs/*
|
||||
!/specs/fission-deployment-config.yaml
|
||||
!/specs/README
|
||||
|
||||
/manifests/*
|
||||
|
||||
/fission-dumps
|
||||
436
fission-python/template/README.md
Normal file
436
fission-python/template/README.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# Fission Python Template
|
||||
|
||||
A production-ready template for building Fission serverless Python functions with best practices for configuration, database connectivity, error handling, and testing.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── .fission/
|
||||
│ ├── deployment.json # Fission function deployment configuration
|
||||
│ ├── dev-deployment.json # Development overrides
|
||||
│ └── local-deployment.json # Local development overrides
|
||||
├── src/
|
||||
│ ├── __init__.py # Package initialization
|
||||
│ ├── vault.py # Vault encryption/decryption utilities
|
||||
│ ├── helpers.py # Shared utilities (DB, secrets, configs)
|
||||
│ ├── exceptions.py # Custom exception hierarchy
|
||||
│ ├── models.py # Pydantic models (request/response schemas)
|
||||
│ ├── build.sh # Package build script
|
||||
│ └── your_function.py # Your function implementations
|
||||
├── test/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_*.py # Unit tests
|
||||
│ └── requirements.txt # Test dependencies
|
||||
├── migrates/
|
||||
│ └── schema.sql # Database migration scripts
|
||||
├── manifests/ # Kubernetes manifests (optional)
|
||||
├── specs/ # Generated Fission specs (created by fission CLI)
|
||||
├── requirements.txt # Runtime dependencies
|
||||
├── dev-requirements.txt # Development dependencies
|
||||
├── .env.example # Environment variable template
|
||||
├── pytest.ini # Pytest configuration
|
||||
└── README.md # Project documentation
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### Fission Configuration in Docstrings
|
||||
|
||||
Fission reads function metadata from docstrings using the ````fission` marker:
|
||||
|
||||
```python
|
||||
def my_function(event, context):
|
||||
"""
|
||||
```fission
|
||||
{
|
||||
"name": "my-function",
|
||||
"http_triggers": {
|
||||
"my-trigger": {
|
||||
"url": "/api/my-endpoint",
|
||||
"methods": ["GET", "POST"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
# Your implementation
|
||||
return {"message": "Hello World"}
|
||||
```
|
||||
|
||||
**Note:** Do not use `fission.yaml` or `fission.json`. The Fission Python builder reads the docstring annotations directly from your Python source files.
|
||||
|
||||
### Environment Variables & Secrets
|
||||
|
||||
Configuration is managed through Kubernetes Secrets and ConfigMaps:
|
||||
|
||||
- **Secrets**: Database credentials, API keys, encryption keys (sensitive)
|
||||
- **ConfigMaps**: Non-sensitive configuration, endpoints, feature flags
|
||||
|
||||
Access them via helper functions:
|
||||
|
||||
```python
|
||||
from helpers import get_secret, get_config
|
||||
|
||||
# Read secret (with optional default)
|
||||
db_host = get_secret("PG_HOST", "localhost")
|
||||
db_port = int(get_secret("PG_PORT", "5432"))
|
||||
|
||||
# Read config
|
||||
api_endpoint = get_config("EXTERNAL_API_ENDPOINT")
|
||||
```
|
||||
|
||||
**Placeholder variables** in `deployment.json`:
|
||||
- `${PROJECT_NAME}` - Replaced with your actual project name during project creation
|
||||
- Secret/configmap names follow pattern: `fission-${PROJECT_NAME}-env` and `fission-${PROJECT_NAME}-config`
|
||||
|
||||
### Database Connectivity
|
||||
|
||||
Use the provided `init_db_connection()` helper:
|
||||
|
||||
```python
|
||||
from helpers import init_db_connection, db_rows_to_array
|
||||
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM items")
|
||||
rows = db_rows_to_array(cursor, cursor.fetchall())
|
||||
```
|
||||
|
||||
The helper automatically:
|
||||
- Reads connection parameters from secrets (PG_HOST, PG_PORT, PG_DB, PG_USER, PG_PASS, PG_DBSCHEMA)
|
||||
- Checks port connectivity before connecting
|
||||
- Uses LoggingConnection for query logging
|
||||
- Applies schema search path if PG_DBSCHEMA is set
|
||||
|
||||
### Error Handling
|
||||
|
||||
Use the exception hierarchy from `exceptions.py`:
|
||||
|
||||
```python
|
||||
from exceptions import ValidationError, NotFoundError, ConflictError, DatabaseError
|
||||
|
||||
def get_item(item_id: str):
|
||||
item = db.fetch_one(item_id)
|
||||
if not item:
|
||||
raise NotFoundError(f"Item {item_id} not found", x_user=get_user_from_headers())
|
||||
return item
|
||||
```
|
||||
|
||||
All exceptions return standardized error responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"error_code": "NOT_FOUND",
|
||||
"http_status": 404,
|
||||
"error_msg": "Item 123 not found",
|
||||
"x_user": "user-456",
|
||||
"details": {"item_id": "123"}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation with Pydantic
|
||||
|
||||
Validate request payloads using Pydantic models:
|
||||
|
||||
```python
|
||||
from models import ItemCreateRequest
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
def create_item():
|
||||
try:
|
||||
data = ItemCreateRequest(**request.get_json())
|
||||
except PydanticValidationError as e:
|
||||
raise ValidationError(str(e), details=e.errors())
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Install runtime and development dependencies
|
||||
pip install -r dev-requirements.txt
|
||||
|
||||
# Or just runtime dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Local Testing
|
||||
|
||||
Fission provides `fission spec` to test specs locally:
|
||||
|
||||
```bash
|
||||
# Verify your deployment configuration
|
||||
fission spec verify --file=.fission/deployment.json
|
||||
|
||||
# Build and test locally
|
||||
fission function test --name your-function
|
||||
```
|
||||
|
||||
### 3. Unit Testing
|
||||
|
||||
Run tests with pytest:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src
|
||||
|
||||
# Run specific test file
|
||||
pytest test/test_my_function.py
|
||||
|
||||
# Verbose output
|
||||
pytest -v
|
||||
```
|
||||
|
||||
**Example test structure:**
|
||||
|
||||
```python
|
||||
# test/test_my_function.py
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from src.my_function import main
|
||||
|
||||
def test_my_function_success():
|
||||
event = {"key": "value"}
|
||||
context = {}
|
||||
result = main(event, context)
|
||||
assert result["status"] == "success"
|
||||
|
||||
@patch("helpers.init_db_connection")
|
||||
def test_my_function_with_db(mock_db):
|
||||
# Mock database connection
|
||||
mock_conn = MagicMock()
|
||||
mock_db.return_value = mock_conn
|
||||
# Test function
|
||||
```
|
||||
|
||||
### 4. Building the Package
|
||||
|
||||
The `build.sh` script installs dependencies and packages your code:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
./src/build.sh
|
||||
|
||||
# This produces a package.zip in the specs directory
|
||||
# Ready for deployment with: fission deploy
|
||||
```
|
||||
|
||||
The build script detects the OS (Debian/Alpine) and installs the correct build dependencies (gcc, libpq-dev, python3-dev).
|
||||
|
||||
### 5. Deployment
|
||||
|
||||
```bash
|
||||
# Deploy to Fission
|
||||
fission deploy
|
||||
|
||||
# Or deploy specific function
|
||||
fission function update --name my-function --env your-env
|
||||
```
|
||||
|
||||
## Deployment Configuration
|
||||
|
||||
### Executors
|
||||
|
||||
Choose between two executor types in `deployment.json`:
|
||||
|
||||
**poolmgr** (default) - Good for high-concurrency HTTP functions:
|
||||
```json
|
||||
"executor": {
|
||||
"select": "poolmgr",
|
||||
"poolmgr": {
|
||||
"concurrency": 1,
|
||||
"requestsperpod": 1,
|
||||
"onceonly": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**newdeploy** - Good for dedicated scaling:
|
||||
```json
|
||||
"executor": {
|
||||
"select": "newdeploy",
|
||||
"newdeploy": {
|
||||
"minscale": 1,
|
||||
"maxscale": 5,
|
||||
"targetcpu": 80
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
|
||||
Set resource allocation in `function_common`:
|
||||
- `mincpu` / `maxcpu` - CPU allocation in millicores (50 = 0.05 cores)
|
||||
- `minmemory` / `maxmemory` - Memory in MB
|
||||
- Adjust based on your function's needs
|
||||
|
||||
### Environment-Specific Overrides
|
||||
|
||||
Use `dev-deployment.json` for development environment (different secrets, lower resources). Fission will automatically use it when `--dev` flag is passed.
|
||||
|
||||
## Vault Encryption
|
||||
|
||||
For encrypted secrets, use the vault utility functions:
|
||||
|
||||
```python
|
||||
from vault import encrypt_vault, decrypt_vault, is_valid_vault_format
|
||||
|
||||
# Encrypt a value (run locally to generate vault string)
|
||||
encrypted = encrypt_vault("my-secret", "your-hex-key-here")
|
||||
# Result: "vault:v1:base64-encrypted-data"
|
||||
|
||||
# Store the encrypted string in your K8s secret
|
||||
# The helper will auto-decrypt if is_valid_vault_format() returns True
|
||||
```
|
||||
|
||||
**Important:** Set `CRYPTO_KEY` in your helpers.py (or via environment override) to your actual 32-byte key in hex format.
|
||||
|
||||
## Testing Strategies
|
||||
|
||||
### Unit Tests
|
||||
- Mock external dependencies (database, HTTP calls)
|
||||
- Test business logic isolation
|
||||
- Use `pytest-mock` for convenient mocking
|
||||
|
||||
### Integration Tests
|
||||
- Use a test database
|
||||
- Clean up test data after each run
|
||||
- Consider using `pytest.fixtures` for setup/teardown
|
||||
|
||||
### Local Development
|
||||
- Use `.fission/local-deployment.json` for local Fission setup
|
||||
- Override secrets/configmaps for local environment
|
||||
- Run with: `fission function test --local`
|
||||
|
||||
## Migrations
|
||||
|
||||
Place SQL migration scripts in `migrates/`:
|
||||
|
||||
```sql
|
||||
-- migrates/001_create_items_table.sql
|
||||
CREATE TABLE items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(50) DEFAULT 'active',
|
||||
created TIMESTAMP DEFAULT NOW(),
|
||||
modified TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
Apply migrations manually via psql or using a migration tool like `alembic`.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep functions small** - Single responsibility per function
|
||||
2. **Use Pydantic** - Validate all inputs with request models
|
||||
3. **Standardize errors** - Use the provided exception classes
|
||||
4. **Log appropriately** - Use `logger` from helpers (already configured)
|
||||
5. **Track users** - Use `get_user_from_headers()` for audit trails
|
||||
6. **Write tests** - Aim for high coverage of business logic
|
||||
7. **Document functions** - Add docstrings with fission config block
|
||||
8. **Avoid global state** - Functions should be stateless and idempotent
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
The template includes `.gitea/workflows/` for CI/CD:
|
||||
|
||||
- `install-dispatch.yaml` - Triggered on installation events
|
||||
- `uninstall-dispatch.yaml` - Cleanup on uninstall
|
||||
- `dev-deployment.yaml` - Development environment updates
|
||||
- `analystic-dispatch.yaml` - Analytics processing
|
||||
|
||||
Adapt these workflows for your deployment pipeline (GitHub Actions, GitLab CI, etc.).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Spec Generation Fails
|
||||
- Ensure all function files have proper fission config in docstrings
|
||||
- Run: `python -m py_compile src/*.py` to check syntax
|
||||
- Verify `build.sh` is executable: `chmod +x src/build.sh`
|
||||
|
||||
### Cannot Connect to Database
|
||||
- Check that secrets are mounted correctly: `kubectl exec <pod> -- ls /secrets/default/`
|
||||
- Verify PG_HOST, PG_PORT are correct
|
||||
- Use `check_port_open()` debug output
|
||||
- Test connection manually: `psql -h $PG_HOST -p $PG_PORT -U $PG_USER $PG_DB`
|
||||
|
||||
### Missing Dependencies
|
||||
- Ensure `requirements.txt` includes ALL dependencies (Flask is required!)
|
||||
- Check build logs for pip errors
|
||||
- Rebuild package: `./src/build.sh`
|
||||
|
||||
## Example Implementations
|
||||
|
||||
### CRUD Operation
|
||||
|
||||
```python
|
||||
from flask import request
|
||||
from helpers import init_db_connection, format_error_response
|
||||
from exceptions import ValidationError, NotFoundError, DatabaseError
|
||||
from models import ItemCreateRequest, ItemResponse
|
||||
|
||||
def create_item(event, context):
|
||||
"""Create a new item."""
|
||||
try:
|
||||
# Validate input
|
||||
data = ItemCreateRequest(**request.get_json())
|
||||
except Exception as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
conn = init_db_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO items (name, description, status) VALUES (%s, %s, %s) RETURNING id, created, modified",
|
||||
(data.name, data.description, data.status.value)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.commit()
|
||||
item = db_row_to_dict(cursor, row)
|
||||
return item
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise DatabaseError(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
### Webhook Receiver
|
||||
|
||||
```python
|
||||
def webhook_handler(event, context):
|
||||
"""Process incoming webhook."""
|
||||
# Webhook data is in event
|
||||
payload = event.get("body", {})
|
||||
signature = request.headers.get("X-Webhook-Signature")
|
||||
|
||||
# Verify signature
|
||||
if not verify_signature(payload, signature):
|
||||
raise ValidationError("Invalid signature")
|
||||
|
||||
# Process webhook
|
||||
process_webhook(payload)
|
||||
|
||||
return {"status": "processed"}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Replace placeholder values in `.fission/deployment.json`
|
||||
2. Update `SECRET_NAME` and `CONFIG_NAME` in `helpers.py` (or use create-project.sh)
|
||||
3. Implement your business logic in new function files
|
||||
4. Write tests for your functions
|
||||
5. Deploy to Kubernetes cluster with Fission
|
||||
|
||||
## Resources
|
||||
|
||||
- [Fission Documentation](https://fission.io/docs/)
|
||||
- [Fission Python Builder](https://github.com/fission/fission-python-builder)
|
||||
- [Pydantic Documentation](https://docs.pydantic.dev/)
|
||||
- [Flask Documentation](https://flask.palletsprojects.com/)
|
||||
14
fission-python/template/dev-requirements.txt
Normal file
14
fission-python/template/dev-requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Runtime dependencies (include these to match production environment)
|
||||
Flask==2.1.1
|
||||
pydantic==2.11.7
|
||||
psycopg2-binary==2.9.10
|
||||
PyNaCl==1.6.0
|
||||
requests==2.32.2
|
||||
|
||||
# Development and testing tools
|
||||
pytest==8.2.0
|
||||
pytest-mock==3.14.0
|
||||
flake8==7.0.0
|
||||
black==24.1.1
|
||||
mypy==1.8.0
|
||||
pytest-cov==4.1.0
|
||||
594
fission-python/template/docs/DEPLOYMENT.md
Normal file
594
fission-python/template/docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# Deployment Guide
|
||||
|
||||
This guide covers deploying Fission Python functions to Kubernetes, including configuration tuning, troubleshooting, and best practices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Quick Start](#quick-start)
|
||||
3. [Deployment Configuration](#deployment-configuration)
|
||||
4. [Executors](#executors)
|
||||
5. [Resource Tuning](#resource-tuning)
|
||||
6. [Environments](#environments)
|
||||
7. [Secrets Management](#secrets-management)
|
||||
8. [Rolling Updates](#rolling-updates)
|
||||
9. [Monitoring & Logging](#monitoring--logging)
|
||||
10. [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes cluster (v1.19+)
|
||||
- Fission installed (`kubectl apply -f https://github.com/fission/fission/releases/latest/download/fission-all.yaml`)
|
||||
- `fission` CLI installed and configured
|
||||
- `kubectl` configured to access cluster
|
||||
- Docker registry access (for custom images if needed)
|
||||
|
||||
## Quick Start
|
||||
|
||||
Assuming you have a project set up:
|
||||
|
||||
```bash
|
||||
# 1. Build the package (creates specs/ directory)
|
||||
cd /path/to/project
|
||||
./src/build.sh
|
||||
|
||||
# 2. Verify deployment configuration
|
||||
fission spec verify --file=.fission/deployment.json
|
||||
|
||||
# 3. Deploy to Fission
|
||||
fission deploy
|
||||
|
||||
# 4. Test deployed function
|
||||
curl http://$FISSION_ROUTER/api/items
|
||||
```
|
||||
|
||||
**That's it!** Fission will:
|
||||
- Build package.zip from src/
|
||||
- Create environment (if not exists)
|
||||
- Create package
|
||||
- Create functions from docstring metadata
|
||||
- Set up HTTP triggers
|
||||
|
||||
## Deployment Configuration
|
||||
|
||||
### deployment.json vs fission.yaml
|
||||
|
||||
This template uses `deployment.json`, **not** `fission.yaml` or `fission.json`. The Fission Python builder extracts function metadata from Python docstrings directly.
|
||||
|
||||
### Key Sections
|
||||
|
||||
#### environments
|
||||
|
||||
Define build environment:
|
||||
|
||||
```json
|
||||
{
|
||||
"environments": {
|
||||
"myproject-py": {
|
||||
"image": "ghcr.io/fission/python-env",
|
||||
"builder": "ghcr.io/fission/python-builder",
|
||||
"mincpu": 50,
|
||||
"maxcpu": 100,
|
||||
"minmemory": 50,
|
||||
"maxmemory": 500,
|
||||
"poolsize": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `image` - Runtime image (Python + libraries)
|
||||
- `builder` - Builder image (compiles dependencies)
|
||||
- Resource limits in millicores (50 = 0.05 CPU) and MB
|
||||
|
||||
#### packages
|
||||
|
||||
Define how to build your code:
|
||||
|
||||
```json
|
||||
{
|
||||
"packages": {
|
||||
"myproject": {
|
||||
"buildcmd": "./build.sh",
|
||||
"sourcearchive": "package.zip",
|
||||
"env": "myproject-py"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `buildcmd` - Build script inside builder container
|
||||
- `sourcearchive` - Generated by builder from `sourcepath`
|
||||
- `env` - Links to environment definition
|
||||
|
||||
#### function_common
|
||||
|
||||
Default configuration for all functions:
|
||||
|
||||
```json
|
||||
{
|
||||
"function_common": {
|
||||
"pkg": "myproject",
|
||||
"secrets": ["fission-myproject-env"],
|
||||
"configmaps": ["fission-myproject-config"],
|
||||
"executor": { ... },
|
||||
"mincpu": 50,
|
||||
"maxcpu": 100,
|
||||
"minmemory": 50,
|
||||
"maxmemory": 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `pkg` - Package name to use
|
||||
- `secrets` / `configmaps` - K8s resources to mount into functions
|
||||
- `executor` - Execution strategy (poolmgr or newdeploy)
|
||||
|
||||
#### secrets / configmaps
|
||||
|
||||
**Placeholder definitions only**. These inform Fission what secret names to expect, but the actual values go in real K8s secrets:
|
||||
|
||||
```json
|
||||
{
|
||||
"secrets": {
|
||||
"fission-myproject-env": {
|
||||
"literals": [
|
||||
"PG_HOST=localhost",
|
||||
"PG_PORT=5432"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create the actual secret:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--from-literal=PG_HOST=prod-db.example.com \
|
||||
--from-literal=PG_PORT=5432 \
|
||||
--from-literal=PG_USER=myuser \
|
||||
--from-literal=PG_PASS=mypassword
|
||||
```
|
||||
|
||||
## Executors
|
||||
|
||||
Fission supports two executor types:
|
||||
|
||||
### poolmgr (default)
|
||||
|
||||
Good for:
|
||||
- High-concurrency HTTP functions
|
||||
- Functions that should scale to zero
|
||||
- Stateless request/response patterns
|
||||
|
||||
Configuration:
|
||||
|
||||
```json
|
||||
"executor": {
|
||||
"select": "poolmgr",
|
||||
"poolmgr": {
|
||||
"concurrency": 1, // Requests per pod
|
||||
"requestsperpod": 1,
|
||||
"onceonly": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `concurrency` - How many concurrent requests each pod handles (usually 1 for Python due to GIL)
|
||||
- `poolsize` from environment controls number of pods in pool
|
||||
|
||||
### newdeploy
|
||||
|
||||
Good for:
|
||||
- Dedicated function instances
|
||||
- Long-running or background jobs
|
||||
- Functions needing stable network identity
|
||||
|
||||
Configuration:
|
||||
|
||||
```json
|
||||
"executor": {
|
||||
"select": "newdeploy",
|
||||
"newdeploy": {
|
||||
"minscale": 1, // Minimum pods (set to 0 for scale-to-zero)
|
||||
"maxscale": 5, // Maximum pods
|
||||
"targetcpu": 80 // Scale up when CPU > 80%
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `minscale` - Keep at least N pods running (0 = scale to zero)
|
||||
- `maxscale` - Maximum pods for auto-scaling
|
||||
- `targetcpu` - CPU threshold for scaling
|
||||
|
||||
## Resource Tuning
|
||||
|
||||
Resources are defined in millicores (m) and MB:
|
||||
|
||||
- `mincpu` / `maxcpu`: 1000 = 1 CPU core
|
||||
- `minmemory` / `maxmemory`: in MB
|
||||
|
||||
**Example settings**:
|
||||
|
||||
| Function Type | mincpu | maxcpu | minmemory | maxmemory |
|
||||
|--------------|--------|--------|-----------|-----------|
|
||||
| Simple API | 50 | 100 | 128 | 256 |
|
||||
| DB-intensive | 200 | 500 | 256 | 512 |
|
||||
| ML inference | 1000 | 2000 | 1024 | 2048 |
|
||||
|
||||
**Tips**:
|
||||
- Start conservatively, monitor, then adjust
|
||||
- Function pods are killed if they exceed `maxmemory`
|
||||
- CPU limits are enforced by Kubernetes scheduler
|
||||
- Use `minmemory` >= 128 to avoid OOM kills
|
||||
|
||||
### Checking Current Usage
|
||||
|
||||
```bash
|
||||
# Get function pods
|
||||
kubectl get pods -n fission
|
||||
|
||||
# Describe pod for resource usage
|
||||
kubectl describe pod <pod-name> -n fission
|
||||
|
||||
# See metrics (if metrics-server installed)
|
||||
kubectl top pod <pod-name> -n fission
|
||||
```
|
||||
|
||||
## Environments
|
||||
|
||||
You can have multiple deployment environments (dev, staging, prod):
|
||||
|
||||
### Using deployment.json variants
|
||||
|
||||
- `deployment.json` - Production (default)
|
||||
- `dev-deployment.json` - Development (used with `fission deploy --dev`)
|
||||
|
||||
Example `dev-deployment.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"namespace": "fission-dev",
|
||||
"function_common": {
|
||||
"secrets": ["fission-myproject-dev-env"],
|
||||
"configmaps": ["fission-myproject-dev-config"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Switching Environments
|
||||
|
||||
```bash
|
||||
# Deploy to dev
|
||||
fission deploy --dev
|
||||
|
||||
# Deploy to prod (default)
|
||||
fission deploy
|
||||
|
||||
# Specify namespace
|
||||
fission deploy --namespace fission-staging
|
||||
```
|
||||
|
||||
## Secrets Management
|
||||
|
||||
### Creating Secrets
|
||||
|
||||
```bash
|
||||
# Basic secret from literals
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--from-literal=PG_HOST=localhost \
|
||||
--from-literal=PG_PORT=5432
|
||||
|
||||
# From file
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--from-file=secrets.properties
|
||||
|
||||
# With multiple namespaces
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--namespace fission-dev \
|
||||
--from-literal=PG_HOST=dev-db.example.com
|
||||
```
|
||||
|
||||
### Encrypted Secrets (Vault)
|
||||
|
||||
To encrypt sensitive values:
|
||||
|
||||
```python
|
||||
# On your local machine (with PyNaCl installed)
|
||||
from vault import encrypt_vault
|
||||
|
||||
key = "your-32-byte-hex-key-here..." # 64 hex chars
|
||||
encrypted = encrypt_vault("super-secret-password", key)
|
||||
print(encrypted) # vault:v1:base64...
|
||||
```
|
||||
|
||||
Store the encrypted string in K8s secret:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--from-literal=PG_PASS='vault:v1:base64...'
|
||||
```
|
||||
|
||||
Set `CRYPTO_KEY` in `helpers.py` to the hex key:
|
||||
|
||||
```python
|
||||
CRYPTO_KEY = "e24ad6ceed96115520f6e6dc8a0da506ae9a706823d54f30a5b75447ecf477b6"
|
||||
```
|
||||
|
||||
**Important**: Rotate keys periodically. When changing key, re-encrypt all secrets.
|
||||
|
||||
### Updating Secrets
|
||||
|
||||
```bash
|
||||
# Edit secret
|
||||
kubectl edit secret fission-myproject-env
|
||||
|
||||
# Update single key
|
||||
kubectl set secret secret fission-myproject-env \
|
||||
--from-literal=PG_PASS='new-password'
|
||||
|
||||
# Roll function to pick up new secret
|
||||
fission function update --name my-function
|
||||
```
|
||||
|
||||
## Rolling Updates
|
||||
|
||||
### Deploy Changes
|
||||
|
||||
```bash
|
||||
# Build and deploy
|
||||
./src/build.sh
|
||||
fission deploy
|
||||
|
||||
# Or deploy single function
|
||||
fission function update --name my-function
|
||||
```
|
||||
|
||||
### Zero-Downtime Deployments
|
||||
|
||||
Fission handles rolling updates automatically:
|
||||
1. New package is built
|
||||
2. New function pods are created with new code
|
||||
3. Old pods continue serving traffic until new pods are ready
|
||||
4. Old pods are terminated
|
||||
|
||||
**No downtime** by default for HTTP triggers.
|
||||
|
||||
### Canary Deployments
|
||||
|
||||
For canary deployments:
|
||||
1. Deploy new version with different function name: `my-function-v2`
|
||||
2. Route some traffic using ingress annotations or service mesh
|
||||
3. Gradually shift traffic
|
||||
4. Delete old function
|
||||
|
||||
## Monitoring & Logging
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# All function logs in namespace
|
||||
kubectl logs -n fission -l fission-function=true --tail=100
|
||||
|
||||
# Specific function
|
||||
kubectl logs -n fission -l fission-function/name=my-function --tail=100
|
||||
|
||||
# Follow logs
|
||||
kubectl logs -n fission -l fission-function/name=my-function -f
|
||||
|
||||
# Container logs (if multiple containers)
|
||||
kubectl logs -n fission -l fission-function/name=my-function -c builder
|
||||
```
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Use `logger` from `helpers.py` (already configured):
|
||||
|
||||
```python
|
||||
logger.info("Processing request", extra={"user_id": user_id})
|
||||
logger.error("Database error", exc_info=True, extra={"query": sql})
|
||||
```
|
||||
|
||||
Logs are collected by the container runtime and available via `kubectl logs`.
|
||||
|
||||
### Metrics
|
||||
|
||||
Fission exposes Prometheus metrics:
|
||||
|
||||
```bash
|
||||
# Get metrics endpoint
|
||||
kubectl port-forward -n fission svc/fission-prometheus-server 9090:9090
|
||||
|
||||
# Or query via kubectl
|
||||
kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/fission/pods/*" | jq .
|
||||
```
|
||||
|
||||
Metrics include:
|
||||
- Request rate
|
||||
- Error rate
|
||||
- Response latency
|
||||
- Pod counts
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Deployment Fails
|
||||
|
||||
**Error**: `Error building package`
|
||||
|
||||
Check:
|
||||
- `build.sh` is executable: `chmod +x src/build.sh`
|
||||
- All dependencies in `requirements.txt` are valid
|
||||
- Python syntax is correct: `python -m py_compile src/*.py`
|
||||
|
||||
**Error**: `Function not found after deploy`
|
||||
|
||||
Check:
|
||||
- Fission docstring block is properly formatted (must be ````fission` with backticks)
|
||||
- No YAML/JSON syntax errors in docstring
|
||||
- Function file is in `src/` directory
|
||||
|
||||
### Function Not Responding
|
||||
|
||||
**Check pod status**:
|
||||
```bash
|
||||
kubectl get pods -n fission -l fission-function/name=my-function
|
||||
```
|
||||
|
||||
**Pod stuck in Pending** - Insufficient resources or image pull error
|
||||
|
||||
**Pod stuck in ContainerCreating** - Volume mount issue or image pull
|
||||
|
||||
**Pod CrashLoopBackOff** - Application error. Check logs:
|
||||
```bash
|
||||
kubectl logs -n fission <pod-name> --previous
|
||||
```
|
||||
|
||||
### Configuration Not Loading
|
||||
|
||||
**Secrets not available**:
|
||||
```bash
|
||||
# Check secret exists in correct namespace
|
||||
kubectl get secret fission-myproject-env -n fission
|
||||
|
||||
# Verify secret is mounted
|
||||
kubectl exec -it <pod-name> -n fission -- ls /secrets/default/
|
||||
```
|
||||
|
||||
**ConfigMaps not available**:
|
||||
```bash
|
||||
kubectl get configmap fission-myproject-config -n fission
|
||||
```
|
||||
|
||||
**Profusion parms not reading**:
|
||||
- Ensure `SECRET_NAME` in helpers.py matches created secret name
|
||||
- Path format: `/secrets/{namespace}/{secret-name}/{key}`
|
||||
|
||||
### Slow Performance
|
||||
|
||||
1. **Increase resources**: Raise `maxmemory` and `maxcpu`
|
||||
2. **Connection pooling**: Use connection pooler like PgBouncer for heavy DB load
|
||||
3. **Database queries**: Check slow queries, add indexes
|
||||
4. **Cold starts**: Set `minscale: 1` with newdeploy executor to keep warm
|
||||
|
||||
### Database Connection Errors
|
||||
|
||||
**Error**: `could not connect to server: Connection refused`
|
||||
|
||||
- Verify database is reachable from cluster
|
||||
- Check security groups/network policies
|
||||
- Test connectivity from pod:
|
||||
```bash
|
||||
kubectl exec -it <pod-name> -n fission -- nc -zv $PG_HOST $PG_PORT
|
||||
```
|
||||
|
||||
**Error**: `password authentication failed`
|
||||
|
||||
- Verify credentials in secret
|
||||
- Check PG_USER format (with `plaintext:` prefix for vault)
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Custom Runtime Image
|
||||
|
||||
If you need system packages:
|
||||
|
||||
```dockerfile
|
||||
FROM ghcr.io/fission/python-env:latest
|
||||
RUN apk add --no-cache gcc libffi-dev
|
||||
```
|
||||
|
||||
Build and push:
|
||||
```bash
|
||||
docker build -t myregistry/python-custom:latest .
|
||||
docker push myregistry/python-custom:latest
|
||||
```
|
||||
|
||||
Update `deployment.json`:
|
||||
```json
|
||||
"environments": {
|
||||
"myproject-py": {
|
||||
"image": "myregistry/python-custom:latest",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables from ConfigMap
|
||||
|
||||
```json
|
||||
"configmaps": {
|
||||
"fission-myproject-config": {
|
||||
"literals": [
|
||||
"LOG_LEVEL=DEBUG",
|
||||
"FEATURE_FLAG_X=true"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Access in code:
|
||||
```python
|
||||
import os
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO")
|
||||
```
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
Use `function_pre_remove` and `function_post_remove` in deployment hooks:
|
||||
|
||||
```json
|
||||
"hooks": {
|
||||
"function_pre_remove": [
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://cleanup-service/cleanup",
|
||||
"timeout": 30000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Common Commands Reference
|
||||
|
||||
```bash
|
||||
# List functions
|
||||
fission function list
|
||||
|
||||
# Test function manually
|
||||
fission function test --name my-function
|
||||
|
||||
# Update single function
|
||||
fission function update --name my-function
|
||||
|
||||
# Delete function
|
||||
fission function delete --name my-function
|
||||
|
||||
# View function pods
|
||||
kubectl get pods -n fission -l fission-function/name=my-function
|
||||
|
||||
# View logs
|
||||
kubectl logs -n fission -l fission-function/name=my-function -f
|
||||
|
||||
# Exec into pod
|
||||
kubectl exec -it <pod-name> -n fission -- /bin/sh
|
||||
|
||||
# Describe function
|
||||
fission function describe --name my-function
|
||||
|
||||
# Get function YAML
|
||||
fission function get --name my-function -o yaml
|
||||
|
||||
# Check Fission version
|
||||
fission version
|
||||
|
||||
# Check Fission status
|
||||
kubectl get pods -n fission
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Fission Deployment Documentation](https://fission.io/docs/usage/deploy/)
|
||||
- [Fission Executors](https://fission.io/docs/architecture/executor/)
|
||||
- [Kubernetes Resource Management](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/)
|
||||
- [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/)
|
||||
582
fission-python/template/docs/MIGRATIONS.md
Normal file
582
fission-python/template/docs/MIGRATIONS.md
Normal file
@@ -0,0 +1,582 @@
|
||||
# Database Migrations
|
||||
|
||||
This guide covers managing database schema changes in Fission Python projects.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Migration Files](#migration-files)
|
||||
3. [Applying Migrations](#applying-migrations)
|
||||
4. [Writing Migrations](#writing-migrations)
|
||||
5. [Best Practices](#best-practices)
|
||||
6. [Rollback Strategies](#rollback-strategies)
|
||||
7. [Automation](#automation)
|
||||
|
||||
## Overview
|
||||
|
||||
Database schema changes should be managed through versioned migration scripts, not manual `CREATE TABLE` statements.
|
||||
|
||||
This template uses **plain SQL migration files** (`.sql`), which provide:
|
||||
- Version control of schema changes
|
||||
- Repeatable application to different environments
|
||||
- Clear upgrade/downgrade paths
|
||||
- Audit trail of schema evolution
|
||||
|
||||
## Migration Files
|
||||
|
||||
Place SQL migration scripts in the `migrates/` directory:
|
||||
|
||||
```
|
||||
migrates/
|
||||
├── 001_initial_schema.sql
|
||||
├── 002_add_user_email.sql
|
||||
├── 003_create_indexes.sql
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Naming convention**:
|
||||
- Prefix with sequential number (zero-padded for sorting)
|
||||
- Descriptive name after underscore
|
||||
- `.sql` extension
|
||||
- Numbers should be unique and monotonically increasing
|
||||
|
||||
### Initial Schema Example
|
||||
|
||||
```sql
|
||||
-- migrates/001_create_items_table.sql
|
||||
-- Create items table
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(50) DEFAULT 'active',
|
||||
metadata JSONB,
|
||||
created TIMESTAMPTZ DEFAULT NOW(),
|
||||
modified TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add indexes
|
||||
CREATE INDEX idx_items_status ON items(status);
|
||||
CREATE INDEX idx_items_created ON items(created);
|
||||
|
||||
-- Add comments
|
||||
COMMENT ON TABLE items IS 'Stores item records';
|
||||
COMMENT ON COLUMN items.status IS 'Item status: active, inactive, pending';
|
||||
```
|
||||
|
||||
## Applying Migrations
|
||||
|
||||
### Manually
|
||||
|
||||
```bash
|
||||
# Connect to database
|
||||
psql -h localhost -U postgres -d mydb
|
||||
|
||||
# Run migration file
|
||||
\i migrates/001_create_items_table.sql
|
||||
|
||||
# Run all migrations in order (bash script)
|
||||
for file in $(ls migrates/*.sql | sort); do
|
||||
echo "Applying $file..."
|
||||
psql -h localhost -U postgres -d mydb -f "$file"
|
||||
done
|
||||
```
|
||||
|
||||
### Automatically from Python
|
||||
|
||||
Create a simple migration runner:
|
||||
|
||||
```python
|
||||
# src/migrate.py (not part of function, standalone script)
|
||||
import os
|
||||
import psycopg2
|
||||
from helpers import init_db_connection
|
||||
|
||||
def run_migrations():
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create migrations tracking table if not exists
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
applied_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# Get already-applied migrations
|
||||
cursor.execute("SELECT version FROM schema_migrations")
|
||||
applied = {row[0] for row in cursor.fetchall()}
|
||||
|
||||
# Find migration files
|
||||
migrates_dir = os.path.join(os.path.dirname(__file__), "..", "migrates")
|
||||
files = sorted([
|
||||
f for f in os.listdir(migrates_dir)
|
||||
if f.endswith(".sql")
|
||||
])
|
||||
|
||||
# Apply pending migrations
|
||||
for filename in files:
|
||||
# Extract version number
|
||||
version = int(filename.split("_")[0])
|
||||
if version in applied:
|
||||
print(f"Skipping {filename} (already applied)")
|
||||
continue
|
||||
|
||||
path = os.path.join(migrates_dir, filename)
|
||||
print(f"Applying {filename}...")
|
||||
with open(path, 'r') as f:
|
||||
sql = f.read()
|
||||
|
||||
try:
|
||||
cursor.execute(sql)
|
||||
cursor.execute(
|
||||
"INSERT INTO schema_migrations (version, name) VALUES (%s, %s)",
|
||||
(version, filename)
|
||||
)
|
||||
conn.commit()
|
||||
print(f" ✓ Applied {filename}")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f" ✗ Failed: {e}")
|
||||
raise
|
||||
|
||||
conn.close()
|
||||
print("All migrations applied")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_migrations()
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python src/migrate.py
|
||||
```
|
||||
|
||||
### Using Migration Tools
|
||||
|
||||
For more advanced features (rollbacks, branching), consider:
|
||||
|
||||
- **[Alembic](https://alembic.sqlalchemy.org/)** - Database migration tool for SQLAlchemy (if using ORM)
|
||||
- **[pg migrator](https://github.com/heroku/pg-migrator)** - Heroku's migration tool
|
||||
- **[goose](https://github.com/pressly/goose)** - Multi-database migration tool (can use from Python)
|
||||
- **[yoyo-migrations](https://github.com/gugulet-h/yoyo-migrations)** - Python-based migrations
|
||||
|
||||
## Writing Migrations
|
||||
|
||||
### Principles
|
||||
|
||||
1. **Idempotent** - Script should succeed if run multiple times
|
||||
2. **Additive first** - Add columns/tables before removing/dropping
|
||||
3. **Backward compatible** - New schema should work with old code
|
||||
4. **Atomic** - One logical change per migration file
|
||||
5. **Test locally** - Apply to test database before production
|
||||
|
||||
### Common Operations
|
||||
|
||||
#### Create Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
total DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add foreign key
|
||||
ALTER TABLE orders
|
||||
ADD CONSTRAINT fk_orders_user
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES users(id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- Index for performance
|
||||
CREATE INDEX idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX idx_orders_created_at ON orders(created_at);
|
||||
```
|
||||
|
||||
#### Add Column
|
||||
|
||||
```sql
|
||||
-- Add nullable column (safe, backward compatible)
|
||||
ALTER TABLE orders
|
||||
ADD COLUMN shipping_address JSONB;
|
||||
|
||||
-- Add column with default (be careful with large tables!)
|
||||
-- This rewrites entire table - use cautiously
|
||||
ALTER TABLE orders
|
||||
ADD COLUMN tax_amount DECIMAL(10,2) DEFAULT 0.00;
|
||||
```
|
||||
|
||||
#### Rename Column
|
||||
|
||||
```sql
|
||||
-- PostgreSQL 9.2+ supports RENAME COLUMN
|
||||
ALTER TABLE orders
|
||||
RENAME COLUMN total TO order_total;
|
||||
```
|
||||
|
||||
#### Modify Column Type
|
||||
|
||||
```sql
|
||||
-- Change VARCHAR length
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN email TYPE VARCHAR(320);
|
||||
|
||||
-- Convert to different type (use USING clause)
|
||||
ALTER TABLE orders
|
||||
ALTER COLUMN status TYPE VARCHAR(100)
|
||||
USING status::VARCHAR(100);
|
||||
```
|
||||
|
||||
#### Create Index
|
||||
|
||||
```sql
|
||||
-- Simple index
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
|
||||
-- Unique index
|
||||
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
|
||||
|
||||
-- Partial index (only active users)
|
||||
CREATE INDEX idx_users_active ON users(id)
|
||||
WHERE status = 'active';
|
||||
|
||||
-- Multi-column index
|
||||
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
|
||||
```
|
||||
|
||||
#### Drop Column/Table
|
||||
|
||||
```sql
|
||||
-- First, ensure no one is using it
|
||||
-- Consider using SET DEFAULT then dropping in subsequent migration
|
||||
|
||||
-- Drop column
|
||||
ALTER TABLE orders
|
||||
DROP COLUMN IF EXISTS old_column;
|
||||
|
||||
-- Drop table (dangerous!)
|
||||
DROP TABLE IF EXISTS old_logs;
|
||||
```
|
||||
|
||||
### Data Migrations
|
||||
|
||||
Sometimes you need to transform data:
|
||||
|
||||
```sql
|
||||
-- Backfill new column from existing data
|
||||
UPDATE orders
|
||||
SET shipping_address = jsonb_build_object(
|
||||
'street', address_street,
|
||||
'city', address_city,
|
||||
'zip', address_zip
|
||||
)
|
||||
WHERE shipping_address IS NULL;
|
||||
|
||||
-- Migrate enum values
|
||||
UPDATE products
|
||||
SET status = 'active' WHERE status = 'ACTIVE';
|
||||
|
||||
-- Clean up duplicates
|
||||
WITH duplicates AS (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at) AS rn
|
||||
FROM users
|
||||
)
|
||||
DELETE FROM users WHERE id IN (SELECT id FROM duplicates WHERE rn > 1);
|
||||
```
|
||||
|
||||
### Transactional Migrations
|
||||
|
||||
Wrap critical migrations in transactions:
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
|
||||
-- Multiple related operations
|
||||
ALTER TABLE orders ADD COLUMN shipping_id UUID;
|
||||
UPDATE orders SET shipping_id = uuid_generate_v4() WHERE shipping_id IS NULL;
|
||||
ALTER TABLE orders ALTER COLUMN shipping_id SET NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**Note**: DDL statements in PostgreSQL auto-commit, so `BEGIN`/`COMMIT` may not work as expected for schema changes. For complex multi-step changes, consider using advisory locks or deployment coordination.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Do's
|
||||
|
||||
1. **Test migrations on copy of production database** before applying to prod
|
||||
2. **Keep migrations small** - One logical change per file
|
||||
3. **Write data migrations as separate files** from schema migrations
|
||||
4. **Use `IF NOT EXISTS` and `IF EXISTS`** to make migrations idempotent
|
||||
5. **Never drop columns/tables in the same migration you add them** - Separate to allow rollback
|
||||
6. **Document why** - Add comments explaining the purpose
|
||||
7. **Consider indexes** - Add indexes for frequently queried columns in same migration as table creation
|
||||
8. **Use UUIDs** for primary keys (`gen_random_uuid()` in PostgreSQL 13+)
|
||||
9. **Add `created_at` and `updated_at` timestamps** to all tables
|
||||
10. **Version numbers must be unique and sequential**
|
||||
|
||||
### ❌ Don'ts
|
||||
|
||||
1. **Don't modify already-applied migrations** - They're part of history
|
||||
2. **Don't skip version numbers** - Creates gaps but not critical
|
||||
3. **Don't use destructive operations without backup** - `DROP COLUMN`, `DROP TABLE`
|
||||
4. **Don't run long-running migrations during peak hours** - Use low-traffic windows
|
||||
5. **Don't add NOT NULL without default** on non-empty tables - Will fail due to existing NULL rows
|
||||
6. **Don't assume order of execution** - Always number sequentially
|
||||
7. **Don't mix unrelated changes** in one migration file
|
||||
|
||||
### Zero-Downtime Migrations
|
||||
|
||||
#### Adding Column
|
||||
|
||||
```sql
|
||||
-- Step 1: Add column as nullable or with default (fast)
|
||||
ALTER TABLE orders ADD COLUMN status VARCHAR(50);
|
||||
|
||||
-- Step 2: Deploy code that writes to new column
|
||||
-- Your application updates to populate status
|
||||
|
||||
-- Step 3: Backfill existing rows (if needed)
|
||||
UPDATE orders SET status = 'completed' WHERE status IS NULL AND shipped_at IS NOT NULL;
|
||||
|
||||
-- Step 4: Make column NOT NULL (if needed) - only after all rows have values
|
||||
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
|
||||
```
|
||||
|
||||
#### Renaming Column
|
||||
|
||||
```sql
|
||||
-- Step 1: Add new column
|
||||
ALTER TABLE orders ADD COLUMN order_status VARCHAR(50);
|
||||
|
||||
-- Step 2: Deploy code writing to both old and new columns (dual-write)
|
||||
|
||||
-- Step 3: Backfill data
|
||||
UPDATE orders SET order_status = status;
|
||||
|
||||
-- Step 4: Deploy code reading from new column, stop writing to old
|
||||
|
||||
-- Step 5: Drop old column (in separate migration)
|
||||
ALTER TABLE orders DROP COLUMN status;
|
||||
```
|
||||
|
||||
## Rollback Strategies
|
||||
|
||||
### Manual Rollback
|
||||
|
||||
For each migration, you may want to write a corresponding "down" migration:
|
||||
|
||||
```sql
|
||||
-- 002_add_user_email.sql (UP)
|
||||
ALTER TABLE users ADD COLUMN email VARCHAR(320);
|
||||
|
||||
-- 002_add_user_email_rollback.sql (DOWN)
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS email;
|
||||
```
|
||||
|
||||
Store rollback scripts alongside migrations or in separate `rollbacks/` directory.
|
||||
|
||||
### Point-in-Time Recovery
|
||||
|
||||
**Best strategy**: Restore database from backup to point before bad migration, then re-apply good migrations.
|
||||
|
||||
```bash
|
||||
# Restore from PITR backup (if using WAL archiving)
|
||||
pg_restore -h localhost -U postgres -d mydb --point-in-time="2025-03-18 10:30:00"
|
||||
|
||||
# Re-run migrations up to good version
|
||||
python src/migrate.py # But this applies all, so need selective
|
||||
```
|
||||
|
||||
### Selective Rollback Script
|
||||
|
||||
```python
|
||||
# rollback.py
|
||||
import sys
|
||||
from helpers import init_db_connection
|
||||
|
||||
def rollback(to_version: int):
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Find migrations after target version
|
||||
cursor.execute("""
|
||||
SELECT version, name
|
||||
FROM schema_migrations
|
||||
WHERE version > %s
|
||||
ORDER BY version DESC
|
||||
""", (to_version,))
|
||||
|
||||
migrations = cursor.fetchall()
|
||||
|
||||
for version, name in migrations:
|
||||
rollback_file = f"rollbacks/{version:03d}_{name.split('_', 1)[1]}.sql"
|
||||
print(f"Rolling back {name} using {rollback_file}...")
|
||||
with open(rollback_file, 'r') as f:
|
||||
sql = f.read()
|
||||
cursor.execute(sql)
|
||||
cursor.execute("DELETE FROM schema_migrations WHERE version = %s", (version,))
|
||||
conn.commit()
|
||||
print(f" Rolled back {name}")
|
||||
|
||||
conn.close()
|
||||
print(f"Rolled back to version {to_version}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
target = int(sys.argv[1])
|
||||
rollback(target)
|
||||
```
|
||||
|
||||
## Automation
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
In your deployment pipeline:
|
||||
|
||||
```bash
|
||||
# Before deploying new code
|
||||
python src/migrate.py
|
||||
|
||||
# If migrations fail, abort deployment
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Migrations failed, aborting deployment"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deploy new code
|
||||
fission deploy
|
||||
```
|
||||
|
||||
### Pre-deployment Hooks
|
||||
|
||||
Use Fission hooks to run migrations automatically:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"function_pre_deploy": [
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://migration-service/migrate",
|
||||
"timeout": 300000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or simpler: run migration as part of `build.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
# src/build.sh
|
||||
|
||||
# Install dependencies
|
||||
pip3 install -r requirements.txt -t .
|
||||
|
||||
# Run migrations against test DB (or do nothing, migrations are separate)
|
||||
# python ../migrate.py
|
||||
|
||||
# Package up
|
||||
cp -r . ${DEPLOY_PKG}
|
||||
```
|
||||
|
||||
### Database Change Management Tools
|
||||
|
||||
Consider specialized tools for larger teams:
|
||||
- **[Flyway](https://flywaydb.org/)** - Java-based, supports repeatable migrations
|
||||
- **[Liquibase](https://www.liquibase.org/)** - XML/YAML/JSON migrations
|
||||
- **[Prisma Migrate](https://www.prisma.io/docs/concepts/components/prisma-migrate)** - If using Prisma ORM
|
||||
- **[Alembic](https://alembic.sqlalchemy.org/)** - Python, SQLAlchemy-specific
|
||||
|
||||
## Example Workflow
|
||||
|
||||
1. **Create migration**:
|
||||
```bash
|
||||
touch migrates/004_add_orders_table.sql
|
||||
```
|
||||
|
||||
2. **Write SQL**:
|
||||
```sql
|
||||
CREATE TABLE orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
total DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_orders_user_id ON orders(user_id);
|
||||
```
|
||||
|
||||
3. **Test locally**:
|
||||
```bash
|
||||
createdb test_migration
|
||||
psql test_migration -f migrates/004_add_orders_table.sql
|
||||
```
|
||||
|
||||
4. **Commit migration file**:
|
||||
```bash
|
||||
git add migrates/004_add_orders_table.sql
|
||||
git commit -m "Add orders table"
|
||||
```
|
||||
|
||||
5. **Apply to staging**:
|
||||
```bash
|
||||
# Update dev-deployment.json if new env vars needed
|
||||
fission deploy --dev
|
||||
python src/migrate.py
|
||||
```
|
||||
|
||||
6. **Apply to production**:
|
||||
```bash
|
||||
# Maintenance window or blue-green deployment
|
||||
fission deploy
|
||||
python src/migrate.py
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Migration Fails
|
||||
|
||||
Check error message:
|
||||
- **syntax error**: Validate SQL with `psql -c "SQL"` manually
|
||||
- **duplicate column**: Migration already applied, check `schema_migrations`
|
||||
- **permission denied**: DB user lacks ALTER/CREATE privileges
|
||||
- **lock timeout**: Another migration running, wait or kill process
|
||||
|
||||
### Migration Already Applied But Failed
|
||||
|
||||
If migration was recorded in `schema_migrations` but failed mid-way:
|
||||
|
||||
1. Manually revert partial changes or fix broken state
|
||||
2. Delete row from `schema_migrations`: `DELETE FROM schema_migrations WHERE version = 4;`
|
||||
3. Re-run migration
|
||||
|
||||
### Long-Running Migration
|
||||
|
||||
Large table alterations can lock rows and cause downtime:
|
||||
|
||||
- Run during low-traffic period
|
||||
- Use `CONCURRENTLY` for index creation (PostgreSQL):
|
||||
```sql
|
||||
CREATE INDEX CONCURRENTLY idx_orders_created ON orders(created_at);
|
||||
```
|
||||
- For adding NOT NULL, populate values first with UPDATE, then add constraint
|
||||
- Consider using `pg_repack` for online table reorganization
|
||||
|
||||
## Summary
|
||||
|
||||
- Store migrations in `migrates/` directory, numbered sequentially
|
||||
- Use `init_db_connection()` to run migrations programmatically
|
||||
- Test migrations on staging database before production
|
||||
- Keep migrations backward compatible when possible
|
||||
- Have a rollback plan (backups, down scripts)
|
||||
- Integrate migrations into CI/CD pipeline
|
||||
438
fission-python/template/docs/SECRETS.md
Normal file
438
fission-python/template/docs/SECRETS.md
Normal file
@@ -0,0 +1,438 @@
|
||||
# Secrets and Configuration Management
|
||||
|
||||
This guide covers best practices for managing secrets and configuration in Fission Python functions.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Kubernetes Secrets vs ConfigMaps](#kubernetes-secrets-vs-configmaps)
|
||||
3. [Secrets in Fission](#secrets-in-fission)
|
||||
4. [Vault Encryption](#vault-encryption)
|
||||
5. [Secret Rotation](#secret-rotation)
|
||||
6. [Configuration Precedence](#configuration-precedence)
|
||||
7. [Best Practices](#best-practices)
|
||||
|
||||
## Overview
|
||||
|
||||
Sensitive data (passwords, API keys) should **never** be:
|
||||
- Committed to Git
|
||||
- Hardcoded in source code
|
||||
- Passed as plaintext in deployment files
|
||||
|
||||
Instead, use:
|
||||
- **Kubernetes Secrets** - For sensitive values
|
||||
- **Kubernetes ConfigMaps** - For non-sensitive configuration
|
||||
- **Vault encryption** - For encrypting secrets at rest in K8s
|
||||
|
||||
## Kubernetes Secrets vs ConfigMaps
|
||||
|
||||
| Feature | Secrets | ConfigMaps |
|
||||
|---------|---------|------------|
|
||||
| Purpose | Sensitive data (passwords, tokens, keys) | Non-sensitive config (endpoints, feature flags) |
|
||||
| Storage | Base64 encoded (not encrypted by default) | Plain text |
|
||||
| Mount as | Files in `/secrets/` | Files in `/configs/` |
|
||||
| Access in code | `get_secret(key)` | `get_config(key)` |
|
||||
| Max size | 1MB total | 1MB total |
|
||||
| Can be encrypted | Yes, with K8s encryption at rest | Yes |
|
||||
|
||||
**Rule of thumb**:
|
||||
- Use Secrets for: database passwords, API tokens, encryption keys
|
||||
- Use ConfigMaps for: service URLs, feature flags, log levels, non-sensitive constants
|
||||
|
||||
## Secrets in Fission
|
||||
|
||||
### Defining Secret References in deployment.json
|
||||
|
||||
In `.fission/deployment.json`, declare the secret names your functions expect:
|
||||
|
||||
```json
|
||||
{
|
||||
"function_common": {
|
||||
"secrets": ["fission-myproject-env"],
|
||||
"configmaps": ["fission-myproject-config"]
|
||||
},
|
||||
"secrets": {
|
||||
"fission-myproject-env": {
|
||||
"literals": [
|
||||
"PG_HOST=localhost",
|
||||
"PG_PORT=5432"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The `literals` array here is **only documentation**. The actual secret values must be created separately in Kubernetes.
|
||||
|
||||
### Creating Actual Kubernetes Secrets
|
||||
|
||||
```bash
|
||||
# Create secret with multiple keys
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--from-literal=PG_HOST=postgres.example.com \
|
||||
--from-literal=PG_PORT=5432 \
|
||||
--from-literal=PG_DB=mydb \
|
||||
--from-literal=PG_USER=myuser \
|
||||
--from-literal=PG_PASS='my-password'
|
||||
|
||||
# In a specific namespace (Fission namespace)
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--namespace fission \
|
||||
--from-literal=...
|
||||
|
||||
# From environment file
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--namespace fission \
|
||||
--from-env-file=.env
|
||||
```
|
||||
|
||||
### How Secrets Are Mounted
|
||||
|
||||
Fission mounts secrets as files in the function pod:
|
||||
|
||||
```
|
||||
/secrets/{namespace}/{secret-name}/{key}
|
||||
```
|
||||
|
||||
Example path: `/secrets/default/fission-myproject-env/PG_HOST`
|
||||
|
||||
The `helpers.py` `get_secret()` function reads from this path:
|
||||
|
||||
```python
|
||||
def get_secret(key: str, default=None):
|
||||
namespace = get_current_namespace()
|
||||
path = f"/secrets/{namespace}/{SECRET_NAME}/{key}"
|
||||
with open(path, "r") as f:
|
||||
return f.read()
|
||||
```
|
||||
|
||||
**Note**: `SECRET_NAME` must match the K8s secret name (`fission-myproject-env`).
|
||||
|
||||
### Reading Secrets in Code
|
||||
|
||||
```python
|
||||
from helpers import get_secret
|
||||
|
||||
# With default fallback
|
||||
db_host = get_secret("PG_HOST", "localhost")
|
||||
db_port = int(get_secret("PG_PORT", "5432"))
|
||||
db_user = get_secret("PG_USER")
|
||||
db_pass = get_secret("PG_PASS")
|
||||
|
||||
# If key missing and no default, returns None
|
||||
maybe_value = get_secret("OPTIONAL_KEY")
|
||||
```
|
||||
|
||||
**Always provide a default** for non-critical configuration to avoid crashes if secret is missing.
|
||||
|
||||
### ConfigMaps
|
||||
|
||||
Same pattern, different mount path: `/configs/{namespace}/{configmap-name}/{key}`
|
||||
|
||||
```python
|
||||
from helpers import get_config
|
||||
|
||||
api_endpoint = get_config("API_ENDPOINT", "http://default.api")
|
||||
feature_flag = get_config("FEATURE_X_ENABLED", "false")
|
||||
```
|
||||
|
||||
Create ConfigMap:
|
||||
|
||||
```bash
|
||||
kubectl create configmap fission-myproject-config \
|
||||
--namespace fission \
|
||||
--from-literal=API_ENDPOINT=https://api.example.com \
|
||||
--from-literal=FEATURE_X_ENABLED=true
|
||||
```
|
||||
|
||||
## Vault Encryption
|
||||
|
||||
To encrypt secrets before storing in K8s:
|
||||
|
||||
### Generate Encryption Key
|
||||
|
||||
```bash
|
||||
# Generate 32-byte (64 hex char) random key
|
||||
openssl rand -hex 32
|
||||
# Example output: e24ad6ceed96115520f6e6dc8a0da506ae9a706823d54f30a5b75447ecf477b6
|
||||
```
|
||||
|
||||
### Encrypt a Value
|
||||
|
||||
```python
|
||||
# Encrypt locally
|
||||
from vault import encrypt_vault
|
||||
|
||||
key = "e24ad6ceed96115520f6e6dc8a0da506ae9a706823d54f30a5b75447ecf477b6"
|
||||
encrypted = encrypt_vault("my-secret-password", key)
|
||||
print(encrypted)
|
||||
# Output: vault:v1:base64-encrypted-data
|
||||
```
|
||||
|
||||
### Store Encrypted Value
|
||||
|
||||
Create K8s secret with encrypted value:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--from-literal=PG_PASS='vault:v1:base64...'
|
||||
```
|
||||
|
||||
### Configure decryption in helpers.py
|
||||
|
||||
```python
|
||||
CRYPTO_KEY = "e24ad6ceed96115520f6e6dc8a0da506ae9a706823d54f30a5b75447ecf477b6"
|
||||
```
|
||||
|
||||
### Automatic Decryption
|
||||
|
||||
`get_secret()` and `get_config()` automatically:
|
||||
1. Read the file content
|
||||
2. Detect if it starts with `vault:v1:` (using `is_valid_vault_format()`)
|
||||
3. Decrypt using `CRYPTO_KEY` if encrypted
|
||||
4. Return plaintext
|
||||
|
||||
**No code changes needed** - it "just works".
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# Test decryption
|
||||
kubectl get secret fission-myproject-env -o jsonpath='{.data.PG_PASS}' | base64 -d
|
||||
# Should show: vault:v1:...
|
||||
|
||||
# Exec into pod and manually check
|
||||
kubectl exec -it <pod-name> -- python3 -c "from helpers import get_secret; print(get_secret('PG_PASS'))"
|
||||
# Should print decrypted value
|
||||
```
|
||||
|
||||
## Secret Rotation
|
||||
|
||||
### Rotating a Secret
|
||||
|
||||
1. **Generate new value** (new password, new API key)
|
||||
2. **Encrypt** (if using vault)
|
||||
3. **Update K8s secret**:
|
||||
```bash
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--dry-run=client \
|
||||
--from-literal=PG_PASS='new-password' \
|
||||
-o yaml | kubectl apply -f -
|
||||
```
|
||||
4. **Update actual external system** (database, API provider) with new value
|
||||
5. **Verify applications work** (check logs)
|
||||
6. **Remove old value** (if rotating from old to new, both may need to coexist temporarily)
|
||||
|
||||
### Rotating Vault Encryption Key
|
||||
|
||||
**Warning**: Changing `CRYPTO_KEY` requires re-encrypting all secrets!
|
||||
|
||||
1. Deploy new code with updated `CRYPTO_KEY` **temporarily** pointing to new key
|
||||
2. Create new K8s secrets with values encrypted under new key (or re-encrypt via script)
|
||||
3. Switch `CRYPTO_KEY` back to original (or both keys during transition) - actually this is complex
|
||||
|
||||
**Recommended**: Have two keys during rotation:
|
||||
```python
|
||||
CRYPTO_KEYS = [
|
||||
"old-key-hex...", # Keep for decrypting old secrets
|
||||
"new-key-hex..." # Use for encrypting new/updated secrets
|
||||
]
|
||||
```
|
||||
|
||||
Then update `decrypt_vault()` to try each key until one works. After all secrets migrated, remove old key.
|
||||
|
||||
## Configuration Precedence
|
||||
|
||||
Fission supports multiple deployment configuration files:
|
||||
|
||||
1. **deployment.json** - Base configuration (committed to repo)
|
||||
2. **dev-deployment.json** - Development overrides (usually not committed)
|
||||
3. **local-deployment.json** - Local overrides (gitignored)
|
||||
|
||||
### Override Priority
|
||||
|
||||
When using `fission deploy --dev`, Fission loads:
|
||||
- Base configuration from `deployment.json`
|
||||
- Overlay from `dev-deployment.json`
|
||||
|
||||
Values in the overlay file replace or extend base values.
|
||||
|
||||
**Example**: Override secret name for dev:
|
||||
|
||||
**deployment.json**:
|
||||
```json
|
||||
{
|
||||
"function_common": {
|
||||
"secrets": ["fission-myproject-env"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**dev-deployment.json**:
|
||||
```json
|
||||
{
|
||||
"function_common": {
|
||||
"secrets": ["fission-myproject-dev-env"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now `fission deploy --dev` uses the dev secret, while `fission deploy` uses prod secret.
|
||||
|
||||
### Local Overrides
|
||||
|
||||
Create `.fission/local-deployment.json` for your workstation:
|
||||
|
||||
```json
|
||||
{
|
||||
"function_common": {
|
||||
"secrets": ["fission-myproject-local-env"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fission automatically uses this if present (no flag needed). `.gitignore` typically excludes it.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's ✅
|
||||
|
||||
1. **Do use Kubernetes Secrets** - Never hardcode credentials
|
||||
2. **Do encrypt with vault** - Prevents plaintext secrets in K8s
|
||||
3. **Do store vault key securely** - In K8s sealed secret, external vault (HashiCorp Vault, AWS Secrets Manager), or as a separate K8s secret in restricted namespace
|
||||
4. **Do namespace secrets** - Use different secrets for dev/staging/prod
|
||||
5. **Do rotate secrets regularly** - Especially database passwords, API tokens
|
||||
6. **Do use ConfigMaps for non-sensitive config** - Cleaner separation
|
||||
7. **Do provide sensible defaults** - In `get_secret()` calls
|
||||
8. **Do validate required secrets** - Fail fast at startup:
|
||||
```python
|
||||
def init():
|
||||
pg_host = get_secret("PG_HOST")
|
||||
if not pg_host:
|
||||
raise ValueError("PG_HOST secret is required")
|
||||
```
|
||||
|
||||
### Don'ts ❌
|
||||
|
||||
1. **Don't commit secrets** - Even in `deployment.json` literals
|
||||
2. **Don't put plaintext in Git** - Use placeholders or remove before commit
|
||||
3. **Don't embed vault key in code for production** - Use environment-specific override or external secret management
|
||||
4. **Don't share vault key publicly** - It's a symmetric key - anyone with it can decrypt all secrets
|
||||
5. **Don't use same secret across namespaces** - Separate environments should have separate credentials
|
||||
6. **Don't rely on obscurity** - Security through obscurity is not security
|
||||
|
||||
### Supply Chain Security
|
||||
|
||||
For production deployments:
|
||||
|
||||
1. **Store vault key in sealed secrets** (if on K8s):
|
||||
```bash
|
||||
kubectl create secret generic crypto-key \
|
||||
--from-literal=key='your-hex-key'
|
||||
# Then use SealedSecrets controller to encrypt in Git
|
||||
```
|
||||
|
||||
2. **Use external secrets operator**:
|
||||
```yaml
|
||||
apiVersion: external-secrets.io/v1beta1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: db-creds
|
||||
spec:
|
||||
refreshInterval: "1h"
|
||||
secretStoreRef:
|
||||
name: vault-backend
|
||||
kind: SecretStore
|
||||
target:
|
||||
name: fission-myproject-env
|
||||
creationPolicy: Owner
|
||||
data:
|
||||
- secretKey: PG_PASS
|
||||
remoteRef:
|
||||
key: /prod/db/password
|
||||
```
|
||||
|
||||
3. **Rotate automatically** with cronjobs or external secret manager
|
||||
|
||||
## Environment Variable Alternative
|
||||
|
||||
While the template uses secret files mounted by Fission, you can also use environment variables:
|
||||
|
||||
```json
|
||||
"function_common": {
|
||||
"environment": {
|
||||
"LOG_LEVEL": "INFO",
|
||||
"FEATURE_FLAG": "true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Access with `os.getenv()`:
|
||||
|
||||
```python
|
||||
import os
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO")
|
||||
```
|
||||
|
||||
**However**: Environment is less flexible than secrets/configmaps for dynamic updates (requires function restart). Prefer secrets/configmaps for values that may change independently of code deployments.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Secret Not Available
|
||||
|
||||
```bash
|
||||
# Check secret exists in correct namespace
|
||||
kubectl get secret fission-myproject-env -n fission
|
||||
|
||||
# Check secret keys
|
||||
kubectl get secret fission-myproject-env -n fission -o jsonpath='{.data}'
|
||||
|
||||
# Check pod mount
|
||||
kubectl exec -it <pod-name> -n fission -- ls -la /secrets/default/
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Secret in wrong namespace (use Fission namespace, usually `fission` or as configured)
|
||||
- Secret name typo in helpers.py `SECRET_NAME` variable
|
||||
- Secret not mounted due to missing permission (service account restriction)
|
||||
|
||||
### Vault Decryption Failing
|
||||
|
||||
```python
|
||||
from vault import is_valid_vault_format, decrypt_vault
|
||||
|
||||
vault_str = get_secret("PG_PASS")
|
||||
print(is_valid_vault_format(vault_str)) # Should be True
|
||||
print(decrypt_vault(vault_str, "wrong-key")) # Raises CryptoError
|
||||
```
|
||||
|
||||
Check:
|
||||
- `CRYPTO_KEY` is set correctly in `helpers.py`
|
||||
- Key is 64 hex characters (32 bytes)
|
||||
- Encrypted value format is exactly `vault:v1:base64...`
|
||||
|
||||
### Permission Denied Reading Secret
|
||||
|
||||
Pod may lack permission to read secret. Check service account:
|
||||
|
||||
```bash
|
||||
# Get function pod's service account
|
||||
kubectl get pod <pod-name> -n fission -o jsonpath='{.spec.serviceAccountName}'
|
||||
|
||||
# Check role bindings
|
||||
kubectl get rolebinding -n fission
|
||||
kubectl get clusterrolebinding -n fission
|
||||
|
||||
# Add permission if needed (requires cluster admin)
|
||||
kubectl create clusterrolebinding fission-secret-reader \
|
||||
--clusterrole=view \
|
||||
--serviceaccount=fission:default
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/)
|
||||
- [Kubernetes ConfigMaps](https://kubernetes.io/docs/concepts/configuration/configmap/)
|
||||
- [Fission Environment and Config](https://fission.io/docs/usage/env/)
|
||||
- [PyNaCl Documentation](https://pynacl.readthedocs.io/)
|
||||
- [SealedSecrets](https://github.com/bitnami-labs/sealed-secrets) - Store encrypted secrets in Git
|
||||
240
fission-python/template/docs/STRUCTURE.md
Normal file
240
fission-python/template/docs/STRUCTURE.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Project Structure
|
||||
|
||||
This document explains the purpose and contents of each directory and file in a Fission Python project.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
project/
|
||||
├── .fission/ # Fission configuration
|
||||
│ ├── deployment.json # Main deployment configuration
|
||||
│ ├── dev-deployment.json # Development environment overrides
|
||||
│ └── local-deployment.json # Local development overrides
|
||||
├── src/ # Source code
|
||||
│ ├── __init__.py # Package initialization
|
||||
│ ├── vault.py # Vault encryption utilities
|
||||
│ ├── helpers.py # Shared utility functions
|
||||
│ ├── exceptions.py # Custom exception classes
|
||||
│ ├── models.py # Pydantic models for validation
|
||||
│ ├── build.sh # Build script (executable)
|
||||
│ └── *.py # Your function implementations
|
||||
├── test/ # Unit and integration tests
|
||||
│ ├── __init__.py
|
||||
│ ├── test_*.py # Test files
|
||||
│ └── requirements.txt # Test dependencies
|
||||
├── migrates/ # Database migration scripts
|
||||
│ └── *.sql # SQL migration files
|
||||
├── manifests/ # Kubernetes manifests (optional)
|
||||
│ └── *.yaml # K8s resources
|
||||
├── specs/ # Generated Fission specs
|
||||
│ ├── fission-deployment-config.yaml
|
||||
│ └── ...
|
||||
├── requirements.txt # Runtime dependencies
|
||||
├── dev-requirements.txt # Development dependencies
|
||||
├── .env.example # Environment variable template
|
||||
├── pytest.ini # Pytest configuration
|
||||
├── README.md # Project documentation
|
||||
└── (other project files)
|
||||
```
|
||||
|
||||
## File Purposes
|
||||
|
||||
### .fission/deployment.json
|
||||
|
||||
This is **the most important configuration file** for Fission deployment. It defines:
|
||||
|
||||
- **environments**: Build environment configuration (image, builder, resources)
|
||||
- **archives**: Source code packaging (typically "package.zip" from src/)
|
||||
- **packages**: Package definitions linking source to environment
|
||||
- **function_common**: Default settings applied to all functions
|
||||
- **secrets**: Secret definitions (literal values are placeholders - actual secrets go in K8s)
|
||||
- **configmaps**: ConfigMap definitions (non-sensitive configuration)
|
||||
|
||||
**Important**: The secret and configmap literals are **placeholders only**. In production, you create actual K8s secrets/configmaps with the same names containing real values.
|
||||
|
||||
**Placeholders**:
|
||||
- `${PROJECT_NAME}` - Replaced with your project name by `create-project.sh`
|
||||
- Secret name pattern: `fission-${PROJECT_NAME}-env`
|
||||
- ConfigMap name pattern: `fission-${PROJECT_NAME}-config`
|
||||
|
||||
### src/vault.py
|
||||
|
||||
Provides encryption/decryption utilities using PyNaCl (SecretBox). This is used when you want to store encrypted values in K8s secrets rather than plaintext.
|
||||
|
||||
**Key functions**:
|
||||
- `encrypt_vault(plaintext, key)` - Encrypt and return vault format string
|
||||
- `decrypt_vault(vault, key)` - Decrypt vault format string
|
||||
- `is_valid_vault_format(vault)` - Check if string is vault-encrypted
|
||||
|
||||
**Usage in helpers.py**: The `get_secret()` and `get_config()` functions automatically detect vault format (`vault:v1:...`) and decrypt if a valid `CRYPTO_KEY` is set.
|
||||
|
||||
### src/helpers.py
|
||||
|
||||
Shared utilities used across functions:
|
||||
|
||||
**Database**:
|
||||
- `init_db_connection()` - Creates PostgreSQL connection from secrets
|
||||
- `db_row_to_dict(cursor, row)` - Convert row tuple to dict
|
||||
- `db_rows_to_array(cursor, rows)` - Convert multiple rows to list of dicts
|
||||
|
||||
**Configuration**:
|
||||
- `get_secret(key, default=None)` - Read from K8s secret volume
|
||||
- `get_config(key, default=None)` - Read from K8s config volume
|
||||
- `get_current_namespace()` - Get current K8s namespace
|
||||
|
||||
**Utilities**:
|
||||
- `str_to_bool(input)` - Convert string to boolean
|
||||
- `check_port_open(ip, port, timeout)` - TCP port connectivity check
|
||||
- `get_user_from_headers()` - Extract user ID from request headers
|
||||
- `format_error_response(...)` - Build standardized error dict
|
||||
|
||||
**Logging**:
|
||||
- Helper uses `current_app.logger` (Flask) for error logging
|
||||
|
||||
### src/exceptions.py
|
||||
|
||||
Custom exception hierarchy:
|
||||
|
||||
```
|
||||
ServiceException (base)
|
||||
├── ValidationError (400) - Invalid input
|
||||
├── NotFoundError (404) - Resource not found
|
||||
├── ConflictError (409) - Duplicate/conflict
|
||||
└── DatabaseError (500) - Database failure
|
||||
```
|
||||
|
||||
All exceptions include:
|
||||
- `error_code` - Machine-readable code
|
||||
- `http_status` - HTTP status
|
||||
- `error_msg` - Human-readable message
|
||||
- `x_user` (optional) - User identifier
|
||||
- `details` (optional) - Additional context dict
|
||||
|
||||
When raised in a Fission function, these automatically return proper JSON error responses.
|
||||
|
||||
### src/models.py
|
||||
|
||||
Pydantic models for request/response validation:
|
||||
|
||||
**Patterns included**:
|
||||
- Enums (e.g., `Status`, `DataType`)
|
||||
- Dataclass filters (e.g., `ItemFilter`, `Pagination`)
|
||||
- Request models (`ItemCreateRequest`, `ItemUpdateRequest`)
|
||||
- Response models (`ItemResponse`, `PaginatedResponse`)
|
||||
- ErrorResponse model (used by exceptions)
|
||||
|
||||
**Key concepts**:
|
||||
- Use `Field(...)` with constraints (min_length, max_length, ge, le)
|
||||
- Provide `description` for API documentation
|
||||
- Use `json_schema_extra` for example values
|
||||
- Set `from_attributes = True` for ORM compatibility
|
||||
|
||||
### src/build.sh
|
||||
|
||||
Bash script that builds the dependency package. It:
|
||||
1. Detects OS (Debian vs Alpine)
|
||||
2. Installs build dependencies (gcc, libpq-dev/python3-dev/postgresql-dev)
|
||||
3. Installs Python requirements into `src/` directory
|
||||
4. Copies `src/` to package destination
|
||||
|
||||
**Important**: Must be executable (`chmod +x src/build.sh`)
|
||||
|
||||
The script expects environment variables:
|
||||
- `SRC_PKG` - Source package directory (e.g., `src`)
|
||||
- `DEPLOY_PKG` - Destination package (e.g., `specs/package`)
|
||||
|
||||
Fission builder sets these automatically.
|
||||
|
||||
### test/
|
||||
|
||||
Contains unit and integration tests.
|
||||
|
||||
**Structure**:
|
||||
- `test_*.py` - Test files following pytest conventions
|
||||
- `requirements.txt` - Test dependencies (pytest, pytest-mock, requests)
|
||||
|
||||
**Running tests**:
|
||||
```bash
|
||||
pip install -r dev-requirements.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
## Fission Configuration in Docstrings
|
||||
|
||||
Each Python function that should be exposed as a Fission function **must** include a ````fission` block in its docstring:
|
||||
|
||||
```python
|
||||
def my_function(event, context):
|
||||
"""
|
||||
```fission
|
||||
{
|
||||
"name": "my-function",
|
||||
"http_triggers": {
|
||||
"my-trigger": {
|
||||
"url": "/api/endpoint",
|
||||
"methods": ["GET", "POST"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Human-readable description here.
|
||||
"""
|
||||
# Implementation
|
||||
```
|
||||
|
||||
The Fission Python builder parses these docstrings and generates the `specs/fission-deployment-config.yaml` and other spec files.
|
||||
|
||||
**Supported trigger types**:
|
||||
- `http_triggers` - HTTP endpoints
|
||||
- `kafka_triggers` - Kafka topics
|
||||
- `timer_triggers` - Scheduled execution
|
||||
- `message_queue_triggers` - MQTT, NATS, etc.
|
||||
|
||||
## Configuration Precedence
|
||||
|
||||
1. **deployment.json** - Base configuration (committed to repo)
|
||||
2. **dev-deployment.json** - Overrides for dev environment (not always committed)
|
||||
3. **local-deployment.json** - Local overrides (typically .gitignored)
|
||||
|
||||
When deploying:
|
||||
- `fission deploy` uses deployment.json
|
||||
- `fission deploy --dev` uses dev-deployment.json if present
|
||||
|
||||
## Secrets and Configuration Flow
|
||||
|
||||
1. **Define placeholders** in `deployment.json`:
|
||||
```json
|
||||
"secrets": {
|
||||
"fission-myproject-env": {
|
||||
"literals": ["PG_HOST=localhost", "PG_PORT=5432"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Create actual K8s secret**:
|
||||
```bash
|
||||
kubectl create secret generic fission-myproject-env \
|
||||
--from-literal=PG_HOST=prod-db.example.com \
|
||||
--from-literal=PG_PORT=5432
|
||||
```
|
||||
|
||||
3. **Read in code** via `get_secret()`:
|
||||
```python
|
||||
host = get_secret("PG_HOST")
|
||||
```
|
||||
|
||||
4. **For vault encryption**:
|
||||
- Set `CRYPTO_KEY` in helpers.py or as env override
|
||||
- Store encrypted: `vault:v1:base64data` in K8s secret
|
||||
- `get_secret()` auto-decrypts
|
||||
|
||||
## Summary
|
||||
|
||||
- Keep function code in `src/`
|
||||
- Define Fission metadata in docstring blocks
|
||||
- Use helpers for common operations
|
||||
- Define custom exceptions for error handling
|
||||
- Validate inputs with Pydantic models
|
||||
- Store tests in `test/` with pytest
|
||||
- Manage database migrations in `migrates/`
|
||||
- Do not commit actual secrets to repository
|
||||
567
fission-python/template/docs/TESTING.md
Normal file
567
fission-python/template/docs/TESTING.md
Normal file
@@ -0,0 +1,567 @@
|
||||
# Testing Guide
|
||||
|
||||
This document covers testing strategies and best practices for Fission Python functions.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Test Types](#test-types)
|
||||
2. [Dependencies](#dependencies)
|
||||
3. [Unit Testing](#unit-testing)
|
||||
4. [Integration Testing](#integration-testing)
|
||||
5. [Test Database](#test-database)
|
||||
6. [Mocking](#mocking)
|
||||
7. [Fixtures](#fixtures)
|
||||
8. [Coverage](#coverage)
|
||||
9. [Running Tests](#running-tests)
|
||||
10. [CI/CD Integration](#cicd-integration)
|
||||
|
||||
## Test Types
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Test individual functions in isolation, mocking external dependencies:
|
||||
- Database calls
|
||||
- HTTP requests
|
||||
- File I/O
|
||||
- External services
|
||||
|
||||
**Goal**: Verify business logic correctness without infrastructure.
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test the function with real (or test) dependencies:
|
||||
- Actual database queries
|
||||
- End-to-end request/response flow
|
||||
- Real configuration loading
|
||||
|
||||
**Goal**: Verify integration points work correctly.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Install test dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r test/requirements.txt
|
||||
# Or for dev (includes both runtime and test deps):
|
||||
pip install -r dev-requirements.txt
|
||||
```
|
||||
|
||||
Required packages:
|
||||
- `pytest` - Test framework
|
||||
- `pytest-mock` - Mocking utilities (provides `mocker` fixture)
|
||||
- `requests` - For integration tests making HTTP calls
|
||||
|
||||
## Unit Testing
|
||||
|
||||
### Example Test Structure
|
||||
|
||||
```python
|
||||
# test/test_my_function.py
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from src.my_function import create_item
|
||||
from exceptions import ValidationError
|
||||
|
||||
def test_create_item_success():
|
||||
"""Test successful item creation."""
|
||||
# Arrange
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_cursor.fetchone.return_value = ("item-id", "Item Name", "active")
|
||||
|
||||
# Mock init_db_connection to return our mock
|
||||
with patch("src.my_function.init_db_connection", return_value=mock_conn):
|
||||
# Create a mock Flask request
|
||||
with patch("src.my_function.request") as mock_request:
|
||||
mock_request.get_json.return_value = {
|
||||
"name": "Test Item",
|
||||
"status": "active"
|
||||
}
|
||||
mock_request.view_args = {}
|
||||
|
||||
# Act
|
||||
result = create_item({}, {})
|
||||
|
||||
# Assert
|
||||
assert result["id"] == "item-id"
|
||||
assert result["name"] == "Test Item"
|
||||
mock_cursor.execute.assert_called_once()
|
||||
mock_conn.commit.assert_called_once()
|
||||
|
||||
def test_create_item_validation_error():
|
||||
"""Test validation of missing required fields."""
|
||||
with patch("src.my_function.request") as mock_request:
|
||||
mock_request.get_json.return_value = {"name": ""} # Empty name
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
create_item({}, {})
|
||||
|
||||
assert "validation" in str(exc_info.value.error_msg).lower()
|
||||
```
|
||||
|
||||
### Mocking Helpers
|
||||
|
||||
Use `patch` to replace dependencies:
|
||||
|
||||
```python
|
||||
# Mock helpers.get_secret
|
||||
@patch("src.my_function.helpers.get_secret")
|
||||
def test_with_mocked_secret(mock_get_secret):
|
||||
mock_get_secret.return_value = "localhost"
|
||||
# Test code...
|
||||
|
||||
# Mock entire module
|
||||
@patch("src.my_function.helpers.init_db_connection")
|
||||
def test_with_mocked_db(mock_init_db):
|
||||
mock_conn = MagicMock()
|
||||
mock_init_db.return_value = mock_conn
|
||||
# Test code...
|
||||
```
|
||||
|
||||
### Mocking Flask Request
|
||||
|
||||
```python
|
||||
from flask import Request
|
||||
|
||||
def test_with_flask_request():
|
||||
with patch("src.my_function.request") as mock_request:
|
||||
mock_request.get_json.return_value = {"key": "value"}
|
||||
mock_request.args.getlist.return_value = []
|
||||
mock_request.headers.get.return_value = "user-123"
|
||||
# Test code...
|
||||
```
|
||||
|
||||
## Integration Testing
|
||||
|
||||
### Test Database Setup
|
||||
|
||||
Use a separate test database:
|
||||
|
||||
```bash
|
||||
# Create test database
|
||||
createdb fission_test
|
||||
|
||||
# Or with Docker:
|
||||
docker run -d -p 5433:5432 -e POSTGRES_PASSWORD=test postgres:15
|
||||
```
|
||||
|
||||
Set environment variables for test database:
|
||||
```bash
|
||||
export PG_HOST=localhost
|
||||
export PG_PORT=5433
|
||||
export PG_DB=fission_test
|
||||
export PG_USER=postgres
|
||||
export PG_PASS=test
|
||||
```
|
||||
|
||||
### pytest Fixtures for Database
|
||||
|
||||
```python
|
||||
# conftest.py (placed in test/ directory)
|
||||
import pytest
|
||||
import psycopg2
|
||||
from helpers import init_db_connection
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def db_connection():
|
||||
"""Create a database connection for the entire test session."""
|
||||
conn = init_db_connection()
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db_cursor(db_connection):
|
||||
"""Create a cursor for each test, with transaction rollback."""
|
||||
conn = db_connection
|
||||
cursor = conn.cursor()
|
||||
# Start a transaction that will be rolled back
|
||||
conn.rollback()
|
||||
yield cursor
|
||||
# Rollback after each test to keep DB clean
|
||||
conn.rollback()
|
||||
```
|
||||
|
||||
### Example Integration Test
|
||||
|
||||
```python
|
||||
# test/test_integration.py
|
||||
def test_create_and_retrieve_item_integration(db_connection):
|
||||
"""Test full CRUD cycle with real database."""
|
||||
from src.models import ItemCreateRequest
|
||||
from src.functions import create_item, get_item
|
||||
|
||||
# Insert test data
|
||||
cursor = db_connection.cursor()
|
||||
cursor.execute("DELETE FROM items WHERE name = 'Integration Test'")
|
||||
db_connection.commit()
|
||||
|
||||
# Create item via function
|
||||
with patch("src.functions.request") as mock_request:
|
||||
mock_request.get_json.return_value = {
|
||||
"name": "Integration Test",
|
||||
"description": "Test item"
|
||||
}
|
||||
mock_request.view_args = {}
|
||||
result = create_item({}, {})
|
||||
|
||||
item_id = result["id"]
|
||||
assert result["name"] == "Integration Test"
|
||||
|
||||
# Retrieve same item
|
||||
with patch("src.functions.request") as mock_request:
|
||||
with patch("src.functions.request.view_args", {"id": item_id}):
|
||||
result = get_item({"path": f"/items/{item_id}"}, {})
|
||||
assert result["id"] == item_id
|
||||
|
||||
# Cleanup
|
||||
cursor.execute("DELETE FROM items WHERE id = %s", (item_id,))
|
||||
db_connection.commit()
|
||||
```
|
||||
|
||||
## Test Database Migrations
|
||||
|
||||
Apply migrations before integration tests:
|
||||
|
||||
```python
|
||||
# conftest.py
|
||||
import subprocess
|
||||
|
||||
def apply_migrations():
|
||||
"""Apply all SQL migrations to test database."""
|
||||
import os
|
||||
migrates_dir = os.path.join(os.path.dirname(__file__), "..", "migrates")
|
||||
for file in sorted(os.listdir(migrates_dir)):
|
||||
if file.endswith(".sql"):
|
||||
path = os.path.join(migrates_dir, file)
|
||||
subprocess.run(
|
||||
["psql", "-d", "fission_test", "-f", path],
|
||||
check=True
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_database():
|
||||
"""Run migrations before any tests."""
|
||||
apply_migrations()
|
||||
yield
|
||||
# Optionally drop and recreate after tests
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
### Built-in unittest.mock
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
|
||||
# Simple patch
|
||||
with patch("module.function") as mock_func:
|
||||
mock_func.return_value = "mocked"
|
||||
# call code that uses module.function
|
||||
|
||||
# Assert called with specific args
|
||||
mock_func.assert_called_once_with("arg1", "arg2")
|
||||
|
||||
# Mock context manager
|
||||
with patch("builtins.open", mock_open(read_data="file content")) as mock_file:
|
||||
# code that opens file
|
||||
mock_file.assert_called_with("path/to/file", "r")
|
||||
```
|
||||
|
||||
### pytest-mock Fixture
|
||||
|
||||
Simpler syntax using `mocker` fixture:
|
||||
|
||||
```python
|
||||
def test_with_mocker(mocker):
|
||||
mock_func = mocker.patch("src.function.helper")
|
||||
mock_func.return_value = {"key": "value"}
|
||||
# test code...
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
|
||||
Create reusable fixtures in `conftest.py`:
|
||||
|
||||
```python
|
||||
# test/conftest.py
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def sample_item_data():
|
||||
"""Provide sample item data for tests."""
|
||||
return {
|
||||
"name": "Test Item",
|
||||
"description": "A test item",
|
||||
"status": "active"
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_connection(mocker):
|
||||
"""Provide a mocked database connection."""
|
||||
mock_conn = mocker.MagicMock()
|
||||
mock_cursor = mocker.MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_cursor.fetchone.return_value = None
|
||||
return mock_conn
|
||||
```
|
||||
|
||||
Fixtures are automatically available to all tests in the directory.
|
||||
|
||||
## Coverage
|
||||
|
||||
Measure test coverage with pytest-cov:
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pip install pytest-cov
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src
|
||||
|
||||
# HTML report
|
||||
pytest --cov=src --cov-report=html
|
||||
open htmlcov/index.html
|
||||
|
||||
# Show missing lines
|
||||
pytest --cov=src --cov-report=term-missing
|
||||
```
|
||||
|
||||
Aim for high coverage of business logic (80%+). Don't worry about 100% coverage of trivial getters/setters.
|
||||
|
||||
### Excluding Files
|
||||
|
||||
Add to `pytest.ini`:
|
||||
```ini
|
||||
[pytest]
|
||||
addopts = --cov=src --cov-exclude=src/vault.py
|
||||
```
|
||||
|
||||
Or use `.coveragerc`:
|
||||
```ini
|
||||
[run]
|
||||
omit = src/vault.py
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Verbose
|
||||
pytest -v
|
||||
|
||||
# Run specific test file
|
||||
pytest test/test_my_function.py
|
||||
|
||||
# Run specific test function
|
||||
pytest test/test_my_function.py::test_create_item_success
|
||||
|
||||
# Run with markers
|
||||
pytest -m "integration" # if using @pytest.mark.integration
|
||||
|
||||
# Stop on first failure
|
||||
pytest -x
|
||||
|
||||
# Show print statements
|
||||
pytest -s
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
Create `test/.env` or set environment variables before tests:
|
||||
|
||||
```bash
|
||||
# For integration tests
|
||||
export PG_HOST=localhost
|
||||
export PG_PORT=5432
|
||||
export PG_DB=fission_test
|
||||
```
|
||||
|
||||
Or use a pytest fixture to load from `.env`:
|
||||
|
||||
```python
|
||||
# conftest.py
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def load_env():
|
||||
env_path = os.path.join(os.path.dirname(__file__), ".env")
|
||||
load_dotenv(env_path)
|
||||
```
|
||||
|
||||
### Markers
|
||||
|
||||
Mark tests as unit/integration/slow:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_quick_unit():
|
||||
pass
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_full_workflow():
|
||||
pass
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_long_running():
|
||||
pass
|
||||
```
|
||||
|
||||
Run only unit tests:
|
||||
```bash
|
||||
pytest -m "unit"
|
||||
```
|
||||
|
||||
Skip tests:
|
||||
```bash
|
||||
pytest -m "not slow"
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yaml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_PASSWORD: test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r dev-requirements.txt
|
||||
- name: Setup database
|
||||
run: |
|
||||
createdb -h localhost -U postgres fission_test
|
||||
psql -h localhost -U postgres fission_test -f migrates/001_schema.sql
|
||||
env:
|
||||
PGPASSWORD: test
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest --cov=src --cov-report=xml
|
||||
env:
|
||||
PG_HOST: localhost
|
||||
PG_PORT: 5432
|
||||
PG_DB: fission_test
|
||||
PG_USER: postgres
|
||||
PG_PASS: test
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **One assertion per test** - Keep tests focused
|
||||
2. **Use descriptive names** - `test_create_item_validation_error_for_missing_name`
|
||||
3. **Arrange-Act-Assert** - Structure tests clearly
|
||||
4. **Mock external dependencies** - Don't rely on network or external services
|
||||
5. **Test error cases** - Don't just test happy paths
|
||||
6. **Use fixtures** - Reuse setup/teardown code
|
||||
7. **Keep tests independent** - No shared state between tests
|
||||
8. **Test edge cases** - Empty inputs, null values, boundary conditions
|
||||
9. **Don't test libraries** - Don't write tests for Flask/Pydantic themselves
|
||||
10. **Clean up resources** - Use fixtures to ensure cleanup
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Testing Exceptions
|
||||
|
||||
```python
|
||||
def test_raises_not_found():
|
||||
with pytest.raises(NotFoundError) as exc:
|
||||
get_item("nonexistent-id")
|
||||
assert exc.value.http_status == 404
|
||||
```
|
||||
|
||||
### Parametrized Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.parametrize("input,expected", [
|
||||
("true", True),
|
||||
("false", False),
|
||||
("", None),
|
||||
(None, None),
|
||||
])
|
||||
def test_str_to_bool(input, expected):
|
||||
from helpers import str_to_bool
|
||||
assert str_to_bool(input) == expected
|
||||
```
|
||||
|
||||
### Temporary Files/Directories
|
||||
|
||||
```python
|
||||
def test_with_temp_file(tmp_path):
|
||||
# tmp_path is a pathlib.Path to a temporary directory
|
||||
file = tmp_path / "test.txt"
|
||||
file.write_text("content")
|
||||
assert file.read_text() == "content"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Fail with Database Errors
|
||||
|
||||
- Check test database is running: `pg_isready -h localhost -p 5432`
|
||||
- Verify migrations applied: `psql -l | grep fission_test`
|
||||
- Check environment variables: `echo $PG_HOST`
|
||||
|
||||
### Mock Not Working
|
||||
|
||||
- Ensure you're patching the **correct import location** (where it's used, not where it's defined)
|
||||
```python
|
||||
# Wrong: patching where it's defined
|
||||
@patch("helpers.get_secret")
|
||||
# Right: patching where it's used in your function module
|
||||
@patch("src.my_function.helpers.get_secret")
|
||||
```
|
||||
|
||||
### Import Errors
|
||||
|
||||
Ensure PYTHONPATH includes project root:
|
||||
```bash
|
||||
export PYTHONPATH=/path/to/project:$PYTHONPATH
|
||||
```
|
||||
|
||||
Or use pytest's `pythonpath` option in pytest.ini:
|
||||
```ini
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [pytest documentation](https://docs.pytest.org/)
|
||||
- [pytest-mock documentation](https://github.com/pytest-dev/pytest-mock)
|
||||
- [Python unittest.mock](https://docs.python.org/3/library/unittest.mock.html)
|
||||
- [Testing Flask Applications](https://flask.palletsprojects.com/en/2.1.x/testing/)
|
||||
433
fission-python/template/examples/example_crud.py
Normal file
433
fission-python/template/examples/example_crud.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
Example: Basic CRUD operations for a resource.
|
||||
|
||||
This demonstrates:
|
||||
- Pydantic request validation
|
||||
- Database operations with helpers
|
||||
- Standard error handling
|
||||
- Proper Fission docstring configuration
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from helpers import (
|
||||
init_db_connection,
|
||||
db_row_to_dict,
|
||||
db_rows_to_array,
|
||||
get_user_from_headers,
|
||||
format_error_response,
|
||||
)
|
||||
from exceptions import ValidationError, NotFoundError, ConflictError, DatabaseError
|
||||
from models import ItemResponse, ItemCreateRequest, ItemUpdateRequest
|
||||
|
||||
# Pool manager executor, one request at a time
|
||||
def create(event, context):
|
||||
"""
|
||||
```fission
|
||||
{
|
||||
"name": "create-item",
|
||||
"http_triggers": {
|
||||
"create": {
|
||||
"url": "/api/items",
|
||||
"methods": ["POST"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Create a new item.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "string (required, 1-255 chars)",
|
||||
"description": "string (optional)",
|
||||
"status": "active|inactive|pending",
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
- 200: Item created successfully
|
||||
- 400: Validation error
|
||||
- 409: Conflict (e.g., duplicate name)
|
||||
- 500: Database error
|
||||
"""
|
||||
# Get user for audit trail
|
||||
x_user = get_user_from_headers()
|
||||
|
||||
# Validate request payload
|
||||
try:
|
||||
data = ItemCreateRequest(**request.get_json())
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Invalid request: {str(e)}", x_user=x_user)
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check for conflicts (example)
|
||||
cursor.execute(
|
||||
"SELECT id FROM items WHERE name = %s",
|
||||
(data.name,)
|
||||
)
|
||||
if cursor.fetchone():
|
||||
raise ConflictError(
|
||||
f"Item with name '{data.name}' already exists",
|
||||
x_user=x_user,
|
||||
details={"name": data.name}
|
||||
)
|
||||
|
||||
# Insert new item
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO items (name, description, status, metadata)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, name, description, status, metadata, created, modified
|
||||
""",
|
||||
(data.name, data.description, data.status.value, data.metadata)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.commit()
|
||||
|
||||
# Build response
|
||||
item = db_row_to_dict(cursor, row)
|
||||
return item
|
||||
|
||||
except (ValidationError, NotFoundError, ConflictError, DatabaseError):
|
||||
# Re-raise our own exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
raise DatabaseError(f"Database error: {str(e)}", x_user=x_user)
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_items(event, context):
|
||||
"""
|
||||
```fission
|
||||
{
|
||||
"name": "list-items",
|
||||
"http_triggers": {
|
||||
"list": {
|
||||
"url": "/api/items",
|
||||
"methods": ["GET"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
List items with optional filtering and pagination.
|
||||
|
||||
**Query Parameters:**
|
||||
- `page` (int): Page number, zero-based (default: 0)
|
||||
- `size` (int): Items per page (default: 10, max: 100)
|
||||
- `asc` (bool): Sort ascending (default: true)
|
||||
- `filter[ids]` (string[]): Filter by specific IDs
|
||||
- `filter[keyword]` (string): Search in name/description
|
||||
- `filter[status]` (string[]): Filter by status values
|
||||
- `filter[created_from]` (datetime): Filter created after
|
||||
- `filter[created_to]` (datetime): Filter created before
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"page": 0,
|
||||
"size": 10,
|
||||
"total": 42
|
||||
}
|
||||
```
|
||||
"""
|
||||
from helpers import str_to_bool
|
||||
|
||||
# Parse pagination
|
||||
page = int(request.args.get("page", 0))
|
||||
size = int(request.args.get("size", 10))
|
||||
asc = str_to_bool(request.args.get("asc", "true"))
|
||||
|
||||
# Parse filters
|
||||
ids = request.args.getlist("filter[ids]")
|
||||
keyword = request.args.get("filter[keyword]")
|
||||
statuses = request.args.getlist("filter[status]")
|
||||
|
||||
# Build query
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if ids:
|
||||
conditions.append(f"id IN ({', '.join(['%s'] * len(ids))})")
|
||||
params.extend(ids)
|
||||
if keyword:
|
||||
conditions.append("(name ILIKE %s OR description ILIKE %s)")
|
||||
params.extend([f"%{keyword}%", f"%{keyword}%"])
|
||||
if statuses:
|
||||
conditions.append(f"status IN ({', '.join(['%s'] * len(statuses))})")
|
||||
params.extend(statuses)
|
||||
|
||||
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get total count
|
||||
count_sql = f"SELECT COUNT(*) FROM items {where_clause}"
|
||||
cursor.execute(count_sql, params)
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
# Get paginated data
|
||||
offset = page * size
|
||||
data_sql = f"""
|
||||
SELECT id, name, description, status, metadata, created, modified
|
||||
FROM items
|
||||
{where_clause}
|
||||
ORDER BY created {'ASC' if asc else 'DESC'}
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cursor.execute(data_sql, params + [size, offset])
|
||||
rows = cursor.fetchall()
|
||||
items = [db_row_to_dict(cursor, row) for row in rows]
|
||||
|
||||
return {
|
||||
"data": items,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"total": total
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise DatabaseError(f"Failed to list items: {str(e)}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_item(event, context):
|
||||
"""
|
||||
```fission
|
||||
{
|
||||
"name": "get-item",
|
||||
"http_triggers": {
|
||||
"get": {
|
||||
"url": "/api/items/:id",
|
||||
"methods": ["GET"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Get a specific item by ID.
|
||||
|
||||
**URL Parameters:**
|
||||
- `id` (string): Item UUID
|
||||
|
||||
**Response:**
|
||||
- 200: Item found
|
||||
- 404: Item not found
|
||||
- 500: Database error
|
||||
"""
|
||||
# Extract item ID from path (Fission passes path params differently depending on trigger)
|
||||
# For HTTP triggers, the ID would come from the URL path
|
||||
item_id = request.view_args.get('id') if hasattr(request, 'view_args') else None
|
||||
if not item_id:
|
||||
# Fallback: parse from query or request path
|
||||
item_id = request.path.rstrip('/').split('/')[-1]
|
||||
|
||||
if not item_id:
|
||||
raise ValidationError("Item ID is required")
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, name, description, status, metadata, created, modified
|
||||
FROM items WHERE id = %s
|
||||
""",
|
||||
(item_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise NotFoundError(f"Item {item_id} not found")
|
||||
|
||||
return db_row_to_dict(cursor, row)
|
||||
|
||||
except NotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise DatabaseError(f"Failed to fetch item: {str(e)}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_item(event, context):
|
||||
"""
|
||||
```fission
|
||||
{
|
||||
"name": "update-item",
|
||||
"http_triggers": {
|
||||
"update": {
|
||||
"url": "/api/items/:id",
|
||||
"methods": ["PUT", "PATCH"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Update an existing item.
|
||||
|
||||
**URL Parameters:**
|
||||
- `id` (string): Item UUID
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "string (optional)",
|
||||
"description": "string (optional)",
|
||||
"status": "active|inactive|pending (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
- 200: Item updated successfully
|
||||
- 404: Item not found
|
||||
- 409: Conflict (duplicate name)
|
||||
- 400: Validation error
|
||||
- 500: Database error
|
||||
"""
|
||||
x_user = get_user_from_headers()
|
||||
|
||||
# Extract item ID
|
||||
item_id = request.view_args.get('id') if hasattr(request, 'view_args') else None
|
||||
if not item_id:
|
||||
item_id = request.path.rstrip('/').split('/')[-1]
|
||||
|
||||
if not item_id:
|
||||
raise ValidationError("Item ID is required")
|
||||
|
||||
# Validate request
|
||||
try:
|
||||
data = ItemUpdateRequest(**request.get_json())
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Invalid request: {str(e)}", x_user=x_user)
|
||||
|
||||
# Build update statement dynamically
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if data.name is not None:
|
||||
updates.append("name = %s")
|
||||
params.append(data.name)
|
||||
if data.description is not None:
|
||||
updates.append("description = %s")
|
||||
params.append(data.description)
|
||||
if data.status is not None:
|
||||
updates.append("status = %s")
|
||||
params.append(data.status.value)
|
||||
if data.metadata is not None:
|
||||
updates.append("metadata = %s")
|
||||
params.append(data.metadata)
|
||||
|
||||
if not updates:
|
||||
raise ValidationError("No update fields provided", x_user=x_user)
|
||||
|
||||
updates.append("modified = NOW()")
|
||||
params.append(item_id)
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check for name conflict if name is being updated
|
||||
if data.name:
|
||||
cursor.execute(
|
||||
"SELECT id FROM items WHERE name = %s AND id != %s",
|
||||
(data.name, item_id)
|
||||
)
|
||||
if cursor.fetchone():
|
||||
raise ConflictError(
|
||||
f"Another item with name '{data.name}' already exists",
|
||||
x_user=x_user,
|
||||
details={"name": data.name}
|
||||
)
|
||||
|
||||
# Execute update
|
||||
sql = f"UPDATE items SET {', '.join(updates)} WHERE id = %s RETURNING *"
|
||||
cursor.execute(sql, params)
|
||||
row = cursor.fetchone()
|
||||
conn.commit()
|
||||
|
||||
if not row:
|
||||
raise NotFoundError(f"Item {item_id} not found", x_user=x_user)
|
||||
|
||||
return db_row_to_dict(cursor, row)
|
||||
|
||||
except (ValidationError, NotFoundError, ConflictError, DatabaseError):
|
||||
raise
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
raise DatabaseError(f"Failed to update item: {str(e)}", x_user=x_user)
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_item(event, context):
|
||||
"""
|
||||
```fission
|
||||
{
|
||||
"name": "delete-item",
|
||||
"http_triggers": {
|
||||
"delete": {
|
||||
"url": "/api/items/:id",
|
||||
"methods": ["DELETE"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Delete an item.
|
||||
|
||||
**URL Parameters:**
|
||||
- `id` (string): Item UUID
|
||||
|
||||
**Response:**
|
||||
- 204: Item deleted successfully
|
||||
- 404: Item not found
|
||||
- 500: Database error
|
||||
"""
|
||||
x_user = get_user_from_headers()
|
||||
|
||||
# Extract item ID
|
||||
item_id = request.view_args.get('id') if hasattr(request, 'view_args') else None
|
||||
if not item_id:
|
||||
item_id = request.path.rstrip('/').split('/')[-1]
|
||||
|
||||
if not item_id:
|
||||
raise ValidationError("Item ID is required", x_user=x_user)
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM items WHERE id = %s", (item_id,))
|
||||
conn.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise NotFoundError(f"Item {item_id} not found", x_user=x_user)
|
||||
|
||||
return None # 204 No Content
|
||||
|
||||
except NotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
raise DatabaseError(f"Failed to delete item: {str(e)}", x_user=x_user)
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
311
fission-python/template/examples/example_scheduler.py
Normal file
311
fission-python/template/examples/example_scheduler.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Example: Background job / scheduled task pattern.
|
||||
|
||||
This demonstrates:
|
||||
- Long-running job execution
|
||||
- Job status tracking
|
||||
- Error handling and retries
|
||||
- Periodic task scheduling
|
||||
- Worker session management
|
||||
|
||||
Use cases: report generation, batch processing, cleanup jobs, etc.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import time
|
||||
import uuid
|
||||
from helpers import init_db_connection, db_row_to_dict, db_rows_to_array
|
||||
from exceptions import DatabaseError
|
||||
|
||||
|
||||
def scheduled_job(event, context):
|
||||
"""
|
||||
```fission
|
||||
{
|
||||
"name": "scheduled-job",
|
||||
"http_triggers": {
|
||||
"run": {
|
||||
"url": "/jobs/run",
|
||||
"methods": ["POST"]
|
||||
}
|
||||
},
|
||||
"kafka_triggers": {
|
||||
"job-queue": {
|
||||
"topic": "job-queue",
|
||||
"consumer_group": "scheduler-workers"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Execute a scheduled or queued background job.
|
||||
|
||||
This function can be triggered:
|
||||
- Manually via HTTP POST /jobs/run
|
||||
- Automatically by message queue (Kafka)
|
||||
- By cron schedule (via Fission timer trigger)
|
||||
|
||||
**Request Body (HTTP trigger):**
|
||||
```json
|
||||
{
|
||||
"job_type": "report_generation",
|
||||
"parameters": {
|
||||
"report_type": "daily",
|
||||
"date": "2025-03-18"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
- 200: Job completed successfully
|
||||
- 202: Job accepted for async processing
|
||||
- 400: Invalid request
|
||||
- 500: Job failed
|
||||
"""
|
||||
# Parse input
|
||||
job_type = event.get("job_type") or event.get("type", "default")
|
||||
parameters = event.get("parameters", {})
|
||||
|
||||
# Generate job ID for tracking
|
||||
job_id = str(uuid.uuid4())
|
||||
started_at = datetime.datetime.utcnow()
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Record job start
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO jobs (id, type, parameters, status, started_at)
|
||||
VALUES (%s, %s, %s, 'running', %s)
|
||||
""",
|
||||
(job_id, job_type, parameters, started_at)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Execute job based on type
|
||||
if job_type == "report_generation":
|
||||
result = generate_report(cursor, job_id, parameters)
|
||||
elif job_type == "data_cleanup":
|
||||
result = cleanup_old_data(cursor, job_id, parameters)
|
||||
elif job_type == "sync_external":
|
||||
result = sync_external_system(cursor, job_id, parameters)
|
||||
else:
|
||||
result = run_default_job(cursor, job_id, parameters)
|
||||
|
||||
# Mark job as completed
|
||||
completed_at = datetime.datetime.utcnow()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE jobs
|
||||
SET status = 'completed',
|
||||
result = %s,
|
||||
completed_at = %s,
|
||||
duration = EXTRACT(EPOCH FROM (%s - started_at))
|
||||
WHERE id = %s
|
||||
""",
|
||||
(result, completed_at, completed_at, job_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
"duration_seconds": (completed_at - started_at).total_seconds()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Mark job as failed
|
||||
if conn:
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE jobs
|
||||
SET status = 'failed',
|
||||
error = %s,
|
||||
completed_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(str(e), job_id)
|
||||
)
|
||||
conn.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
raise DatabaseError(f"Job {job_type} failed: {str(e)}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def generate_report(cursor, job_id: str, parameters: dict):
|
||||
"""
|
||||
Generate a report based on parameters.
|
||||
|
||||
Args:
|
||||
cursor: Database cursor
|
||||
job_id: Job tracking ID
|
||||
parameters: Report configuration (report_type, date, filters, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary with report metadata and summary
|
||||
"""
|
||||
report_type = parameters.get("report_type", "daily")
|
||||
report_date = parameters.get("date", datetime.datetime.utcnow().strftime("%Y-%m-%d"))
|
||||
|
||||
# Simulate report generation (could be complex aggregation queries)
|
||||
time.sleep(1) # Simulate work
|
||||
|
||||
# Example: Get statistics for the date
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total_orders,
|
||||
SUM(total) as revenue,
|
||||
COUNT(DISTINCT user_id) as unique_customers
|
||||
FROM orders
|
||||
WHERE DATE(created_at) = %s
|
||||
""",
|
||||
(report_date,)
|
||||
)
|
||||
stats = db_row_to_dict(cursor, cursor.fetchone())
|
||||
|
||||
return {
|
||||
"report_type": report_type,
|
||||
"date": report_date,
|
||||
"statistics": stats,
|
||||
"generated_at": datetime.datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
def cleanup_old_data(cursor, job_id: str, parameters: dict):
|
||||
"""
|
||||
Clean up old records based on retention policy.
|
||||
|
||||
Args:
|
||||
cursor: Database cursor
|
||||
job_id: Job tracking ID
|
||||
parameters: Cleanup configuration (table, days_to_retain, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup summary
|
||||
"""
|
||||
table = parameters.get("table", "jobs") # Table to clean
|
||||
days_to_retain = int(parameters.get("days_to_retain", 90))
|
||||
cutoff_date = datetime.datetime.utcnow() - datetime.timedelta(days=days_to_retain)
|
||||
|
||||
# Safety: prevent dropping tables
|
||||
if table not in ["jobs", "webhook_events", "logs", "sessions"]:
|
||||
raise ValueError(f"Cannot clean table: {table}")
|
||||
|
||||
# Count records to be deleted
|
||||
cursor.execute(
|
||||
f"SELECT COUNT(*) FROM {table} WHERE created_at < %s",
|
||||
(cutoff_date,)
|
||||
)
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
# Delete old records
|
||||
cursor.execute(
|
||||
f"DELETE FROM {table} WHERE created_at < %s",
|
||||
(cutoff_date,)
|
||||
)
|
||||
|
||||
return {
|
||||
"table": table,
|
||||
"cutoff_date": cutoff_date.isoformat(),
|
||||
"records_deleted": count
|
||||
}
|
||||
|
||||
|
||||
def sync_external_system(cursor, job_id: str, parameters: dict):
|
||||
"""
|
||||
Synchronize data with external system.
|
||||
|
||||
Args:
|
||||
cursor: Database cursor
|
||||
job_id: Job tracking ID
|
||||
parameters: Sync configuration (system, endpoint, filters, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary with sync summary
|
||||
"""
|
||||
system = parameters.get("system")
|
||||
endpoint = parameters.get("endpoint")
|
||||
|
||||
# This would typically make HTTP requests to external API
|
||||
# using requests library
|
||||
import requests
|
||||
|
||||
# Fetch last sync timestamp
|
||||
cursor.execute(
|
||||
"SELECT last_sync_at FROM sync_state WHERE system = %s",
|
||||
(system,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
last_sync = row[0] if row else None
|
||||
|
||||
# Build query parameters
|
||||
params = {"since": last_sync.isoformat() if last_sync else ""}
|
||||
|
||||
# Make request to external API
|
||||
try:
|
||||
resp = requests.get(endpoint, params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
raise DatabaseError(f"Failed to fetch from {system}: {str(e)}")
|
||||
|
||||
# Process and store data
|
||||
records_processed = 0
|
||||
for item in data.get("items", []):
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO external_data (system, external_id, data, synced_at)
|
||||
VALUES (%s, %s, %s, NOW())
|
||||
ON CONFLICT (system, external_id) DO UPDATE SET
|
||||
data = EXCLUDED.data,
|
||||
synced_at = EXCLUDED.synced_at
|
||||
""",
|
||||
(system, item["id"], item)
|
||||
)
|
||||
records_processed += 1
|
||||
|
||||
# Update sync state
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sync_state (system, last_sync_at)
|
||||
VALUES (%s, NOW())
|
||||
ON CONFLICT (system) DO UPDATE SET
|
||||
last_sync_at = NOW()
|
||||
""",
|
||||
(system,)
|
||||
)
|
||||
|
||||
return {
|
||||
"system": system,
|
||||
"records_processed": records_processed,
|
||||
"sync_timestamp": datetime.datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
def run_default_job(cursor, job_id: str, parameters: dict):
|
||||
"""
|
||||
Default no-op job for testing.
|
||||
|
||||
Args:
|
||||
cursor: Database cursor
|
||||
job_id: Job tracking ID
|
||||
parameters: Job parameters
|
||||
|
||||
Returns:
|
||||
Simple acknowledgment
|
||||
"""
|
||||
time.sleep(0.5) # Simulate some work
|
||||
return {
|
||||
"message": "Default job executed",
|
||||
"parameters_received": parameters
|
||||
}
|
||||
296
fission-python/template/examples/example_webhook.py
Normal file
296
fission-python/template/examples/example_webhook.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Example: Webhook receiver pattern.
|
||||
|
||||
This demonstrates:
|
||||
- Processing external service callbacks
|
||||
- Signature verification
|
||||
- Event type handling
|
||||
- Idempotency checks
|
||||
- Async processing patterns
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from flask import request
|
||||
from helpers import init_db_connection, get_secret
|
||||
from exceptions import ValidationError, DatabaseError
|
||||
|
||||
# For signed webhooks, you'll need a secret
|
||||
WEBHOOK_SECRET = get_secret("WEBHOOK_SECRET", "")
|
||||
|
||||
|
||||
def verify_signature(payload: bytes, signature: str) -> bool:
|
||||
"""
|
||||
Verify HMAC-SHA256 webhook signature.
|
||||
|
||||
Args:
|
||||
payload: Raw request body bytes
|
||||
signature: Signature header value (format: "sha256=<hex>")
|
||||
|
||||
Returns:
|
||||
True if signature is valid, False otherwise
|
||||
"""
|
||||
if not WEBHOOK_SECRET:
|
||||
return True # Skip verification if no secret configured (for dev)
|
||||
|
||||
expected = hmac.new(
|
||||
WEBHOOK_SECRET.encode(),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Signature header format: "sha256=abcdef..."
|
||||
received = signature.split("=", 1)[1] if "=" in signature else signature
|
||||
return hmac.compare_digest(expected, received)
|
||||
|
||||
|
||||
def webhook_receiver(event, context):
|
||||
"""
|
||||
```fission
|
||||
{
|
||||
"name": "webhook-receiver",
|
||||
"http_triggers": {
|
||||
"webhook": {
|
||||
"url": "/webhooks/external-service",
|
||||
"methods": ["POST"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Receive and process webhook from external service.
|
||||
|
||||
**Request:**
|
||||
- Raw JSON payload in body
|
||||
- Signature header: `X-Webhook-Signature: sha256=<hmac>`
|
||||
|
||||
**Response:**
|
||||
- 200: Webhook accepted for processing
|
||||
- 400: Invalid signature or payload
|
||||
- 500: Processing error
|
||||
|
||||
**Idempotency:**
|
||||
This function is idempotent - duplicate webhooks with same
|
||||
event ID will not be processed twice.
|
||||
"""
|
||||
# Get raw body for signature verification
|
||||
payload = request.get_data()
|
||||
signature = request.headers.get("X-Webhook-Signature", "")
|
||||
|
||||
# Verify signature
|
||||
if not verify_signature(payload, signature):
|
||||
raise ValidationError("Invalid webhook signature")
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
data = request.get_json()
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Invalid JSON payload: {str(e)}")
|
||||
|
||||
# Validate required fields
|
||||
event_id = data.get("event_id") or data.get("id")
|
||||
event_type = data.get("event_type") or data.get("type")
|
||||
|
||||
if not event_id:
|
||||
raise ValidationError("Missing event_id in webhook payload")
|
||||
|
||||
if not event_type:
|
||||
raise ValidationError("Missing event_type in webhook payload")
|
||||
|
||||
# Idempotency check: have we already processed this event?
|
||||
conn = None
|
||||
try:
|
||||
conn = init_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if event already processed
|
||||
cursor.execute(
|
||||
"SELECT id FROM webhook_events WHERE event_id = %s",
|
||||
(event_id,)
|
||||
)
|
||||
if cursor.fetchone():
|
||||
# Already processed - return success (idempotent)
|
||||
return {"status": "already_processed", "event_id": event_id}
|
||||
|
||||
# Record webhook event (for idempotency)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO webhook_events (event_id, event_type, payload, received_at)
|
||||
VALUES (%s, %s, %s, NOW())
|
||||
""",
|
||||
(event_id, event_type, payload.decode('utf-8'))
|
||||
)
|
||||
|
||||
# Process based on event type
|
||||
result = process_event(cursor, event_type, data)
|
||||
|
||||
conn.commit()
|
||||
return {"status": "processed", "event_id": event_id, "result": result}
|
||||
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
raise DatabaseError(f"Failed to process webhook: {str(e)}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def process_event(cursor, event_type: str, data: dict):
|
||||
"""
|
||||
Route event to appropriate handler.
|
||||
|
||||
Args:
|
||||
cursor: Database cursor
|
||||
event_type: Type of event (e.g., "user.created", "order.updated")
|
||||
data: Event payload
|
||||
|
||||
Returns:
|
||||
Handler result
|
||||
"""
|
||||
handlers = {
|
||||
"user.created": handle_user_created,
|
||||
"user.updated": handle_user_updated,
|
||||
"user.deleted": handle_user_deleted,
|
||||
"order.created": handle_order_created,
|
||||
"order.paid": handle_order_paid,
|
||||
"order.shipped": handle_order_shipped,
|
||||
}
|
||||
|
||||
handler = handlers.get(event_type)
|
||||
if not handler:
|
||||
# Log unknown event type but don't fail
|
||||
logger = get_logger()
|
||||
logger.warning(f"Unhandled webhook event type: {event_type}")
|
||||
return {"skipped": True, "reason": "unknown_event_type"}
|
||||
|
||||
return handler(cursor, data)
|
||||
|
||||
|
||||
def handle_user_created(cursor, data: dict):
|
||||
"""Handle user creation event."""
|
||||
user_id = data.get("user_id") or data.get("id")
|
||||
email = data.get("email")
|
||||
name = data.get("name")
|
||||
|
||||
# Create user record
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO users (id, email, name, created_at)
|
||||
VALUES (%s, %s, %s, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
name = EXCLUDED.name,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(user_id, email, name)
|
||||
)
|
||||
|
||||
# Send welcome email (async via message queue, etc.)
|
||||
# enqueue_welcome_email(user_id, email)
|
||||
|
||||
return {"action": "user_created", "user_id": user_id}
|
||||
|
||||
|
||||
def handle_user_updated(cursor, data: dict):
|
||||
"""Handle user update event."""
|
||||
user_id = data.get("user_id") or data.get("id")
|
||||
updates = data.get("updates", {})
|
||||
|
||||
# Build dynamic update
|
||||
set_clauses = []
|
||||
params = []
|
||||
for key, value in updates.items():
|
||||
set_clauses.append(f"{key} = %s")
|
||||
params.append(value)
|
||||
params.append(user_id)
|
||||
|
||||
cursor.execute(
|
||||
f"UPDATE users SET {', '.join(set_clauses)}, updated_at = NOW() WHERE id = %s",
|
||||
params
|
||||
)
|
||||
|
||||
return {"action": "user_updated", "user_id": user_id}
|
||||
|
||||
|
||||
def handle_user_deleted(cursor, data: dict):
|
||||
"""Handle user deletion event."""
|
||||
user_id = data.get("user_id") or data.get("id")
|
||||
|
||||
# Soft delete (mark as inactive)
|
||||
cursor.execute(
|
||||
"UPDATE users SET status = 'deleted', deleted_at = NOW() WHERE id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
return {"action": "user_deleted", "user_id": user_id}
|
||||
|
||||
|
||||
def handle_order_created(cursor, data: dict):
|
||||
"""Handle order creation event."""
|
||||
order_id = data.get("order_id") or data.get("id")
|
||||
user_id = data.get("user_id")
|
||||
total = data.get("total")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO orders (id, user_id, total, status, created_at)
|
||||
VALUES (%s, %s, %s, 'pending', NOW())
|
||||
""",
|
||||
(order_id, user_id, total)
|
||||
)
|
||||
|
||||
return {"action": "order_created", "order_id": order_id}
|
||||
|
||||
|
||||
def handle_order_paid(cursor, data: dict):
|
||||
"""Handle order payment event."""
|
||||
order_id = data.get("order_id") or data.get("id")
|
||||
payment_id = data.get("payment_id")
|
||||
amount = data.get("amount")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET status = 'paid',
|
||||
paid_amount = %s,
|
||||
payment_id = %s,
|
||||
paid_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(amount, payment_id, order_id)
|
||||
)
|
||||
|
||||
# Trigger fulfillment
|
||||
# enqueue_fulfillment(order_id)
|
||||
|
||||
return {"action": "order_paid", "order_id": order_id}
|
||||
|
||||
|
||||
def handle_order_shipped(cursor, data: dict):
|
||||
"""Handle order shipment event."""
|
||||
order_id = data.get("order_id") or data.get("id")
|
||||
tracking_number = data.get("tracking_number")
|
||||
carrier = data.get("carrier")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET status = 'shipped',
|
||||
tracking_number = %s,
|
||||
carrier = %s,
|
||||
shipped_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(tracking_number, carrier, order_id)
|
||||
)
|
||||
|
||||
# Send shipping notification
|
||||
# send_shipping_email(order_id)
|
||||
|
||||
return {"action": "order_shipped", "order_id": order_id}
|
||||
|
||||
|
||||
def get_logger():
|
||||
"""Get logger instance."""
|
||||
import logging
|
||||
return logging.getLogger(__name__)
|
||||
37
fission-python/template/migrates/schema.sql
Normal file
37
fission-python/template/migrates/schema.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- Migration: 001_initial_schema.sql
|
||||
-- Description: Initial database schema with example items table
|
||||
-- To customize: Rename tables/columns and add your own migrations
|
||||
|
||||
-- Create items table (example)
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
metadata JSONB,
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
modified TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create index on status for faster filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_items_status ON items(status);
|
||||
|
||||
-- Create index on created for sorting
|
||||
CREATE INDEX IF NOT EXISTS idx_items_created ON items(created);
|
||||
|
||||
-- Optional: Trigger to auto-update modified timestamp
|
||||
CREATE OR REPLACE FUNCTION update_modified_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.modified = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_items_modtime
|
||||
BEFORE UPDATE ON items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
|
||||
-- Add table comment
|
||||
COMMENT ON TABLE items IS 'Example items table - replace with your own schema';
|
||||
8
fission-python/template/pytest.ini
Normal file
8
fission-python/template/pytest.ini
Normal file
@@ -0,0 +1,8 @@
|
||||
[pytest]
|
||||
testpaths = test
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
log_cli = true
|
||||
log_cli_level = INFO
|
||||
addopts = -v --tb=short
|
||||
42
fission-python/template/specs/README
Normal file
42
fission-python/template/specs/README
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
Fission Specs
|
||||
=============
|
||||
|
||||
This is a set of specifications for a Fission app. This includes functions,
|
||||
environments, and triggers; we collectively call these things "resources".
|
||||
|
||||
How to use these specs
|
||||
----------------------
|
||||
|
||||
These specs are handled with the 'fission spec' command. See 'fission spec --help'.
|
||||
|
||||
'fission spec apply' will "apply" all resources specified in this directory to your
|
||||
cluster. That means it checks what resources exist on your cluster, what resources are
|
||||
specified in the specs directory, and reconciles the difference by creating, updating or
|
||||
deleting resources on the cluster.
|
||||
|
||||
'fission spec apply' will also package up your source code (or compiled binaries) and
|
||||
upload the archives to the cluster if needed. It uses 'ArchiveUploadSpec' resources in
|
||||
this directory to figure out which files to archive.
|
||||
|
||||
You can use 'fission spec apply --watch' to watch for file changes and continuously keep
|
||||
the cluster updated.
|
||||
|
||||
You can add YAMLs to this directory by writing them manually, but it's easier to generate
|
||||
them. Use 'fission function create --spec' to generate a function spec,
|
||||
'fission environment create --spec' to generate an environment spec, and so on.
|
||||
|
||||
You can edit any of the files in this directory, except 'fission-deployment-config.yaml',
|
||||
which contains a UID that you should never change. To apply your changes simply use
|
||||
'fission spec apply'.
|
||||
|
||||
fission-deployment-config.yaml
|
||||
------------------------------
|
||||
|
||||
fission-deployment-config.yaml contains a UID. This UID is what fission uses to correlate
|
||||
resources on the cluster to resources in this directory.
|
||||
|
||||
All resources created by 'fission spec apply' are annotated with this UID. Resources on
|
||||
the cluster that are _not_ annotated with this UID are never modified or deleted by
|
||||
fission.
|
||||
|
||||
0
fission-python/template/src/__init__.py
Normal file
0
fission-python/template/src/__init__.py
Normal file
15
fission-python/template/src/build.sh
Executable file
15
fission-python/template/src/build.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
ID=$( grep "^ID=" /etc/os-release | awk -F= '{print $2}' )
|
||||
|
||||
if [ "${ID}" = "debian" ]
|
||||
then
|
||||
apt-get update && apt-get install -y gcc libpq-dev python3-dev
|
||||
else
|
||||
apk update && apk add gcc postgresql-dev python3-dev
|
||||
fi
|
||||
|
||||
if [ -f ${SRC_PKG}/requirements.txt ]
|
||||
then
|
||||
pip3 install -r ${SRC_PKG}/requirements.txt -t ${SRC_PKG}
|
||||
fi
|
||||
cp -r ${SRC_PKG} ${DEPLOY_PKG}
|
||||
103
fission-python/template/src/exceptions.py
Normal file
103
fission-python/template/src/exceptions.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Custom exceptions for Fission Python functions.
|
||||
|
||||
All exceptions include:
|
||||
- error_code: Machine-readable error identifier
|
||||
- http_status: Appropriate HTTP status code
|
||||
- error_msg: Human-readable message
|
||||
- x_user: Optional user identifier from request headers
|
||||
- details: Optional additional error context
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ServiceException(Exception):
|
||||
"""Base exception for service errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_code: str,
|
||||
http_status: int,
|
||||
error_msg: str,
|
||||
x_user: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
):
|
||||
self.error_code = error_code
|
||||
self.http_status = http_status
|
||||
self.error_msg = error_msg
|
||||
self.x_user = x_user
|
||||
self.details = details
|
||||
super().__init__(self.error_msg)
|
||||
|
||||
|
||||
class ValidationError(ServiceException):
|
||||
"""Invalid request data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_msg: str,
|
||||
x_user: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
):
|
||||
super().__init__(
|
||||
error_code="VALIDATION_ERROR",
|
||||
http_status=400,
|
||||
error_msg=error_msg,
|
||||
x_user=x_user,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class NotFoundError(ServiceException):
|
||||
"""Resource not found."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_msg: str,
|
||||
x_user: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
):
|
||||
super().__init__(
|
||||
error_code="NOT_FOUND",
|
||||
http_status=404,
|
||||
error_msg=error_msg,
|
||||
x_user=x_user,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class ConflictError(ServiceException):
|
||||
"""Resource conflict (e.g., duplicate name)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_msg: str,
|
||||
x_user: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
):
|
||||
super().__init__(
|
||||
error_code="CONFLICT",
|
||||
http_status=409,
|
||||
error_msg=error_msg,
|
||||
x_user=x_user,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class DatabaseError(ServiceException):
|
||||
"""Database operation failed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_msg: str,
|
||||
x_user: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
):
|
||||
super().__init__(
|
||||
error_code="DB_ERROR",
|
||||
http_status=500,
|
||||
error_msg=error_msg,
|
||||
x_user=x_user,
|
||||
details=details,
|
||||
)
|
||||
251
fission-python/template/src/helpers.py
Normal file
251
fission-python/template/src/helpers.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Helper utilities for Fission Python functions.
|
||||
|
||||
Provides database connectivity, configuration/secrets access, and basic data utilities.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import psycopg2
|
||||
from flask import current_app
|
||||
from psycopg2.extras import LoggingConnection
|
||||
from vault import decrypt_vault, is_valid_vault_format
|
||||
|
||||
# Configuration - these will be overridden by environment-specific values
|
||||
CORS_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
# These placeholders will be replaced by create-project.sh with actual project names
|
||||
SECRET_NAME = "${PROJECT_NAME}-env"
|
||||
CONFIG_NAME = "${PROJECT_NAME}-config"
|
||||
K8S_NAMESPACE = "default"
|
||||
CRYPTO_KEY = "" # Set this in your deployment environment
|
||||
|
||||
# Logging setup
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_db_connection():
|
||||
"""
|
||||
Initialize PostgreSQL database connection.
|
||||
|
||||
Configuration is loaded from Kubernetes secrets or defaults.
|
||||
|
||||
Returns:
|
||||
psycopg2 connection object
|
||||
|
||||
Raises:
|
||||
Exception: If database connection fails or port check fails
|
||||
"""
|
||||
db_host = get_secret("PG_HOST", "127.0.0.1")
|
||||
db_port = int(get_secret("PG_PORT", 5432))
|
||||
|
||||
if not check_port_open(ip=db_host, port=db_port):
|
||||
raise Exception(f"Failed to connect to database at {db_host}:{db_port}")
|
||||
|
||||
options = get_secret("PG_DBSCHEMA")
|
||||
if options:
|
||||
options = f"-c search_path={options}" # if specific db schema
|
||||
conn = psycopg2.connect(
|
||||
database=get_secret("PG_DB", "postgres"),
|
||||
user=get_secret("PG_USER", "postgres"),
|
||||
password=get_secret("PG_PASS", "secret"),
|
||||
host=db_host,
|
||||
port=db_port,
|
||||
options=options,
|
||||
connection_factory=LoggingConnection,
|
||||
)
|
||||
conn.initialize(logger)
|
||||
return conn
|
||||
|
||||
|
||||
def db_row_to_dict(cursor, row) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert a database row to a dictionary.
|
||||
|
||||
Args:
|
||||
cursor: Database cursor (with description attribute)
|
||||
row: Database row tuple
|
||||
|
||||
Returns:
|
||||
Dictionary mapping column names to values (datetime converted to isoformat)
|
||||
"""
|
||||
record = {}
|
||||
for i, column in enumerate(cursor.description):
|
||||
data = row[i]
|
||||
if isinstance(data, datetime.datetime):
|
||||
data = data.isoformat()
|
||||
record[column.name] = data
|
||||
return record
|
||||
|
||||
|
||||
def db_rows_to_array(cursor, rows) -> list:
|
||||
"""
|
||||
Convert multiple database rows to list of dictionaries.
|
||||
|
||||
Args:
|
||||
cursor: Database cursor
|
||||
rows: List of row tuples
|
||||
|
||||
Returns:
|
||||
List of dictionaries
|
||||
"""
|
||||
return [db_row_to_dict(cursor, row) for row in rows]
|
||||
|
||||
|
||||
def get_current_namespace() -> str:
|
||||
"""
|
||||
Get current Kubernetes namespace from service account secret.
|
||||
|
||||
Returns:
|
||||
Namespace string or default K8S_NAMESPACE if not available
|
||||
"""
|
||||
try:
|
||||
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f:
|
||||
namespace = f.read().strip()
|
||||
except Exception:
|
||||
namespace = K8S_NAMESPACE
|
||||
return str(namespace)
|
||||
|
||||
|
||||
def get_secret(key: str, default=None) -> str:
|
||||
"""
|
||||
Read a secret from Kubernetes secrets volume.
|
||||
|
||||
Args:
|
||||
key: Secret key name
|
||||
default: Default value if secret not found
|
||||
|
||||
Returns:
|
||||
Secret value (decrypted if vault-encrypted) or default
|
||||
"""
|
||||
namespace = get_current_namespace()
|
||||
path = f"/secrets/{namespace}/{SECRET_NAME}/{key}"
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
value = f.read().strip()
|
||||
if value:
|
||||
if is_valid_vault_format(value):
|
||||
return decrypt_vault(value, CRYPTO_KEY)
|
||||
else:
|
||||
return value
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Failed to read secret {path}: {err}")
|
||||
return default
|
||||
|
||||
|
||||
def get_config(key: str, default=None) -> str:
|
||||
"""
|
||||
Read configuration from Kubernetes config volume.
|
||||
|
||||
Args:
|
||||
key: Config key name
|
||||
default: Default value if config not found
|
||||
|
||||
Returns:
|
||||
Config value (decrypted if vault-encrypted) or default
|
||||
"""
|
||||
namespace = get_current_namespace()
|
||||
path = f"/configs/{namespace}/{CONFIG_NAME}/{key}"
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
value = f.read().strip()
|
||||
if value:
|
||||
if is_valid_vault_format(value):
|
||||
return decrypt_vault(value, CRYPTO_KEY)
|
||||
else:
|
||||
return value
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Failed to read config {path}: {err}")
|
||||
return default
|
||||
|
||||
|
||||
def str_to_bool(input: Optional[str]) -> Optional[bool]:
|
||||
"""
|
||||
Convert string representation to boolean.
|
||||
|
||||
Args:
|
||||
input: String value ('true', 'false', or None)
|
||||
|
||||
Returns:
|
||||
True, False, or None if not recognized
|
||||
"""
|
||||
input = input or ""
|
||||
BOOL_MAP = {"true": True, "false": False}
|
||||
return BOOL_MAP.get(input.strip().lower(), None)
|
||||
|
||||
|
||||
def check_port_open(ip: str, port: int, timeout: int = 30) -> bool:
|
||||
"""
|
||||
Check if a TCP port is open on the given IP address.
|
||||
|
||||
Args:
|
||||
ip: IP address or hostname
|
||||
port: Port number
|
||||
timeout: Connection timeout in seconds
|
||||
|
||||
Returns:
|
||||
True if port is open, False otherwise
|
||||
"""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(timeout)
|
||||
result = s.connect_ex((ip, port))
|
||||
return result == 0
|
||||
except Exception as err:
|
||||
logger.error(f"Check port open error: {err}")
|
||||
return False
|
||||
|
||||
|
||||
def get_user_from_headers() -> Optional[str]:
|
||||
"""
|
||||
Extract user identifier from request headers.
|
||||
|
||||
Returns:
|
||||
User ID from X-Fission-Params-UserId or similar header, or None if not present.
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
# Try common header names
|
||||
user_id = (
|
||||
request.headers.get("X-Fission-Params-UserId")
|
||||
or request.headers.get("X-User-Id")
|
||||
or request.headers.get("User-Id")
|
||||
)
|
||||
return user_id
|
||||
|
||||
|
||||
def format_error_response(
|
||||
error_code: str,
|
||||
error_msg: str,
|
||||
http_status: int,
|
||||
x_user: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a standardized error response dictionary.
|
||||
|
||||
Args:
|
||||
error_code: Machine-readable error identifier
|
||||
error_msg: Human-readable error message
|
||||
http_status: HTTP status code
|
||||
x_user: Optional user identifier
|
||||
details: Optional additional error context
|
||||
|
||||
Returns:
|
||||
Dictionary formatted as ErrorResponse schema
|
||||
"""
|
||||
response = {
|
||||
"error_code": error_code,
|
||||
"http_status": http_status,
|
||||
"error_msg": error_msg,
|
||||
}
|
||||
if x_user:
|
||||
response["x_user"] = x_user
|
||||
if details:
|
||||
response["details"] = details
|
||||
return response
|
||||
153
fission-python/template/src/models.py
Normal file
153
fission-python/template/src/models.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Pydantic models for request/response validation and data schemas.
|
||||
|
||||
This file provides example patterns that you can adapt for your service:
|
||||
- Enums for controlled vocabularies
|
||||
- Request models with validation
|
||||
- Response models with serialization config
|
||||
- Pagination and filtering patterns
|
||||
- Nested model relationships
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
from flask import request
|
||||
|
||||
|
||||
# ========== Example Enums ==========
|
||||
class Status(str, enum.Enum):
|
||||
"""Example status enum."""
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
class DataType(str, enum.Enum):
|
||||
"""Example data type enum."""
|
||||
ITEM = "ITEM"
|
||||
COLLECTION = "COLLECTION"
|
||||
|
||||
|
||||
# ========== Filter Models (for query parameters) ==========
|
||||
@typing.dataclass
|
||||
class ItemFilter:
|
||||
"""
|
||||
Example filter using dataclass.
|
||||
|
||||
Filters are often built from request query parameters.
|
||||
"""
|
||||
ids: typing.Optional[typing.List[str]] = None
|
||||
keyword: typing.Optional[str] = None
|
||||
status: typing.Optional[typing.List[str]] = None
|
||||
created_from: typing.Optional[datetime.datetime] = None
|
||||
created_to: typing.Optional[datetime.datetime] = None
|
||||
|
||||
@classmethod
|
||||
def from_request_queries(cls) -> "ItemFilter":
|
||||
"""Build filter from Flask request query parameters."""
|
||||
filter = ItemFilter()
|
||||
filter.ids = request.args.getlist("filter[ids]")
|
||||
filter.keyword = request.args.get("filter[keyword]")
|
||||
filter.status = request.args.getlist("filter[status]")
|
||||
filter.created_from = request.args.get("filter[created_from]")
|
||||
filter.created_to = request.args.get("filter[created_to]")
|
||||
return filter
|
||||
|
||||
|
||||
@typing.dataclass
|
||||
class Pagination:
|
||||
"""Pagination parameters."""
|
||||
page: int = 0
|
||||
size: int = 10
|
||||
asc: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_request_queries(cls) -> "Pagination":
|
||||
"""Build pagination from request query parameters."""
|
||||
p = Pagination()
|
||||
p.page = int(request.args.get("page", 0))
|
||||
p.size = int(request.args.get("size", 10))
|
||||
p.asc = bool(request.args.get("asc", True))
|
||||
return p
|
||||
|
||||
|
||||
# ========== Request Models ==========
|
||||
class ItemCreateRequest(pydantic.BaseModel):
|
||||
"""Request model for creating a new item."""
|
||||
name: str = pydantic.Field(..., min_length=1, max_length=255, description="Item name")
|
||||
description: typing.Optional[str] = pydantic.Field(
|
||||
default=None, description="Item description"
|
||||
)
|
||||
status: Status = pydantic.Field(default=Status.ACTIVE, description="Item status")
|
||||
metadata: typing.Optional[dict] = pydantic.Field(
|
||||
default=None, description="Additional metadata as JSON"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"name": "Example Item",
|
||||
"description": "A sample item",
|
||||
"status": "active",
|
||||
"metadata": {"key": "value"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ItemUpdateRequest(pydantic.BaseModel):
|
||||
"""Request model for updating an existing item."""
|
||||
name: typing.Optional[str] = pydantic.Field(
|
||||
default=None, min_length=1, max_length=255, description="Item name"
|
||||
)
|
||||
description: typing.Optional[str] = pydantic.Field(
|
||||
default=None, description="Item description"
|
||||
)
|
||||
status: typing.Optional[Status] = pydantic.Field(
|
||||
default=None, description="Item status"
|
||||
)
|
||||
metadata: typing.Optional[dict] = pydantic.Field(
|
||||
default=None, description="Additional metadata"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"name": "Updated Item Name",
|
||||
"status": "inactive",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ========== Response Models ==========
|
||||
class ItemResponse(pydantic.BaseModel):
|
||||
"""Standard item response."""
|
||||
id: str = pydantic.Field(..., description="Item unique identifier")
|
||||
name: str = pydantic.Field(..., description="Item name")
|
||||
description: typing.Optional[str] = pydantic.Field(default=None, description="Item description")
|
||||
status: Status = pydantic.Field(..., description="Item status")
|
||||
metadata: typing.Optional[dict] = pydantic.Field(default=None, description="Additional metadata")
|
||||
created: datetime.datetime = pydantic.Field(..., description="Creation timestamp")
|
||||
modified: datetime.datetime = pydantic.Field(..., description="Last modification timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True # Enable ORM mode for SQLAlchemy/psycopg2 compatibility
|
||||
|
||||
|
||||
class PaginatedResponse(pydantic.BaseModel):
|
||||
"""Paginated listing response."""
|
||||
data: typing.List[ItemResponse] = pydantic.Field(..., description="List of items")
|
||||
page: int = pydantic.Field(..., description="Current page number (0-indexed)")
|
||||
size: int = pydantic.Field(..., description="Page size")
|
||||
total: typing.Optional[int] = pydantic.Field(default=None, description="Total count of items")
|
||||
|
||||
|
||||
class ErrorResponse(pydantic.BaseModel):
|
||||
"""Standardized error response format (used by exceptions)."""
|
||||
error_code: str = pydantic.Field(..., description="Machine-readable error identifier")
|
||||
http_status: int = pydantic.Field(..., description="HTTP status code")
|
||||
error_msg: str = pydantic.Field(..., description="Human-readable error message")
|
||||
x_user: typing.Optional[str] = pydantic.Field(default=None, description="User identifier")
|
||||
details: typing.Optional[dict] = pydantic.Field(default=None, description="Additional error context")
|
||||
5
fission-python/template/src/requirements.txt
Normal file
5
fission-python/template/src/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Flask==2.1.1
|
||||
pydantic==2.11.7
|
||||
psycopg2-binary==2.9.10
|
||||
PyNaCl==1.6.0
|
||||
requests==2.32.2
|
||||
142
fission-python/template/src/vault.py
Normal file
142
fission-python/template/src/vault.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import base64
|
||||
|
||||
import nacl.secret
|
||||
|
||||
|
||||
def string_to_hex(text: str) -> str:
|
||||
"""
|
||||
Convert a string to hexadecimal representation.
|
||||
|
||||
Args:
|
||||
text: Input string to convert
|
||||
|
||||
Returns:
|
||||
Hexadecimal string representation
|
||||
"""
|
||||
return text.encode("utf-8").hex()
|
||||
|
||||
|
||||
def hex_to_string(hex_string: str) -> str | None:
|
||||
"""
|
||||
Convert a hexadecimal string back to regular string.
|
||||
|
||||
Args:
|
||||
hex_string: Hexadecimal string to convert
|
||||
|
||||
Returns:
|
||||
Decoded string
|
||||
|
||||
Raises:
|
||||
ValueError: If hex_string is not valid hexadecimal
|
||||
"""
|
||||
return bytes.fromhex(hex_string).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_vault(vault: str, key: str) -> str:
|
||||
"""
|
||||
Decrypt a vault string encrypted with PyNaCl SecretBox.
|
||||
|
||||
Vault format: "vault:v1:<base64_encrypted_data>"
|
||||
|
||||
Args:
|
||||
vault: Vault-formatted string (e.g., "vault:v1:eW91cl9lbmNyeXB0ZWRfZGF0YQ==")
|
||||
key: Hex string representation of 32-byte encryption key
|
||||
|
||||
Returns:
|
||||
Decrypted string
|
||||
|
||||
Raises:
|
||||
ValueError: If vault format is invalid or key is not valid hex
|
||||
nacl.exceptions.CryptoError: If decryption fails (wrong key or corrupted data)
|
||||
"""
|
||||
# Parse vault format
|
||||
parts = vault.split(":", 2)
|
||||
if len(parts) != 3 or parts[0] != "vault" or parts[1] != "v1":
|
||||
raise ValueError("Invalid vault format. Expected 'vault:v1:<encrypted_data>'")
|
||||
|
||||
encrypted_string = parts[2]
|
||||
# Convert hex string key to bytes
|
||||
key_bytes = bytes.fromhex(key)
|
||||
|
||||
# Create a SecretBox instance with the key
|
||||
box = nacl.secret.SecretBox(key_bytes)
|
||||
|
||||
# Decode the base64-encoded encrypted string
|
||||
encrypted_data = base64.b64decode(encrypted_string)
|
||||
|
||||
# Decrypt the data
|
||||
decrypted_bytes = box.decrypt(encrypted_data)
|
||||
|
||||
# Convert bytes to string
|
||||
return decrypted_bytes.decode("utf-8")
|
||||
|
||||
|
||||
def encrypt_vault(plaintext: str, key: str) -> str:
|
||||
"""
|
||||
Encrypt a string and return it in vault format.
|
||||
|
||||
Args:
|
||||
plaintext: String to encrypt
|
||||
key: Hex string representation of 32-byte encryption key
|
||||
|
||||
Returns:
|
||||
Vault-formatted encrypted string (e.g., "vault:v1:<encrypted_data>")
|
||||
|
||||
Raises:
|
||||
ValueError: If key is not valid hex string
|
||||
"""
|
||||
# Convert hex string key to bytes
|
||||
key_bytes = bytes.fromhex(key)
|
||||
|
||||
# Create a SecretBox instance with the key
|
||||
box = nacl.secret.SecretBox(key_bytes)
|
||||
|
||||
# Encrypt the data
|
||||
encrypted = box.encrypt(plaintext.encode("utf-8"))
|
||||
|
||||
# Encode to base64
|
||||
encrypted_string = base64.b64encode(encrypted).decode("utf-8")
|
||||
|
||||
# Return in vault format
|
||||
return f"vault:v1:{encrypted_string}"
|
||||
|
||||
|
||||
def is_valid_vault_format(vault: str) -> bool:
|
||||
"""
|
||||
Check if a string is in valid vault format.
|
||||
|
||||
Vault format: "vault:v1:<base64_encrypted_data>"
|
||||
|
||||
Args:
|
||||
vault: String to validate
|
||||
|
||||
Returns:
|
||||
True if the string matches vault format structure, False otherwise
|
||||
|
||||
Note:
|
||||
This only checks the format structure, not whether the data can be decrypted
|
||||
"""
|
||||
# Parse vault format
|
||||
parts = vault.split(":", 2)
|
||||
|
||||
# Check basic structure: vault:v1:<data>
|
||||
if len(parts) != 3 or parts[0] != "vault" or parts[1] != "v1":
|
||||
return False
|
||||
|
||||
encrypted_data = parts[2]
|
||||
|
||||
# Check if data part is not empty
|
||||
if not encrypted_data:
|
||||
return False
|
||||
|
||||
# Check if data is valid base64
|
||||
try:
|
||||
decoded = base64.b64decode(encrypted_data)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Check if decoded data has at least nonce bytes (24 bytes for NaCl)
|
||||
if len(decoded) < nacl.secret.SecretBox.NONCE_SIZE:
|
||||
return False
|
||||
|
||||
return True
|
||||
0
fission-python/template/test/__init__.py
Normal file
0
fission-python/template/test/__init__.py
Normal file
3
fission-python/template/test/requirements.txt
Normal file
3
fission-python/template/test/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
pytest==8.2.0
|
||||
pytest-mock==3.14.0
|
||||
requests==2.32.3
|
||||
40
fission-python/template/test/test_example.py
Normal file
40
fission-python/template/test/test_example.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Example test file for Fission Python functions.
|
||||
|
||||
This demonstrates basic testing patterns.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def test_placeholder():
|
||||
"""Placeholder test - replace with your actual tests."""
|
||||
assert True
|
||||
|
||||
|
||||
# Example: Testing a function with mocked dependencies
|
||||
@patch("helpers.init_db_connection")
|
||||
def test_example_with_mock(mock_db):
|
||||
"""Example test showing how to mock database."""
|
||||
from examples.example_crud import create_item
|
||||
|
||||
# Setup mock
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_db.return_value = mock_conn
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_cursor.fetchone.return_value = ("id-123", "Test Item", "active")
|
||||
|
||||
# Mock Flask request
|
||||
with patch("examples.example_crud.request") as mock_request:
|
||||
mock_request.get_json.return_value = {"name": "Test Item", "status": "active"}
|
||||
mock_request.view_args = {}
|
||||
|
||||
# Call function
|
||||
result = create_item({}, {})
|
||||
|
||||
# Assert
|
||||
assert result["name"] == "Test Item"
|
||||
mock_cursor.execute.assert_called_once()
|
||||
mock_conn.commit.assert_called_once()
|
||||
290
fission-python/update-docstring.sh
Executable file
290
fission-python/update-docstring.sh
Executable file
@@ -0,0 +1,290 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Fission Docstring Updater
|
||||
# Parses and updates embedded fission configuration in Python function docstrings
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <file-path> [function-name] [--set \"<json>\"] [--get] [--help]"
|
||||
echo ""
|
||||
echo "Arguments:"
|
||||
echo " file-path: Path to the Python file containing the function"
|
||||
echo " function-name: Optional specific function name to target (if not provided, processes all functions with fission configuration)"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --set <json>: Set the fission configuration to the provided JSON string"
|
||||
echo " --get: Get/display the current fission configuration (default action)"
|
||||
echo " --help: Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 ./src/my_function.py main --get"
|
||||
echo " $0 ./src/my_function.py main --set '{\"name\": \"updated-function\"}'"
|
||||
echo " $0 ./src/functions.py --get"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check dependencies
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "Error: python3 is required but not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
FILE_PATH="$1"
|
||||
shift
|
||||
|
||||
FUNCTION_NAME=""
|
||||
ACTION="get"
|
||||
SET_VALUE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--set)
|
||||
SET_VALUE="$2"
|
||||
ACTION="set"
|
||||
shift 2
|
||||
;;
|
||||
--get)
|
||||
ACTION="get"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$FUNCTION_NAME" ]]; then
|
||||
FUNCTION_NAME="$1"
|
||||
else
|
||||
echo "Error: Unexpected argument '$1'"
|
||||
usage
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate file exists
|
||||
if [[ ! -f "$FILE_PATH" ]]; then
|
||||
echo "Error: File '$FILE_PATH' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate JSON if setting
|
||||
if [[ "$ACTION" == "set" && -z "$SET_VALUE" ]]; then
|
||||
echo "Error: --set requires a JSON value"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$ACTION" == "set" ]]; then
|
||||
# Validate JSON format
|
||||
if ! echo "$SET_VALUE" | python3 -m json.tool >/dev/null 2>&1; then
|
||||
echo "Error: Invalid JSON provided for --set"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Python script to handle the docstring parsing and updating
|
||||
PYTHON_SCRIPT=$(cat << 'EOF'
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def extract_functions_with_fission(content):
|
||||
"""Extract all functions that have fission configuration in their docstrings."""
|
||||
# Pattern to match Python functions with docstrings containing fission configuration
|
||||
# This looks for def function_name(): followed by a docstring that contains ```fission
|
||||
pattern = r'(\s*def\s+(\w+)\s*\([^)]*\):\s*(?:\n\s*)?\"\"\"[\s\S]*?```fission[\s\S]*?```[\s\S]*?\"\"\"[\s\S]*?)(?=\n\s*def|\n\s*class|\Z)'
|
||||
|
||||
matches = re.finditer(pattern, content, re.MULTILINE)
|
||||
functions = []
|
||||
|
||||
for match in matches:
|
||||
full_match = match.group(1)
|
||||
# Extract function name from the match
|
||||
func_name_match = re.search(r'def\s+(\w+)\s*\(', full_match)
|
||||
if func_name_match:
|
||||
func_name = func_name_match.group(1)
|
||||
functions.append({
|
||||
'name': func_name,
|
||||
'full_text': full_match,
|
||||
'start_pos': match.start(),
|
||||
'end_pos': match.end()
|
||||
})
|
||||
|
||||
return functions
|
||||
|
||||
def extract_fission_config(docstring):
|
||||
"""Extract fission configuration from a docstring."""
|
||||
# Look for ```fission ... ``` blocks
|
||||
pattern = r'```fission\s*([\s\S]*?)\s*```'
|
||||
match = re.search(pattern, docstring)
|
||||
if match:
|
||||
config_text = match.group(1).strip()
|
||||
try:
|
||||
return json.loads(config_text)
|
||||
except json.JSONDecodeError as e:
|
||||
return None
|
||||
return None
|
||||
|
||||
def replace_fission_config_in_docstring(docstring, new_config):
|
||||
"""Replace fission configuration in a docstring with new config."""
|
||||
# Format the new config as JSON with indentation
|
||||
formatted_config = json.dumps(new_config, indent=4)
|
||||
# Replace the ```fission ... ``` block
|
||||
pattern = r'(```fission\s*)[\s\S]*?(\s*```)'
|
||||
replacement = r'\1' + formatted_config + r'\2'
|
||||
return re.sub(pattern, replacement, docstring, flags=re.DOTALL)
|
||||
|
||||
def process_file(file_path, target_function=None, action='get', set_value=None):
|
||||
"""Process the Python file to get or set fission configuration."""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
except IOError as e:
|
||||
print(f"Error: Cannot read file '{file_path}': {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
functions = extract_functions_with_fission(content)
|
||||
|
||||
if not functions:
|
||||
print("No functions with fission configuration found in file.", file=sys.stderr)
|
||||
if action == 'get':
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
# Filter by function name if specified
|
||||
if target_function:
|
||||
functions = [f for f in functions if f['name'] == target_function]
|
||||
if not functions:
|
||||
print(f"Error: Function '{target_function}' with fission configuration not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if action == 'get':
|
||||
# Display current configuration for each function
|
||||
for func in functions:
|
||||
# Extract docstring from the function text
|
||||
docstring_match = re.search(r'\"\"\"[\s\S]*?\"\"\"', func['full_text'])
|
||||
if docstring_match:
|
||||
docstring = docstring_match.group(0)
|
||||
config = extract_fission_config(docstring)
|
||||
if config is not None:
|
||||
if len(functions) == 1:
|
||||
print(json.dumps(config, indent=2))
|
||||
else:
|
||||
print(f"Function '{func['name']}':")
|
||||
print(json.dumps(config, indent=2))
|
||||
print()
|
||||
else:
|
||||
if len(functions) == 1:
|
||||
print("No fission configuration found in function docstring.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Function '{func['name']}': No fission configuration found in docstring.", file=sys.stderr)
|
||||
else:
|
||||
if len(functions) == 1:
|
||||
print("Could not extract docstring from function.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Function '{func['name']}': Could not extract docstring.", file=sys.stderr)
|
||||
|
||||
elif action == 'set':
|
||||
if set_value is None:
|
||||
print("Error: No value provided for --set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
new_config = json.loads(set_value)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON provided for --set: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Update each function
|
||||
updated_content = content
|
||||
offset = 0 # Track position changes due to replacements
|
||||
|
||||
for func in functions:
|
||||
# Extract docstring from the function text
|
||||
docstring_match = re.search(r'\"\"\"[\s\S]*?\"\"\"', func['full_text'])
|
||||
if docstring_match:
|
||||
docstring = docstring_match.group(0)
|
||||
# Check if fission configuration exists
|
||||
if extract_fission_config(docstring) is not None:
|
||||
# Replace fission configuration in docstring
|
||||
new_docstring = replace_fission_config_in_docstring(docstring, new_config)
|
||||
# Replace the docstring in the function text
|
||||
new_func_text = func['full_text'].replace(docstring, new_docstring, 1)
|
||||
# Replace in the overall content (adjusting for previous changes)
|
||||
start_pos = func['start_pos'] + offset
|
||||
end_pos = func['end_pos'] + offset
|
||||
# Update content with the change
|
||||
before = updated_content[:start_pos]
|
||||
after = updated_content[end_pos:]
|
||||
updated_content = before + new_func_text + after
|
||||
# Update offset for next replacements
|
||||
offset += len(new_func_text) - len(func['full_text'])
|
||||
else:
|
||||
print(f"Warning: No fission configuration found in function '{func['name']}' to update.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Warning: Could not extract docstring from function '{func['name']}'.", file=sys.stderr)
|
||||
|
||||
# Write back to file
|
||||
try:
|
||||
with open(file_path, 'w') as f:
|
||||
f.write(updated_content)
|
||||
if len(functions) == 1:
|
||||
print(f"Updated fission configuration in function '{functions[0]['name']}'.")
|
||||
else:
|
||||
print(f"Updated fission configuration in {len(functions)} function(s).")
|
||||
except IOError as e:
|
||||
print(f"Error: Cannot write to file '{file_path}': {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: fission-docstring-updater <file-path> [function-name] [--set \"<json>\"] [--get]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
file_path = sys.argv[1]
|
||||
target_function = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith('--') else None
|
||||
|
||||
# Parse arguments
|
||||
action = 'get'
|
||||
set_value = None
|
||||
|
||||
i = 3 if target_function else 2
|
||||
while i < len(sys.argv):
|
||||
if sys.argv[i] == '--set' and i + 1 < len(sys.argv):
|
||||
action = 'set'
|
||||
set_value = sys.argv[i + 1]
|
||||
i += 2
|
||||
elif sys.argv[i] == '--get':
|
||||
action = 'get'
|
||||
i += 1
|
||||
else:
|
||||
print(f"Error: Unknown argument '{sys.argv[i]}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
process_file(file_path, target_function, action, set_value)
|
||||
EOF
|
||||
)
|
||||
|
||||
# Build arguments for Python script
|
||||
PYTHON_ARGS=("$FILE_PATH")
|
||||
if [[ -n "$FUNCTION_NAME" ]]; then
|
||||
PYTHON_ARGS+=("$FUNCTION_NAME")
|
||||
fi
|
||||
|
||||
if [[ "$ACTION" == "set" ]]; then
|
||||
PYTHON_ARGS+=("--set" "$SET_VALUE")
|
||||
else
|
||||
PYTHON_ARGS+=("--get")
|
||||
fi
|
||||
|
||||
# Execute the Python script
|
||||
python3 -c "$PYTHON_SCRIPT" "${PYTHON_ARGS[@]}"
|
||||
Reference in New Issue
Block a user