diff --git a/docs/deployment_patterns.md b/docs/deployment_patterns.md index fb2e0063..2ec82dca 100644 --- a/docs/deployment_patterns.md +++ b/docs/deployment_patterns.md @@ -75,3 +75,52 @@ This overwrites your customizations with defaults from the stack's `commands.py` git pull # Get latest spec.yml from your operator repo laconic-so deployment --dir my-deployment restart ``` + +## Private Registry Authentication + +For deployments using images from private container registries (e.g., GitHub Container Registry), configure authentication in your spec.yml: + +### Configuration + +Add an `image-registry` section to your spec.yml: + +```yaml +image-registry: + server: ghcr.io + username: your-org-or-username + token-env: REGISTRY_TOKEN +``` + +**Fields:** +- `server`: The registry hostname (e.g., `ghcr.io`, `docker.io`, `gcr.io`) +- `username`: Registry username (for GHCR, use your GitHub username or org name) +- `token-env`: Name of the environment variable containing your API token/PAT + +### Token Environment Variable + +The `token-env` pattern keeps credentials out of version control. Set the environment variable when running `deployment start`: + +```bash +export REGISTRY_TOKEN="your-personal-access-token" +laconic-so deployment --dir my-deployment start +``` + +For GHCR, create a Personal Access Token (PAT) with `read:packages` scope. + +### Ansible Integration + +When using Ansible for deployments, pass the token from a credentials file: + +```yaml +- name: Start deployment + ansible.builtin.command: + cmd: laconic-so deployment --dir {{ deployment_dir }} start + environment: + REGISTRY_TOKEN: "{{ lookup('file', '~/.credentials/ghcr_token') }}" +``` + +### How It Works + +1. laconic-so reads the `image-registry` config from spec.yml +2. Creates a Kubernetes `docker-registry` secret named `{deployment}-registry` +3. The deployment's pods reference this secret for image pulls diff --git a/stack_orchestrator/deploy/deployment_create.py b/stack_orchestrator/deploy/deployment_create.py index bfa5b3d6..870410f8 100644 --- a/stack_orchestrator/deploy/deployment_create.py +++ b/stack_orchestrator/deploy/deployment_create.py @@ -15,6 +15,7 @@ import click from importlib import util +import json import os import re import base64 @@ -554,6 +555,87 @@ def _generate_and_store_secrets(config_vars: dict, deployment_name: str): return secrets +def create_registry_secret(spec: Spec, deployment_name: str) -> Optional[str]: + """Create K8s docker-registry secret from spec + environment. + + Reads registry configuration from spec.yml and creates a Kubernetes + secret of type kubernetes.io/dockerconfigjson for image pulls. + + Args: + spec: The deployment spec containing image-registry config + deployment_name: Name of the deployment (used for secret naming) + + Returns: + The secret name if created, None if no registry config + """ + from kubernetes import client, config as k8s_config + + registry_config = spec.get_image_registry_config() + if not registry_config: + return None + + server = registry_config.get("server") + username = registry_config.get("username") + token_env = registry_config.get("token-env") + + if not all([server, username, token_env]): + return None + + # Type narrowing for pyright - we've validated these aren't None above + assert token_env is not None + token = os.environ.get(token_env) + if not token: + print( + f"Warning: Registry token env var '{token_env}' not set, " + "skipping registry secret" + ) + return None + + # Create dockerconfigjson format (Docker API uses "password" field for tokens) + auth = base64.b64encode(f"{username}:{token}".encode()).decode() + docker_config = { + "auths": {server: {"username": username, "password": token, "auth": auth}} + } + + # Secret name derived from deployment name + secret_name = f"{deployment_name}-registry" + + # Load kube config + try: + k8s_config.load_kube_config() + except Exception: + try: + k8s_config.load_incluster_config() + except Exception: + print("Warning: Could not load kube config, registry secret not created") + return None + + v1 = client.CoreV1Api() + namespace = "default" + + k8s_secret = client.V1Secret( + metadata=client.V1ObjectMeta(name=secret_name), + data={ + ".dockerconfigjson": base64.b64encode( + json.dumps(docker_config).encode() + ).decode() + }, + type="kubernetes.io/dockerconfigjson", + ) + + try: + v1.create_namespaced_secret(namespace, k8s_secret) + print(f"Created registry secret '{secret_name}' for {server}") + except client.exceptions.ApiException as e: + if e.status == 409: # Already exists + v1.replace_namespaced_secret(secret_name, namespace, k8s_secret) + print(f"Updated registry secret '{secret_name}' for {server}") + else: + raise + + return secret_name + + def _write_config_file( spec_file: Path, config_env_file: Path, deployment_name: Optional[str] = None ): diff --git a/stack_orchestrator/deploy/k8s/cluster_info.py b/stack_orchestrator/deploy/k8s/cluster_info.py index 0d9ac2ed..22c3ccf4 100644 --- a/stack_orchestrator/deploy/k8s/cluster_info.py +++ b/stack_orchestrator/deploy/k8s/cluster_info.py @@ -487,7 +487,12 @@ class ClusterInfo: volumes = volumes_for_pod_files( self.parsed_pod_yaml_map, self.spec, self.app_name ) - image_pull_secrets = [client.V1LocalObjectReference(name="laconic-registry")] + registry_config = self.spec.get_image_registry_config() + if registry_config: + secret_name = f"{self.app_name}-registry" + image_pull_secrets = [client.V1LocalObjectReference(name=secret_name)] + else: + image_pull_secrets = [] annotations = None labels = {"app": self.app_name} diff --git a/stack_orchestrator/deploy/k8s/deploy_k8s.py b/stack_orchestrator/deploy/k8s/deploy_k8s.py index 2d44fe23..326cb6ab 100644 --- a/stack_orchestrator/deploy/k8s/deploy_k8s.py +++ b/stack_orchestrator/deploy/k8s/deploy_k8s.py @@ -332,6 +332,11 @@ class K8sDeployer(Deployer): else: print("Dry run mode enabled, skipping k8s API connect") + # Create registry secret if configured + from stack_orchestrator.deploy.deployment_create import create_registry_secret + + create_registry_secret(self.cluster_info.spec, self.cluster_info.app_name) + self._create_volume_data() self._create_deployment() diff --git a/stack_orchestrator/deploy/spec.py b/stack_orchestrator/deploy/spec.py index a870ef60..07b220cd 100644 --- a/stack_orchestrator/deploy/spec.py +++ b/stack_orchestrator/deploy/spec.py @@ -98,6 +98,14 @@ class Spec: def get_image_registry(self): return self.obj.get(constants.image_registry_key) + def get_image_registry_config(self) -> typing.Optional[typing.Dict]: + """Returns registry auth config: {server, username, token-env}. + + Used for private container registries like GHCR. The token-env field + specifies an environment variable containing the API token/PAT. + """ + return self.obj.get("image-registry") + def get_volumes(self): return self.obj.get(constants.volumes_key, {})