feat: update GitOps workflow and gitea-runner for Docker builds
Build and Push Docker Images / build-api (push) Waiting to run Details
Build and Push Docker Images / build-web (push) Waiting to run Details
Build and Push Docker Images / build-worker (push) Failing after 14m30s Details

- Updated workflow to use parallel jobs for worker, api, web builds
- Added GitHub Actions cache for faster builds
- Fixed Gitea runner to properly support Docker-in-Docker with TLS
- Worker fixes: STAC S3 URL patching, MinIO internal endpoint, model path
- storage.py: Fixed endpoint, credentials, and secure=false
This commit is contained in:
fchinembiri 2026-05-04 22:28:58 +02:00
parent 50a5e829ca
commit 86c575330d
7 changed files with 196 additions and 67 deletions

View File

@ -1,44 +1,89 @@
name: Build and Push Docker Images name: Build and Push Docker Images
on: on:
push: push:
branches: branches:
- main - main
paths: paths:
- 'apps/**' - 'apps/worker/**'
- 'apps/api/**'
- 'apps/web/**'
jobs: jobs:
build-and-push: build-worker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: frankchine username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Worker Image - name: Build and push Worker Image
uses: docker/build-push-action@v4 uses: docker/build-push-action@v5
with: with:
context: ./apps/worker context: ./apps/worker
push: true push: true
tags: frankchine/geocrop-worker:latest, frankchine/geocrop-worker:${{ github.sha }} tags: |
frankchine/geocrop-worker:latest
frankchine/geocrop-worker:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-api:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push API Image - name: Build and push API Image
uses: docker/build-push-action@v4 uses: docker/build-push-action@v5
with: with:
context: ./apps/api context: ./apps/api
push: true push: true
tags: frankchine/geocrop-api:latest, frankchine/geocrop-api:${{ github.sha }} tags: |
frankchine/geocrop-api:latest
frankchine/geocrop-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-web:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Web Image - name: Build and push Web Image
uses: docker/build-push-action@v4 uses: docker/build-push-action@v5
with: with:
context: ./apps/web context: ./apps/web
push: true push: true
tags: frankchine/geocrop-web:latest, frankchine/geocrop-web:${{ github.sha }} tags: |
frankchine/geocrop-web:latest
frankchine/geocrop-web:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -139,13 +139,19 @@ class MinIOStorage(StorageAdapter):
def __init__( def __init__(
self, self,
endpoint: str = "minio.geocrop.svc.cluster.local:9000", endpoint: str = None,
access_key: str = None, access_key: str = None,
secret_key: str = None, secret_key: str = None,
bucket_models: str = "geocrop-models", bucket_models: str = "geocrop-models",
bucket_baselines: str = "geocrop-baselines", bucket_baselines: str = "geocrop-baselines",
bucket_results: str = "geocrop-results", bucket_results: str = "geocrop-results",
): ):
# Default to internal service if not provided
if endpoint is None:
host = os.getenv("MINIO_SERVICE_HOST", "minio.geocrop.svc.cluster.local")
port = os.getenv("MINIO_SERVICE_PORT", "9000")
endpoint = f"{host}:{port}"
self.endpoint = endpoint self.endpoint = endpoint
self.access_key = access_key or os.getenv("MINIO_ACCESS_KEY", "minioadmin") self.access_key = access_key or os.getenv("MINIO_ACCESS_KEY", "minioadmin")
self.secret_key = secret_key or os.getenv("MINIO_SECRET_KEY", "minioadmin") self.secret_key = secret_key or os.getenv("MINIO_SECRET_KEY", "minioadmin")
@ -163,12 +169,20 @@ class MinIOStorage(StorageAdapter):
import boto3 import boto3
from botocore.config import Config from botocore.config import Config
scheme = "https" if ".techarvest.co.zw" in self.endpoint else "http"
url = f"{scheme}://{self.endpoint}"
if "://" in self.endpoint:
url = self.endpoint
self._s3_client = boto3.client( self._s3_client = boto3.client(
"s3", "s3",
endpoint_url=f"http://{self.endpoint}", endpoint_url=url,
aws_access_key_id=self.access_key, aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key, aws_secret_access_key=self.secret_key,
config=Config(signature_version="s3v4"), config=Config(
signature_version="s3v4",
retries={"max_attempts": 3, "mode": "standard"}
),
region_name="us-east-1", region_name="us-east-1",
) )
return self._s3_client return self._s3_client
@ -177,25 +191,46 @@ class MinIOStorage(StorageAdapter):
"""Download model files from geocrop-models bucket. """Download model files from geocrop-models bucket.
Args: Args:
model_key: Full key including prefix (e.g., "models/Zimbabwe_Ensemble_Raw_Model.pkl") model_key: Full key or prefix (e.g., "models/Zimbabwe_Ensemble_Raw_Model.pkl" or "models/v1/")
dest_dir: Local directory to save files dest_dir: Local directory to save files
""" """
dest_dir = Path(dest_dir) dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
# Extract filename from key
filename = Path(model_key).name
local_path = dest_dir / filename
try: try:
print(f" Downloading s3://{self.bucket_models}/{model_key} -> {local_path}") # Check if it's a single file or a prefix
self.s3.download_file( response = self.s3.list_objects_v2(Bucket=self.bucket_models, Prefix=model_key)
self.bucket_models,
model_key, if 'Contents' not in response:
str(local_path) raise FileNotFoundError(f"No objects found with prefix/key {model_key} in {self.bucket_models}")
)
# If it's a single file and the key matches exactly
if len(response['Contents']) == 1 and response['Contents'][0]['Key'] == model_key:
filename = Path(model_key).name
# If inference.py expects 'model.pkl', we provide it
local_path = dest_dir / "model.pkl" if model_key.endswith(".pkl") else dest_dir / filename
print(f" Downloading single file s3://{self.bucket_models}/{model_key} -> {local_path}")
self.s3.download_file(self.bucket_models, model_key, str(local_path))
else:
# It's a prefix, download all files within it
print(f" Downloading prefix s3://{self.bucket_models}/{model_key} to {dest_dir}")
for obj in response['Contents']:
key = obj['Key']
if key.endswith("/"): continue # Skip "directories"
# Get relative path from prefix
rel_path = os.path.relpath(key, model_key)
if rel_path == ".":
rel_path = Path(key).name
target_path = dest_dir / rel_path
target_path.parent.mkdir(parents=True, exist_ok=True)
print(f" -> {key} to {target_path}")
self.s3.download_file(self.bucket_models, key, str(target_path))
except Exception as e: except Exception as e:
raise FileNotFoundError(f"Failed to download model {model_key}: {e}") from e raise FileNotFoundError(f"Failed to download model bundle {model_key}: {e}") from e
def get_dw_local_path(self, year: int, season: str) -> str: def get_dw_local_path(self, year: int, season: str) -> str:
"""Get path to DW baseline COG for given year/season. """Get path to DW baseline COG for given year/season.
@ -207,18 +242,33 @@ class MinIOStorage(StorageAdapter):
season: Season type ("summer") season: Season type ("summer")
Returns: Returns:
VSI S3 path string (e.g., "s3://geocrop-baselines/DW_Zim_HighestConf_2021_2022-...") VSI S3 path string (e.g., "/vsis3/geocrop-baselines/dw/zim/summer/DW_Zim_HighestConf_2021_2022-...")
""" """
# Format: DW_Zim_HighestConf_{year}_{year+1}.tif # Prefix in MinIO
# Note: The actual files may have tile suffixes like -0000000000-0000000000.tif prefix = f"dw/zim/summer/DW_Zim_HighestConf_{year}_{year + 1}"
# We'll return a prefix that rasterio can handle with wildcard
# For now, construct the base path try:
# In production, we might need to find the exact tiles # List objects to find the actual tiles
base_key = f"DW_Zim_HighestConf_{year}_{year + 1}" response = self.s3.list_objects_v2(Bucket=self.bucket_baselines, Prefix=prefix)
# Return VSI path for rasterio to handle if 'Contents' not in response:
return f"s3://{self.bucket_baselines}/{base_key}" # Try alternative prefix without dw/zim/summer
prefix_alt = f"DW_Zim_HighestConf_{year}_{year + 1}"
response = self.s3.list_objects_v2(Bucket=self.bucket_baselines, Prefix=prefix_alt)
if 'Contents' not in response:
raise FileNotFoundError(f"No DW baseline tiles found for {year} {season} in {self.bucket_baselines}")
# For now, just pick the first tile.
# In a real system, we should use a VRT or find the tile that covers the AOI.
# But for testing, the first tile often works if the AOI is near the origin.
key = response['Contents'][0]['Key']
print(f" Found DW baseline tile: {key}")
# Return /vsis3 path for rasterio
return f"/vsis3/{self.bucket_baselines}/{key}"
except Exception as e:
raise FileNotFoundError(f"Failed to find DW baseline: {e}") from e
def upload_result(self, local_path: Path, key: str) -> str: def upload_result(self, local_path: Path, key: str) -> str:
"""Upload result file to geocrop-results bucket. """Upload result file to geocrop-results bucket.

View File

@ -437,7 +437,23 @@ def load_dw_baseline_window(cfg, year: int, season: str, aoi: AOI) -> Tuple[np.n
You can implement a mapping in cfg.dw_key_for(year, season). You can implement a mapping in cfg.dw_key_for(year, season).
""" """
local_path = cfg.storage.get_dw_local_path(year=year, season=season) local_path = cfg.storage.get_dw_local_path(year=year, season=season)
arr, profile = clip_raster_to_aoi(local_path, aoi)
# Configure Rasterio Env for MinIO /vsis3 access if needed
import rasterio.env
storage = cfg.storage
endpoint = storage.endpoint
# Ensure no http/https prefix in endpoint for GDAL
if "://" in endpoint:
endpoint = endpoint.split("://")[1]
env_config = {
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
}
print(f" Configuring Rasterio Env for {local_path}...")
with rasterio.env.Env(**env_config):
arr, profile = clip_raster_to_aoi(local_path, aoi)
# Ensure a single band profile # Ensure a single band profile
profile.update({"count": 1}) profile.update({"count": 1})
@ -586,14 +602,15 @@ def build_feature_stack_from_dea(
search = client.search( search = client.search(
collections=["s2_l2a"], collections=["s2_l2a"],
bbox=bbox, bbox=bbox,
datetime=f"{start_date}/{end_date}", datetime=f"{start_date}/{end_date}"
query={
"eo:cloud_cover": {"lt": 30}, # Cloud filter
}
) )
items = list(search.items()) items = list(search.items())
print(f" Found {len(items)} Sentinel-2 scenes")
# Filter by cloud cover manually since query extension is deprecated/unsupported
items = [it for it in items if it.properties.get("eo:cloud_cover", 100) < 30]
print(f" Found {len(items)} Sentinel-2 scenes (after cloud filtering)")
if len(items) == 0: if len(items) == 0:
raise ValueError("No Sentinel-2 imagery available for the selected AOI and date range") raise ValueError("No Sentinel-2 imagery available for the selected AOI and date range")

View File

@ -118,6 +118,15 @@ class DEAfricaSTACWrapper:
print(f"Connecting to Digital Earth Africa STAC Catalog at {stac_url}...") print(f"Connecting to Digital Earth Africa STAC Catalog at {stac_url}...")
self.catalog = Client.open(stac_url) self.catalog = Client.open(stac_url)
@staticmethod
def _patch_s3_url(url: str) -> str:
if url.startswith("s3://deafrica-sentinel-2"):
return url.replace(
"s3://deafrica-sentinel-2",
"/vsicurl/https://deafrica-sentinel-2.s3.af-south-1.amazonaws.com"
)
return url
def fetch_and_format_data(self, lat_range, lon_range, time_range, resolution=20): def fetch_and_format_data(self, lat_range, lon_range, time_range, resolution=20):
bbox = [lon_range[0], lat_range[0], lon_range[1], lat_range[1]] bbox = [lon_range[0], lat_range[0], lon_range[1], lat_range[1]]
print(f"Searching STAC for Bounding Box: {bbox} over {time_range}...") print(f"Searching STAC for Bounding Box: {bbox} over {time_range}...")
@ -132,7 +141,6 @@ class DEAfricaSTACWrapper:
print(f"Found {len(items)} STAC items. Loading into xarray...") print(f"Found {len(items)} STAC items. Loading into xarray...")
# Mapping for DE Africa S2 bands
band_map = { band_map = {
'B04': 'red', 'B04': 'red',
'B03': 'green', 'B03': 'green',
@ -142,13 +150,16 @@ class DEAfricaSTACWrapper:
'SCL': 'scl' 'SCL': 'scl'
} }
os.environ["GDAL_DISABLE_READDIR_ON_OPEN"] = "EMPTY_DIR"
ds = odc.stac.load( ds = odc.stac.load(
items, items,
measurements=list(band_map.keys()), measurements=list(band_map.keys()),
bbox=bbox, bbox=bbox,
crs="EPSG:6933", crs="EPSG:6933",
resolution=resolution, resolution=resolution,
groupby="solar_day" groupby="solar_day",
patch_url=self._patch_s3_url
) )
# Rename bands to expected names # Rename bands to expected names

View File

@ -186,26 +186,20 @@ class DEASTACClient:
collections = [coll_id] collections = [coll_id]
def _search(): def _search():
# Build query
query_params = {}
# Try cloud cover filter if DEA_CLOUD_MAX > 0
if self.cloud_max > 0:
try:
# Try with eo:cloud_cover (DEA supports this)
query_params["eo:cloud_cover"] = {"lt": self.cloud_max}
except Exception as e:
logger.warning(f"Cloud filter not supported: {e}")
search = self.client.search( search = self.client.search(
collections=collections, collections=collections,
bbox=bbox, bbox=bbox,
datetime=f"{start_date}/{end_date}", datetime=f"{start_date}/{end_date}",
limit=limit, limit=limit,
query=query_params if query_params else None,
) )
return list(search.items()) items = list(search.items())
# Filter by cloud cover manually if requested
if self.cloud_max > 0:
items = [it for it in items if it.properties.get("eo:cloud_cover", 100) < self.cloud_max]
return items
return self._retry_operation(_search) return self._retry_operation(_search)

View File

@ -328,8 +328,8 @@ def run_job(payload_dict: dict) -> dict:
print(f"[{job_id}] Downloaded {artifact}") print(f"[{job_id}] Downloaded {artifact}")
except Exception as e: except Exception as e:
try: try:
storage.download_file(storage.bucket_models, f"hybrid/{artifact}", model_dir / artifact) storage.download_file(storage.bucket_models, f"models/{artifact}", model_dir / artifact)
print(f"[{job_id}] Downloaded {artifact} (from hybrid/ prefix)") print(f"[{job_id}] Downloaded {artifact} (from models/ prefix)")
except Exception as e2: except Exception as e2:
raise FileNotFoundError( raise FileNotFoundError(
f"Required artifact {artifact} not found in {storage.bucket_models}: {e2}" f"Required artifact {artifact} not found in {storage.bucket_models}: {e2}"

View File

@ -15,6 +15,9 @@ spec:
labels: labels:
app: gitea-runner app: gitea-runner
spec: spec:
securityContext:
supplementalGroups:
- 999
hostNetwork: true hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet dnsPolicy: ClusterFirstWithHostNet
containers: containers:
@ -30,12 +33,17 @@ spec:
- name: CONFIG_FILE - name: CONFIG_FILE
value: /config.yaml value: /config.yaml
- name: DOCKER_HOST - name: DOCKER_HOST
value: unix:///var/run/docker.sock value: tcp://localhost:2376
- name: DOCKER_TLS_CERTDIR
value: /certs/client
securityContext:
privileged: true
volumeMounts: volumeMounts:
- name: runner-data - name: runner-data
mountPath: /data mountPath: /data
- name: docker-socket - name: docker-certs
mountPath: /var/run mountPath: /certs/client
readOnly: true
- name: config - name: config
mountPath: /config.yaml mountPath: /config.yaml
subPath: config.yaml subPath: config.yaml
@ -45,18 +53,22 @@ spec:
privileged: true privileged: true
env: env:
- name: DOCKER_TLS_CERTDIR - name: DOCKER_TLS_CERTDIR
value: "" value: /certs/client
- name: DOCKER_DRIVER
value: overlay2
volumeMounts: volumeMounts:
- name: runner-data
mountPath: /data
- name: docker-graph-storage - name: docker-graph-storage
mountPath: /var/lib/docker mountPath: /var/lib/docker
- name: docker-socket - name: docker-certs
mountPath: /var/run mountPath: /certs/client
volumes: volumes:
- name: runner-data - name: runner-data
emptyDir: {} emptyDir: {}
- name: docker-graph-storage - name: docker-graph-storage
emptyDir: {} emptyDir: {}
- name: docker-socket - name: docker-certs
emptyDir: {} emptyDir: {}
- name: config - name: config
configMap: configMap: