diff --git a/README.md b/README.md index e901e3e..1eed834 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,36 @@ # osmanage -A command-line interface for managing OpenSlides instances. This tool provides direct access to OpenSlides backend actions, datastore queries, database migrations, and deployment setup. +A command-line interface for managing OpenSlides instances. This tool provides deployment automation, Kubernetes orchestration, direct access to OpenSlides backend actions, datastore queries, and database migrations. ## Table of Contents - [Overview](#overview) - [Installation](#installation) - [Quick Start](#quick-start) + - [Docker Compose Setup](#docker-compose-setup) - [Commands](#commands) - - [setup](#setup) - - [action](#action) - - [create-user](#create-user) - - [get](#get) - - [initial-data](#initial-data) - - [migrations](#migrations) - - [set](#set) - - [set-password](#set-password) + - [Instance Management](#instance-management) + - [setup](#setup) + - [config](#config) + - [create](#create) + - [remove](#remove) + - [Kubernetes Operations](#kubernetes-operations) + - [k8s start](#k8s-start) + - [k8s stop](#k8s-stop) + - [k8s update-instance](#k8s-update-instance) + - [k8s update-backendmanage](#k8s-update-backendmanage) + - [k8s scale](#k8s-scale) + - [k8s health](#k8s-health) + - [k8s cluster-status](#k8s-cluster-status) + - [Backend Actions](#backend-actions) + - [action](#action) + - [create-user](#create-user) + - [get](#get) + - [initial-data](#initial-data) + - [migrations](#migrations) + - [set](#set) + - [set-password](#set-password) - [Configuration](#configuration) - [Examples](#examples) - [Development](#development) @@ -37,10 +51,11 @@ A command-line interface for managing OpenSlides instances. This tool provides d ## Overview -`osmanage` is a comprehensive management utility for OpenSlides 4.x instances. It combines deployment automation, database operations, and administrative tasks into a single tool. +`osmanage` is a comprehensive management utility for OpenSlides 4.x instances. It combines deployment automation, Kubernetes orchestration, database operations, and administrative tasks into a single tool. **Key Features:** - **Deployment Setup**: Generate Docker Compose and Kubernetes configurations +- **Kubernetes Management**: Deploy, update, and manage OpenSlides in Kubernetes - **Secrets Management**: Automatic generation of secure passwords and certificates - **Datastore Queries**: Direct PostgreSQL access with advanced filtering - **Database Migrations**: Manage OpenSlides schema migrations with progress tracking @@ -54,7 +69,6 @@ A command-line interface for managing OpenSlides instances. This tool provides d ### Binary Release Download the latest binary from the [releases page](https://github.com/OpenSlides/openslides-cli/releases): - ```bash # Linux AMD64 curl -L https://github.com/OpenSlides/openslides-cli/releases/latest/download/osmanage -o osmanage @@ -66,7 +80,6 @@ sudo mv osmanage /usr/local/bin/ **Requirements:** - Go 1.23 or later - ```bash git clone https://github.com/OpenSlides/openslides-cli.git cd openslides-cli @@ -75,21 +88,19 @@ CGO_ENABLED=0 go build -a -ldflags="-s -w" ./cmd/osmanage --- - ## Quick Start -### Standard Setup (Auto-Initialize) - -If your config has `OPENSLIDES_BACKEND_CREATE_INITIAL_DATA: 1` set (default): +### Docker Compose Setup +Standard workflow for Docker Compose deployments: ```bash -# 1. Generate deployment configuration -osmanage setup ./openslides-deployment \ +# 1. Generate deployment configuration with random secrets +osmanage setup ./my.instance.dir.org \ --config config.yml \ - --template templates/docker-compose.yml + --template docker-compose.yml # 2. Start services -cd openslides-deployment +cd my.instance.dir.org docker compose up -d # 3. Wait for services to be ready (~30 seconds) @@ -110,38 +121,479 @@ docker compose down -v ## Commands -### `setup` +### Instance Management + +Commands for creating and managing OpenSlides instance directories. -Creates deployment configuration files, secrets, and SSL certificates. +--- + +#### `setup` + +Creates a new instance directory with deployment configuration, secrets, and SSL certificates. **Usage:** ```bash -osmanage setup [flags] +osmanage setup [flags] ``` **Flags:** -- `-t, --template `: Custom template file or directory -- `-c, --config `: YAML config file(s) (can be used multiple times) +- `-t, --template `: Template file or directory (required) +- `-c, --config `: YAML config file(s) (can be used multiple times, required) - `-f, --force`: Overwrite existing files -**Generated Files:** -- `docker-compose.yml` or Kubernetes manifests -- `secrets/` directory with: - - `auth_token_key` - - `auth_cookie_key` - - `internal_auth_password` - - `postgres_password` - - `superadmin` - - `cert_crt` and `cert_key` (if HTTPS enabled) +**Generated Structure:** +``` +my.instance.dir.org/ +├── docker-compose.yml # (if using Docker Compose template) +├── namespace.yaml # (if using Kubernetes template) +├── stack/ # (if using Kubernetes template) +│ ├── autoupdate-deployment.yaml +│ ├── backend-deployment.yaml +│ ├── postgres-deployment.yaml +│ └── ... +└── secrets/ + ├── auth_token_key + ├── auth_cookie_key + ├── internal_auth_password + ├── postgres_password + ├── superadmin + ├── cert_crt # (if HTTPS enabled) + └── cert_key +``` -**Example:** +**Examples:** +```bash +# Docker Compose deployment +osmanage setup ./my.instance.dir.org \ + --config config.yml \ + --template docker-compose.yml + +# Kubernetes deployment +osmanage setup ./my.instance.dir.org \ + --config k8s-config.yml \ + --template k8s-template-dir + +# Multiple config files (merged, later files override earlier) +osmanage setup ./my.instance.dir.org \ + --config base-config.yml \ + --config prod-overrides.yml \ + --template k8s-template-dir + +# Overwrite existing instance +osmanage setup ./my.instance.dir.org \ + --config config.yml \ + --template k8s-template-dir \ + --force +``` + +--- + +#### `config` + +(Re)creates deployment configuration files from templates and YAML config files. + +**Usage:** ```bash -osmanage setup ./deployment \ +osmanage config [flags] +``` + +**Flags:** +- `-t, --template `: Template file or directory (required) +- `-c, --config `: YAML config file(s) (can be used multiple times, required) +- `-f, --force`: Overwrite existing files + +**Behavior:** +- Merges multiple YAML config files (later file's fields override earlier ones) +- Renders templates with merged configuration +- Creates or overwrites deployment files in the instance directory + +**Use Cases:** +- Regenerate deployment files after config changes +- Update templates without recreating secrets +- Apply new configuration to existing instance +- Fix or modify deployment manifests + +**Examples:** +```bash +# Regenerate deployment files +osmanage config ./my.instance.dir.org \ + --template ./k8s-templates \ + --config ./config.yml + +# Update with multiple configs (merged) +osmanage config ./my.instance.dir.org \ + --template ./k8s-templates \ + --config base-config.yml \ + --config prod-overrides.yml + +# Force overwrite existing files +osmanage config ./my.instance.dir.org \ + --template docker-compose.yml \ --config config.yml \ - --template templates/docker-compose.yml + --force ``` -### `action` +**Note:** This command does NOT regenerate secrets - it only (re)creates deployment files. Use `osmanage setup` for initial instance creation with secrets, or `osmanage create` to update passwords. + +--- + +#### `create` + +Updates an existing instance with new passwords. + +**Usage:** +```bash +osmanage create [flags] +``` + +**Flags:** +- `--db-password `: Set PostgreSQL password (required) +- `--superadmin-password `: Set superadmin password (required) + +**Use Cases:** +- Set specific passwords instead of random ones +- Rotate passwords for security +- Fix incorrect secret file permissions + +**Examples:** +```bash +# Set specific passwords +osmanage create ./my.instance.dir.org \ + --db-password "MySecureDBPassword123" \ + --superadmin-password "AdminPassword456" + +# Password rotation +osmanage create ./my.instance.dir.org \ + --db-password "$NEW_DB_PASS" \ + --superadmin-password "$NEW_ADMIN_PASS" +``` + +--- + +#### `remove` + +Deletes an instance directory and all its contents. + +**Usage:** +```bash +osmanage remove [flags] +``` + +**Flags:** +- `-f, --force`: Skip confirmation prompt + +**Warning:** This permanently deletes all files in the instance directory, including secrets and manifests. + +**Examples:** +```bash +# With confirmation prompt +osmanage remove ./my.instance.dir.org + +# Skip confirmation +osmanage remove ./my.instance.dir.org --force +``` + +--- + +### Kubernetes Operations + +Commands for managing OpenSlides instances in Kubernetes. + +**Requirements:** +- Valid kubeconfig file with cluster access (typically `~/.kube/config`) + - Or running inside a Kubernetes cluster with service account permissions +- Sufficient Kubernetes RBAC permissions to create/manage namespaces and resources + +**Note:** `osmanage` uses the Kubernetes Go client library and does **not** require `kubectl` to be installed. + +--- + +#### `k8s start` + +Deploys an OpenSlides instance to Kubernetes. + +**Usage:** +```bash +osmanage k8s start [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) +- `--skip-ready-check`: Skip waiting for instance to become ready +- `--timeout `: Maximum time to wait for deployment (default: 3m) + +**Features:** +- Creates dedicated namespace from namespace.yaml +- Creates secrets from instance secrets/ directory (base64-encoded) +- Applies all Kubernetes manifests from stack/ directory +- Shows progress bars for deployment readiness +- Waits for all pods to be healthy + +**Examples:** +```bash +# Standard deployment +osmanage k8s start ./my.instance.dir.org + +# Custom timeout +osmanage k8s start ./my.instance.dir.org --timeout 5m + +# Skip health check +osmanage k8s start ./my.instance.dir.org --skip-ready-check +``` + +**Output:** +``` +Applying manifest: my.instance.dir.org/namespace.yaml +Applied namespace: myinstancedirorg +Applying stack manifests from: my.instance.dir.org/stack/ +... + +Waiting for instance to become ready: +[████████████████████████████████████████] Pods ready (13/13) + +Instance is healthy: 13/13 pods ready +Instance started successfully +``` + +--- + +#### `k8s stop` + +Stops and removes an OpenSlides instance from Kubernetes. + +**Usage:** +```bash +osmanage k8s stop [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) +- `--timeout `: Maximum time to wait for deletion (default: 5m) + +**Behavior:** +- Saves TLS certificate secret (if exists) to `secrets/tls-letsencrypt-secret.yaml` +- Deletes the namespace and all resources + +**Warning:** This deletes the namespace and all resources, including persistent volumes. + +**Examples:** +```bash +# Stop instance +osmanage k8s stop ./my.instance.dir.org + +# Custom timeout +osmanage k8s stop ./my.instance.dir.org --timeout 10m +``` + +--- + +#### `k8s update-instance` + +Updates an existing Kubernetes instance with new manifests. + +**Usage:** +```bash +osmanage k8s update-instance [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) +- `--skip-ready-check`: Skip waiting for instance to become ready +- `--timeout `: Maximum time to wait for rollout (default: 3m) + +**Use Cases:** +- Apply configuration changes +- Update resource limits +- Modify service definitions +- Change replica counts + +**Examples:** +```bash +# Update after config changes +osmanage k8s update-instance ./my.instance.dir.org + +# Update with custom timeout +osmanage k8s update-instance ./my.instance.dir.org --timeout 5m + +# Skip health check +osmanage k8s update-instance ./my.instance.dir.org --skip-ready-check +``` + +--- + +#### `k8s update-backendmanage` + +Updates the backendmanage container image. + +**Usage:** +```bash +osmanage k8s update-backendmanage [flags] +``` + +**Flags (Required):** +- `--tag `: OpenSlides version tag (required) +- `--container-registry `: Container registry (required) + +**Flags (Optional):** +- `--kubeconfig `: Path to kubeconfig file +- `--timeout `: Maximum time to wait for rollout (default: 3m) +- `--revert`: Revert to previous image (uses tag and registry as revert target) + +**Examples:** +```bash +# Update to specific version +osmanage k8s update-backendmanage ./my.instance.dir.org \ + --tag 4.2.0 \ + --container-registry myregistry + +# Update to latest +osmanage k8s update-backendmanage ./my.instance.dir.org \ + --tag latest \ + --container-registry myregistry + +# Revert to previous version +osmanage k8s update-backendmanage ./my.instance.dir.org \ + --tag 4.1.9 \ + --container-registry myregistry + --revert + +# Custom timeout +osmanage k8s update-backendmanage ./my.instance.dir.org \ + --tag 4.2.1 \ + --container-registry myregistry + --timeout 5m +``` + +--- + +#### `k8s scale` + +Scales a specific service deployment. + +**Usage:** +```bash +osmanage k8s scale [flags] +``` + +**Flags (Required):** +- `--service `: Service deployment to scale (required) + +**Flags (Optional):** +- `--kubeconfig `: Path to kubeconfig file +- `--skip-ready-check`: Skip waiting for deployment to become ready +- `--timeout `: Maximum time to wait for scaling (default: 3m) + +**Note:** You must edit the deployment manifest file (`stack/-deployment.yaml`) to change replica count before running this command. + +**Examples:** +```bash +# Scale backend deployment (after editing manifest) +osmanage k8s scale ./my.instance.dir.org --service backend + +# Scale autoupdate without health check +osmanage k8s scale ./my.instance.dir.org --service autoupdate --skip-ready-check + +# Scale with custom timeout +osmanage k8s scale ./my.instance.dir.org --service backend --timeout 5m +``` + +--- + +#### `k8s health` + +Checks the health status of an OpenSlides instance. + +**Usage:** +```bash +osmanage k8s health [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) +- `--wait`: Wait for instance to become healthy +- `--timeout `: Timeout for health check (default: 3m, only with --wait) + +**Features:** +- Reports pod status for all deployments +- Shows ready/total pod counts +- Indicates overall instance health + +**Example:** +```bash +# Check current health +osmanage k8s health ./my.instance.dir.org + +# Wait for instance to become healthy +osmanage k8s health ./my.instance.dir.org --wait --timeout 5m +``` + +**Output:** +``` +Namespace: myinstancedirorg +Ready: 13/13 pods + +Pod Status: + ✓ auth-abc123 Running + ✓ autoupdate-def456 Running + ✓ backendaction-ghi789 Running + ✓ backendmanage-jkl012 Running + ✓ backendpresenter-mno345 Running + ✓ client-pqr678 Running + ✓ datastorereader-stu901 Running + ✓ datastorewriter-vwx234 Running + ✓ icc-yza567 Running + ✓ media-bcd890 Running + ✓ redis-efg123 Running + ✓ search-hij456 Running + ✓ vote-klm789 Running +``` + +--- + +#### `k8s cluster-status` + +Displays comprehensive cluster status. + +**Usage:** +```bash +osmanage k8s cluster-status [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) + +**Features:** +- Shows cluster-wide node health +- Reports ready vs total nodes + +**Example:** +```bash +osmanage k8s cluster-status +``` + +**Output:** +``` +cluster_status: 3 3 + +Total nodes: 3 +Ready nodes: 3 +Node node1: Ready +Node node2: Ready +Node node3: Ready +Cluster is healthy +``` + +--- + +### Backend Actions + +Commands for interacting with the OpenSlides backend API. + +**Note:** All backend action commands require `--address` and `--password-file` flags. + +--- + +#### `action` Execute arbitrary OpenSlides backend actions. @@ -150,26 +602,42 @@ Execute arbitrary OpenSlides backend actions. osmanage action [payload] [flags] ``` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `-f, --file`: JSON payload file or `-` for stdin +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) + +**Flags (Optional):** +- `-f, --file `: JSON payload file or `-` for stdin **Examples:** ```bash -# Inline JSON -osmanage action meeting.create '[{"name": "Annual Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' +# Docker Compose (localhost) +osmanage action meeting.create '[{"name": "Annual Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' \ + --address localhost:9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + +# Kubernetes (port-forwarded) +kubectl port-forward -n myinstancedirorg svc/backendmanage 9002:9002 & +osmanage action meeting.create '[{"name": "Board Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' \ + --address localhost:9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password # From file -osmanage action meeting.create --file meeting.json +osmanage action meeting.create \ + --file meeting.json \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password # From stdin -echo '[{"name": "Test Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' | osmanage action meeting.create --file - +echo '[{"name": "Test", "committee_id": 1, "language": "de", "admin_ids": [1]}]' | \ + osmanage action meeting.create --file - \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password ``` --- -### `create-user` +#### `create-user` Create a new OpenSlides user. @@ -178,22 +646,29 @@ Create a new OpenSlides user. osmanage create-user [user-data] [flags] ``` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `-f, --file`: JSON user data file or `-` for stdin +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) + +**Flags (Optional):** +- `-f, --file `: JSON user data file or `-` for stdin -**Required Fields:** +**Required JSON Fields:** - `username`: User login name - `default_password`: Initial password -**Example:** +**Examples:** ```bash -# From file -osmanage create-user --file user.json +# Inline JSON +osmanage create-user '{"username": "admin", "default_password": "secret123"}' \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password -# Inline -osmanage create-user '{"username": "admin", "default_password": "secret123", "first_name": "Admin", "last_name": "User"}' +# From file +osmanage create-user \ + --file user.json \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password ``` **user.json:** @@ -210,7 +685,7 @@ osmanage create-user '{"username": "admin", "default_password": "secret123", "fi --- -### `get` +#### `get` Query the OpenSlides datastore with advanced filtering. @@ -224,16 +699,18 @@ osmanage get [flags] - `meeting` - `organization` -**Flags:** -- `--postgres-host`: PostgreSQL host (default: `localhost`) -- `--postgres-port`: PostgreSQL port (default: `5432`) -- `--postgres-user`: PostgreSQL user (default: `instance_user`) -- `--postgres-database`: PostgreSQL database (default: `instance_db`) -- `--postgres-password-file`: Password file (default: `/secrets/postgres-password`) -- `--fields`: Comma-separated field list -- `--filter`: Simple key=value filters (multiple allowed, AND'ed together) -- `--filter-raw`: Complex JSON filter with operators -- `--exists`: Return boolean (requires filter) +**Flags (Required):** +- `--postgres-host `: PostgreSQL host (required) +- `--postgres-port `: PostgreSQL port (required) +- `--postgres-user `: PostgreSQL user (required) +- `--postgres-database `: PostgreSQL database (required) +- `--postgres-password-file `: PostgreSQL password file (required) + +**Flags (Optional):** +- `--fields `: Comma-separated field list +- `--filter `: Simple equality filters (can be used multiple times, AND'ed together) +- `--filter-raw `: Complex JSON filter with operators +- `--exists`: Return boolean instead of data (requires filter) **Supported Operators (in `--filter-raw`):** - `=`: Equal @@ -246,63 +723,54 @@ osmanage get [flags] **Examples:** -**Simple queries:** +**Docker Compose:** ```bash -# Get all users with specific fields -osmanage get user --fields first_name,last_name,email - -# Filter by equality -osmanage get user --filter is_active=true --filter username=admin - -# Check existence -osmanage get meeting --filter id=1 --exists +# Simple query +osmanage get user --fields first_name,last_name,email \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + +# With filter +osmanage get user --filter is_active=true \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password ``` **Complex filters:** ```bash -# Numeric comparison -osmanage get user --filter-raw '{"field":"id","operator":">","value":10}' - # Regex matching -osmanage get user --filter-raw '{"field":"username","operator":"~=","value":"^admin"}' +osmanage get user \ + --filter-raw '{"field":"username","operator":"~=","value":"^admin"}' \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password # AND filter -osmanage get user --filter-raw '{ - "and_filter": [ - {"field": "is_active", "operator": "=", "value": true}, - {"field": "first_name", "operator": "~=", "value": "^M"} - ] -}' - -# OR filter -osmanage get meeting --filter-raw '{ - "or_filter": [ - {"field": "name", "operator": "~=", "value": "Annual"}, - {"field": "name", "operator": "~=", "value": "Board"} - ] -}' - -# NOT filter -osmanage get user --filter-raw '{ - "not_filter": { - "field": "is_active", - "operator": "=", - "value": false - } -}' - -# Nested filters -osmanage get user --filter-raw '{ - "and_filter": [ - {"field": "is_active", "operator": "=", "value": true}, - { - "or_filter": [ - {"field": "username", "operator": "~=", "value": "^admin"}, - {"field": "username", "operator": "~=", "value": "^super"} - ] - } - ] -}' +osmanage get user \ + --filter-raw '{"and_filter":[{"field":"is_active","operator":"=","value":true},{"field":"first_name","operator":"~=","value":"^M"}]}' \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + +# Check existence +osmanage get meeting \ + --filter id=1 \ + --exists \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password ``` **Output Format:** @@ -327,7 +795,7 @@ osmanage get user --filter-raw '{ --- -### `initial-data` +#### `initial-data` Initialize a new OpenSlides datastore. @@ -336,81 +804,71 @@ Initialize a new OpenSlides datastore. osmanage initial-data [flags] ``` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `--superadmin-password-file`: Superadmin password file (default: `secrets/superadmin`) -- `-f, --file`: JSON initial data file or `-` for stdin +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) +- `--superadmin-password-file `: Superadmin password file (required) + +**Flags (Optional):** +- `-f, --file `: JSON initial data file or `-` for stdin **Behavior:** - Sets up organization and default data - Sets superadmin (user ID 1) password -- **Returns error if datastore is not empty** (exit code 2) +- Returns error if datastore is not empty (exit code 2) -**Example:** +**Examples:** ```bash -# With default data +# Docker Compose osmanage initial-data \ --address localhost:9002 \ - --password-file secrets/internal_auth_password \ - --superadmin-password-file secrets/superadmin - -# With custom initial data -osmanage initial-data \ - --address localhost:9002 \ - --password-file secrets/internal_auth_password \ - --superadmin-password-file secrets/superadmin \ - --file initial-data.json + --password-file ./secrets/internal_auth_password \ + --superadmin-password-file ./secrets/superadmin ``` --- -### `migrations` +#### `migrations` Manage OpenSlides database migrations. **Subcommands:** - `migrate`: Prepare migrations (dry-run) -- `finalize`: Prepare and apply migrations +- `finalize`: Apply migrations to datastore - `reset`: Reset unapplied migrations - `clear-collectionfield-tables`: Clear auxiliary tables (offline only) - `stats`: Show migration statistics -- `progress`: Query progress of running migration +- `progress`: Check running migration progress -**Common Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `--interval`: Progress polling interval (default: `1s`, use `0` to disable) +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) -**Features:** -- Automatic retry with exponential backoff -- Real-time progress tracking -- Context-aware timeouts -- Network error handling +**Flags (Optional):** +- `--interval `: Progress check interval (default: `1s`, use `0` to disable, only for migrate/finalize) **Examples:** ```bash +# Check migration status +osmanage migrations stats \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password + # Prepare migrations (dry-run) -osmanage migrations migrate --address localhost:9002 +osmanage migrations migrate \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password -# Apply migrations with progress +# Apply migrations osmanage migrations finalize \ --address localhost:9002 \ - --password-file secrets/internal_auth_password + --password-file ./secrets/internal_auth_password -# Apply migrations without progress output +# Apply without progress output osmanage migrations finalize \ --address localhost:9002 \ + --password-file ./secrets/internal_auth_password \ --interval 0 - -# Check migration status -osmanage migrations stats --address localhost:9002 - -# Monitor running migration -osmanage migrations progress --address localhost:9002 - -# Reset migrations -osmanage migrations reset --address localhost:9002 ``` **Migration Stats Output:** @@ -426,7 +884,7 @@ status: migration_running --- -### `set` +#### `set` Update OpenSlides objects using backend actions. @@ -436,38 +894,32 @@ osmanage set [payload] [flags] ``` **Supported Actions:** -- `agenda_item` -- `committee` -- `group` -- `meeting` -- `motion` -- `organization` -- `organization_tag` -- `projector` -- `theme` -- `topic` -- `user` +- `agenda_item`, `committee`, `group`, `meeting`, `motion`, `organization`, `organization_tag`, `projector`, `theme`, `topic`, `user` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `-f, --file`: JSON payload file or `-` for stdin +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) + +**Flags (Optional):** +- `-f, --file `: JSON payload file or `-` for stdin **Examples:** ```bash # Update user -osmanage set user '[{"id": 5, "first_name": "Jane", "last_name": "Smith"}]' - -# Update meeting from file -osmanage set meeting --file meeting-update.json +osmanage set user '[{"id": 5, "first_name": "Jane", "last_name": "Smith"}]' \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password -# Update organization -osmanage set organization '[{"id": 1, "name": "Updated Organization Name"}]' +# Update from file +osmanage set meeting \ + --file meeting-update.json \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password ``` --- -### `set-password` +#### `set-password` Change a user's password. @@ -476,16 +928,17 @@ Change a user's password. osmanage set-password [flags] ``` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `-u, --user_id`: User ID (required) -- `-p, --password`: New password (required) +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) +- `-u, --user_id `: User ID (required) +- `-p, --password `: New password (required) **Example:** ```bash osmanage set-password \ --address localhost:9002 \ + --password-file ./secrets/internal_auth_password \ --user_id 5 \ --password "newSecurePassword123" ``` @@ -496,119 +949,191 @@ osmanage set-password \ ### Logging Levels -Set via the `--log-level` flag (applies to all commands): - +Control verbosity with the global `--log-level` flag: ```bash -osmanage --log-level debug get user +osmanage --log-level debug k8s start ./my.instance.dir.org ``` **Available levels:** - `debug`: Detailed diagnostic information -- `info`: General informational messages (default) -- `warn`: Warning messages +- `info`: General informational messages +- `warn`: Warning messages only (default) - `error`: Error messages only **Example output:** ``` -[INFO] === GET COLLECTION === -[DEBUG] Collection: user -[DEBUG] Found 150 total users -[DEBUG] Fields to fetch: [id first_name last_name email is_active] -[INFO] Query completed successfully +[INFO] === K8S START === +[DEBUG] Namespace: myinstancedirorg +[INFO] Applying Kubernetes manifests... +[DEBUG] Applied manifest: namespace.yaml +[INFO] Waiting for instance to become ready... +[INFO] Instance started successfully ``` --- ## Examples -### Complete Setup Workflow - +### Complete Kubernetes Workflow ```bash -# 1. Generate deployment configuration -osmanage setup ./openslides-deployment \ - --config config.yml \ - --template templates/docker-compose.yml +# 1. Generate instance +osmanage setup ./prod.instance.org \ + --config prod-config.yml \ + --template k8s-template-dir -# 2. Start services (Docker Compose example) -cd openslides-deployment -docker-compose up -d +# 2. Customize secrets (optional) +osmanage create ./prod.instance.org \ + --db-password "$SECURE_DB_PASS" \ + --superadmin-password "$SECURE_ADMIN_PASS" -# 3. Access OpenSlides -# Visit http://localhost:8000 -# Login as 'superadmin' with password from: -cat secrets/superadmin +# 3. Deploy to Kubernetes +osmanage k8s start ./prod.instance.org + +# 4. Check health +osmanage k8s health ./prod.instance.org + +# 5. Scale backend deployment (after editing manifest) +osmanage k8s scale ./prod.instance.org --service projector + +# 6. Update backend image +osmanage k8s update-backendmanage ./prod.instance.org \ + --tag 4.2.1 \ + --container-registry myregistry + +# 7. Stop instance +osmanage k8s stop ./prod.instance.org ``` -### Backup User Data +--- +### Backup User Data ```bash -# Export all users to JSON -osmanage get user > backup-users-$(date +%Y%m%d).json - -# Export specific fields only +# Export all users +osmanage get user \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user instance_user \ + --postgres-database instance_db \ + --postgres-password-file ./my.instance.dir.org/secrets/postgres_password \ + > backup-users-$(date +%Y%m%d).json + +# Export specific fields osmanage get user \ --fields username,first_name,last_name,email \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user instance_user \ + --postgres-database instance_db \ + --postgres-password-file ./my.instance.dir.org/secrets/postgres_password \ > backup-users-minimal.json ``` +--- + ### Query Active Meetings +```bash +# Get all active meetings with details +osmanage get meeting \ + --filter is_active_in_organization_id=1 \ + --fields name,start_time,end_time,location \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + +# Count active meetings +osmanage get meeting \ + --filter is_active_in_organization_id=1 \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password \ + | jq 'length' + +# Check if specific meeting exists and is active +osmanage get meeting \ + --filter-raw '{"and_filter":[{"field":"id","operator":"=","value":1},{"field":"is_active_in_organization_id","operator":"=","value":1}]}' \ + --exists \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password +``` - ```bash - # Get all active meetings with details - osmanage get meeting \ - --filter is_active_in_organization_id=1 \ - --fields name,start_time,end_time,location - - # Count active meetings - osmanage get meeting \ - --filter is_active_in_organization_id=1 \ - | jq 'length' - - # See if an active meeting exists by id - osmanage get meeting \ - --filter-raw '{"and_filter": [{"field": "id", "operator": "=", "value": 1}, {"field": "is_active_in_organization_id", "operator": "=", "value": 1}]}' - --exists - # easier (id=5) - osmanage get meeting --filter is_active_in_organization=1 --fields name | jq '. "5"' - ``` +--- ## Development ### Project Structure - ``` openslides-cli/ ├── cmd/ -│ └── osmanage/ # Main entry point -│ └── main.go +│ └── osmanage/ # Main entry point +│ ├── main.go +│ └── main_test.go ├── internal/ -│ ├── actions/ # Action commands -│ │ ├── action/ -│ │ │ └── action.go -│ │ ├── createuser/ -│ │ │ └── createuser.go -│ │ ├── get/ -│ │ │ ├── get.go -│ │ │ └── get_test.go -│ │ ├── initialdata/ -│ │ │ └── initialdata.go -│ │ ├── set/ -│ │ │ └── set.go -│ │ └── setpassword/ -│ │ └── setpassword.go -│ ├── client/ # HTTP client -│ │ ├── client.go -│ │ └── client_test.go -│ ├── logger/ # Logging -│ │ ├── logger.go -│ │ └── logger_test.go -│ ├── migrations/ # Migration commands -│ │ ├── migrations.go -│ │ └── migrations_test.go -│ ├── templating/ # Setup & templating +│ ├── constants/ # Project-wide constants +│ │ └── constants.go +│ ├── instance/ # Instance management │ │ ├── config/ +│ │ │ ├── config.go +│ │ │ └── config_test.go +│ │ ├── create/ +│ │ │ ├── create.go +│ │ │ └── create_test.go +│ │ ├── remove/ +│ │ │ ├── remove.go +│ │ │ └── remove_test.go │ │ └── setup/ -│ └── utils/ # Utilities +│ │ ├── setup.go +│ │ └── setup_test.go +│ ├── k8s/ # Kubernetes operations +│ │ ├── actions/ +│ │ │ ├── apply.go +│ │ │ ├── cluster_status.go +│ │ │ ├── cluster_status_test.go +│ │ │ ├── health.go +│ │ │ ├── health_check.go +│ │ │ ├── health_check_test.go +│ │ │ ├── helpers.go +│ │ │ ├── helpers_test.go +│ │ │ ├── scale.go +│ │ │ ├── start.go +│ │ │ ├── stop.go +│ │ │ ├── update_backendmanage.go +│ │ │ └── update_instance.go +│ │ └── client/ +│ │ └── client.go +│ ├── manage/ # Backend action commands +│ │ ├── actions/ +│ │ │ ├── action/ +│ │ │ │ └── action.go +│ │ │ ├── createuser/ +│ │ │ │ └── createuser.go +│ │ │ ├── get/ +│ │ │ │ ├── get.go +│ │ │ │ └── get_test.go +│ │ │ ├── initialdata/ +│ │ │ │ └── initialdata.go +│ │ │ ├── integration_test.go +│ │ │ ├── migrations/ +│ │ │ │ ├── migrations.go +│ │ │ │ └── migrations_test.go +│ │ │ ├── set/ +│ │ │ │ ├── set.go +│ │ │ │ └── set_test.go +│ │ │ └── setpassword/ +│ │ │ └── setpassword.go +│ │ └── client/ +│ │ ├── client.go +│ │ └── client_test.go +│ ├── logger/ # Logging utilities +│ │ ├── logger.go +│ │ └── logger_test.go +│ └── utils/ # Common utilities │ ├── utils.go │ └── utils_test.go ├── go.mod @@ -617,40 +1142,53 @@ openslides-cli/ └── LICENSE ``` -### Running Tests +--- +### Running Tests ```bash # Run all tests go test ./... -# Run tests with coverage +# Run with coverage go test -cover ./... + +# Run specific package +go test ./internal/k8s/actions + +# Verbose output +go test -v ./... ``` -### Building +--- +### Building ```bash -# Build for testing (bigger binary, debuggable) +# Development build (larger binary, debuggable) go build -o osmanage ./cmd/osmanage -# Build for prod (smaller binary, no C code, no debug) + +# Production build (smaller binary, optimized, no debug symbols) CGO_ENABLED=0 go build -a -ldflags="-s -w" -o osmanage ./cmd/osmanage ``` +--- + ### Contributing 1. Fork the repository 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 3. Write tests for your changes 4. Ensure all tests pass (`go test ./...`) -5. Commit your changes (`git commit -m 'Add amazing feature'`) -6. Push to the branch (`git push origin feature/amazing-feature`) -7. Open a Pull Request +5. Run `go fmt ./...` to format code +6. Commit your changes (`git commit -m 'Add amazing feature'`) +7. Push to the branch (`git push origin feature/amazing-feature`) +8. Open a Pull Request **Code Style:** - Follow standard Go conventions - Run `go fmt` before committing - Add tests for new functionality - Update documentation as needed +- Use project constants from `internal/constants` --- @@ -659,13 +1197,17 @@ CGO_ENABLED=0 go build -a -ldflags="-s -w" -o osmanage ./cmd/osmanage This tool represents a significant refactor and expansion of the original [openslides-manage-service](https://github.com/OpenSlides/openslides-manage-service) created by **Norman Jäckel**. **Major Changes from Original:** +- Complete Kubernetes orchestration system with health checks and progress tracking - Migration from `datastorereader` to `openslides-go/datastore/dsfetch` -- Removed gRPC -- Filtering in-memory (until better solution is found -> TODO) -- retry mechanism for migrations -- Comprehensive test coverage -- Improved deployment configuration system -- Simplified the templating +- Removed gRPC dependencies +- In-memory filtering for datastore queries +- Comprehensive retry mechanisms for migrations +- Extensive test coverage +- Improved deployment configuration and templating +- Centralized constants and project structure +- Instance management commands (setup/config/create/remove) +- Real-time deployment monitoring with progress bars +- Cluster status and health monitoring **Refactored/Developed by:** Alexej Antoni @ Intevation GmbH diff --git a/cmd/osmanage/main.go b/cmd/osmanage/main.go index 61978a7..729eca5 100644 --- a/cmd/osmanage/main.go +++ b/cmd/osmanage/main.go @@ -4,16 +4,19 @@ import ( "fmt" "os" - "github.com/OpenSlides/openslides-cli/internal/actions/action" - "github.com/OpenSlides/openslides-cli/internal/actions/createuser" - "github.com/OpenSlides/openslides-cli/internal/actions/get" - "github.com/OpenSlides/openslides-cli/internal/actions/initialdata" - "github.com/OpenSlides/openslides-cli/internal/actions/migrations" - "github.com/OpenSlides/openslides-cli/internal/actions/set" - "github.com/OpenSlides/openslides-cli/internal/actions/setpassword" + "github.com/OpenSlides/openslides-cli/internal/instance/config" + "github.com/OpenSlides/openslides-cli/internal/instance/create" + "github.com/OpenSlides/openslides-cli/internal/instance/remove" + "github.com/OpenSlides/openslides-cli/internal/instance/setup" + k8sActions "github.com/OpenSlides/openslides-cli/internal/k8s/actions" "github.com/OpenSlides/openslides-cli/internal/logger" - "github.com/OpenSlides/openslides-cli/internal/templating/config" - "github.com/OpenSlides/openslides-cli/internal/templating/setup" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/action" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/createuser" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/get" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/initialdata" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/migrations" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/set" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/setpassword" "github.com/spf13/cobra" ) @@ -62,9 +65,28 @@ func RootCmd() *cobra.Command { return nil } + // K8s command group + k8sCmd := &cobra.Command{ + Use: "k8s", + Short: "Manage Kubernetes deployments", + Long: "Manage OpenSlides instances deployed on Kubernetes", + } + + k8sCmd.AddCommand( + k8sActions.StartCmd(), + k8sActions.StopCmd(), + k8sActions.HealthCmd(), + k8sActions.ClusterStatusCmd(), + k8sActions.UpdateBackendmanageCmd(), + k8sActions.UpdateInstanceCmd(), + k8sActions.ScaleCmd(), + ) + rootCmd.AddCommand( setup.Cmd(), config.Cmd(), + create.Cmd(), + remove.Cmd(), createuser.Cmd(), initialdata.Cmd(), setpassword.Cmd(), @@ -72,6 +94,7 @@ func RootCmd() *cobra.Command { get.Cmd(), action.Cmd(), migrations.Cmd(), + k8sCmd, ) return rootCmd diff --git a/cmd/osmanage/main_test.go b/cmd/osmanage/main_test.go index 56de13b..5e112c1 100644 --- a/cmd/osmanage/main_test.go +++ b/cmd/osmanage/main_test.go @@ -19,6 +19,7 @@ func TestRootCmd(t *testing.T) { logLevelFlag := cmd.PersistentFlags().Lookup("log-level") if logLevelFlag == nil { t.Fatal("Expected log-level flag to exist") + return } if logLevelFlag.DefValue != "warn" { t.Errorf("Expected default log-level 'info', got %s", logLevelFlag.DefValue) diff --git a/go.mod b/go.mod index 49e5960..d2bd319 100644 --- a/go.mod +++ b/go.mod @@ -4,20 +4,59 @@ go 1.25.1 require ( github.com/OpenSlides/openslides-go v0.0.0-20251104124242-d8e4b15bb11e + github.com/schollz/progressbar/v3 v3.19.0 github.com/shopspring/decimal v1.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/text v0.33.0 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 + sigs.k8s.io/yaml v1.6.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.42.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 72a9f9f..41ff7eb 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= @@ -10,9 +12,12 @@ github.com/OpenSlides/openslides-go v0.0.0-20251104124242-d8e4b15bb11e h1:pRKc33 github.com/OpenSlides/openslides-go v0.0.0-20251104124242-d8e4b15bb11e/go.mod h1:Em6jcRrIaNDy6pkWLJx5gLLFO61th+GNQCmD/0AQPtY= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= +github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -24,16 +29,41 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -44,16 +74,41 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -66,9 +121,13 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= +github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -78,30 +137,80 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/actions/migrations/migrations.go b/internal/actions/migrations/migrations.go deleted file mode 100644 index ac4d9bb..0000000 --- a/internal/actions/migrations/migrations.go +++ /dev/null @@ -1,340 +0,0 @@ -package migrations - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/OpenSlides/openslides-cli/internal/client" - "github.com/OpenSlides/openslides-cli/internal/logger" - "github.com/OpenSlides/openslides-cli/internal/utils" - - "github.com/spf13/cobra" -) - -const ( - MigrationsHelp = "Wrapper to the OpenSlides backend migration tool" - MigrationsHelpExtra = `Run database migrations on the OpenSlides datastore. -See help text for the respective commands for more information.` - - defaultInterval = 1 * time.Second - migrationRunning = "migration_running" -) - -func Cmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "migrations", - Short: MigrationsHelp, - Long: MigrationsHelp + "\n\n" + MigrationsHelpExtra, - } - - cmd.AddCommand( - migrateCmd(), - finalizeCmd(), - resetCmd(), - clearCollectionfieldTablesCmd(), - statsCmd(), - progressCmd(), - ) - - return cmd -} - -func migrateCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "migrate", - Short: "Prepare migrations but do not apply them to the datastore", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, true) -} - -func finalizeCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "finalize", - Short: "Prepare migrations and apply them to the datastore", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, true) -} - -func resetCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "reset", - Short: "Reset unapplied migrations", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, false) -} - -func clearCollectionfieldTablesCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "clear-collectionfield-tables", - Short: "Clear all data from auxiliary tables (only when OpenSlides is offline)", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, false) -} - -func statsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "stats", - Short: "Print statistics about the current migration state", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, false) -} - -func progressCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "progress", - Short: "Query the progress of a currently running migration command", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, false) -} - -func setupMigrationCmd(cmd *cobra.Command, withInterval bool) *cobra.Command { - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") - - var interval *time.Duration - if withInterval { - interval = cmd.Flags().Duration("interval", defaultInterval, - "interval of progress calls on running migrations, set 0 to disable progress") - } - - cmd.RunE = func(cmd *cobra.Command, args []string) error { - logger.Info("=== MIGRATIONS: %s ===", strings.ToUpper(cmd.Use)) - - authPassword, err := utils.ReadPassword(*passwordFile) - if err != nil { - return fmt.Errorf("reading password: %w", err) - } - - cl := client.New(*address, authPassword) - - return runMigrations(cl, cmd.Use, interval) - } - - return cmd -} - -func runMigrations(cl *client.Client, command string, intervalFlag *time.Duration) error { - logger.Debug("Running migrations command: %s", command) - - mR, err := executeMigrationsCommand(cl, command) - if err != nil { - return fmt.Errorf("executing migrations command: %w", err) - } - - var interval time.Duration - if intervalFlag != nil { - interval = *intervalFlag - } - - // If no interval or not running, just print and return - if interval == 0 || !mR.Running() { - output, err := mR.GetOutput(command) - if err != nil { - return fmt.Errorf("parsing migrations response: %w", err) - } - fmt.Print(output) - return nil - } - - // Track progress with intervals - fmt.Println("Progress:") - logger.Debug("Starting progress tracking with interval: %v", interval) - - for { - time.Sleep(interval) - - mR, err := executeMigrationsCommand(cl, "progress") - if err != nil { - return fmt.Errorf("checking progress: %w", err) - } - - if mR.Faulty() { - logger.Error("Migration command failed") - out, err := mR.GetOutput("progress") - if err != nil { - return fmt.Errorf("parsing error response: %w", err) - } - fmt.Print(out) - } else { - out, err := mR.GetOutput("progress") - if err != nil { - return fmt.Errorf("error parsing progress output: %w", err) - } - fmt.Print(out) - } - - if !mR.Running() { - logger.Info("Migration completed") - break - } - } - - return nil -} - -func executeMigrationsCommand(cl *client.Client, command string) (MigrationResponse, error) { - logger.Debug("Executing migrations command: %s", command) - - const maxRetries = 5 - const retryDelay = 5 * time.Second - const totalTimeout = 3 * time.Minute // Max time for all retries combined - - ctx, cancel := context.WithTimeout(context.Background(), totalTimeout) - defer cancel() - - var lastErr error - for attempt := 0; attempt < maxRetries; attempt++ { - // Check if context expired - if ctx.Err() != nil { - return MigrationResponse{}, fmt.Errorf("migrations command timed out after %v: %w", totalTimeout, ctx.Err()) - } - - if attempt > 0 { - logger.Warn("Retry attempt %d/%d after %v (previous error: %v)", - attempt, maxRetries, retryDelay, lastErr) - - // Sleep with context awareness - select { - case <-time.After(retryDelay): - // Continue to next attempt - case <-ctx.Done(): - return MigrationResponse{}, fmt.Errorf("migrations command cancelled during retry: %w", ctx.Err()) - } - } - - resp, err := cl.SendMigrations(command) - if err != nil { - lastErr = fmt.Errorf("sending migrations request: %w", err) - if isRetryableError(err) && attempt < maxRetries-1 { - logger.Debug("Retryable error: %v", err) - continue - } - return MigrationResponse{}, lastErr - } - - body, err := client.CheckResponse(resp) - if err != nil { - lastErr = err - if isRetryableError(err) && attempt < maxRetries-1 { - logger.Debug("Retryable error: %v", err) - continue - } - return MigrationResponse{}, lastErr - } - - var mR MigrationResponse - if err := json.Unmarshal(body, &mR); err != nil { - logger.Error("Failed to unmarshal migrations response: %v", err) - return MigrationResponse{}, fmt.Errorf("unmarshalling migration response: %w", err) - } - - logger.Debug("Migration response - Success: %v, Status: %s, Running: %v", - mR.Success, mR.Status, mR.Running()) - - return mR, nil - } - - return MigrationResponse{}, fmt.Errorf("migrations command failed after %d retries: %w", maxRetries, lastErr) -} - -func isRetryableError(err error) bool { - if err == nil { - return false - } - - errStr := strings.ToLower(err.Error()) - - retryableErrors := []string{ - "connection refused", - "connection reset", - "timeout", - "temporary failure", - "no such host", - "network is unreachable", - "eof", - "broken pipe", - "i/o timeout", - } - - for _, retryable := range retryableErrors { - if strings.Contains(errStr, retryable) { - return true - } - } - - if strings.Contains(errStr, "server error") || - strings.Contains(errStr, "503") || - strings.Contains(errStr, "502") || - strings.Contains(errStr, "504") { - return true - } - - return false -} - -type MigrationResponse struct { - Success bool `json:"success"` - Status string `json:"status"` - Output string `json:"output"` - Exception string `json:"exception"` - Stats json.RawMessage `json:"stats"` -} - -func (mR MigrationResponse) GetOutput(command string) (string, error) { - if mR.Faulty() { - return mR.formatAll() - } - if command == "stats" { - return mR.formatStats() - } - return mR.Output, nil -} - -func (mR MigrationResponse) formatStats() (string, error) { - var stats map[string]any - if err := json.Unmarshal(mR.Stats, &stats); err != nil { - return "", fmt.Errorf("unmarshalling stats: %w", err) - } - - // Define the order we want fields printed - orderedFields := []string{ - "current_migration_index", - "target_migration_index", - "positions", - "events", - "partially_migrated_positions", - "fully_migrated_positions", - "status", - } - - var sb strings.Builder - for _, field := range orderedFields { - if value, ok := stats[field]; ok { - sb.WriteString(fmt.Sprintf("%s: %v\n", field, value)) - } - } - - return sb.String(), nil -} - -func (mR MigrationResponse) formatAll() (string, error) { - return fmt.Sprintf("Success: %v\nStatus: %s\nOutput: %s\nException: %s\n", - mR.Success, mR.Status, mR.Output, mR.Exception), nil -} - -func (mR MigrationResponse) Faulty() bool { - return !mR.Success || mR.Exception != "" -} - -func (mR MigrationResponse) Running() bool { - return mR.Status == migrationRunning -} diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..d06807b --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,214 @@ +// Package constants defines project-wide constants used across osmanage. +// These represent the standard OpenSlides instance directory structure +// and file permissions. +package constants + +import ( + "io/fs" + "time" +) + +// Instance directory structure +const ( + // NamespaceYAML inside the instance root directory is applied to create instance namespace + NamespaceYAML string = "namespace.yaml" + + // StackDirName is the directory containing Kubernetes manifests + StackDirName string = "stack" + + // DeploymentFileTemplate for OpenSlides deployment filenames inside the stack dir, i. e. autoupdate-deployment.yaml + DeploymentFileTemplate string = "%s-deployment.yaml" + + // SecretsDirName is the directory containing sensitive files + SecretsDirName string = "secrets" + + // AdminSecretsFile contains the superadmin password + AdminSecretsFile string = "superadmin" + + // PgPasswordFile contains the PostgreSQL database password + PgPasswordFile string = "postgres_password" + + // AuthTokenKey contains the authentication token secret + AuthTokenKey string = "auth_token_key" + + // AuthCookieKey contains the cookie signing secret + AuthCookieKey string = "auth_cookie_key" + + // InternalAuthPassword contains the internal service authentication password + InternalAuthPassword string = "internal_auth_password" + + // TlsCertSecret is kubernetes secret name for HTTPS + TlsCertSecret string = "tls-letsencrypt" + + // TlsCertSecretYAML is the manifest file for the kubernetes secret enabling HTTPS + TlsCertSecretYAML string = "tls-letsencrypt-secret.yaml" + + // DefaultConfigFile is the filename used, if none is set in config file(s) + DefaultConfigFile string = "os-config.yaml" + + // CertCertName is filename for the HTTPS certificate file + CertCertName string = "cert_crt" + + // CertKeyName is filename for the HTTPS key file + CertKeyName string = "cert_key" +) + +// File permissions +const ( + // SecretsDirPerm is the permission for the secrets directory (owner only) + SecretsDirPerm fs.FileMode = 0700 + + // SecretFilePerm is the permission for secret files (owner read/write only) + SecretFilePerm fs.FileMode = 0600 + + // InstanceDirPerm is the permission for project root directory (owner + others read) + InstanceDirPerm fs.FileMode = 0755 + + // StackDirPerm is the permission for the stack directory (owner + others read) + StackDirPerm fs.FileMode = 0755 + + // StackFilePerm is the permission for manifest files (owner write, others read) + StackFilePerm fs.FileMode = 0644 +) + +// Secret generation defaults +const ( + // DefaultSuperadminPasswordLength is the default length for superadmin passwords + DefaultSuperadminPasswordLength int = 20 + + // DefaultPostgresPasswordLength is the default length for database passwords + DefaultPostgresPasswordLength int = 40 + + // DefaultSecretBytesLength is the number of random bytes used for base64-encoded secrets. + // These 32 bytes produce a 44-character base64 string used for: + // - auth_token_key + // - auth_cookie_key + // - internal_auth_password + DefaultSecretBytesLength int64 = 32 + + // PasswordCharset defines allowed characters for randomly generated passwords. + // Includes lowercase, uppercase, digits, and safe special characters. + // Used for generating postgres_password and superadmin passwords. + PasswordCharset string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]" +) + +// Default timeouts for Kubernetes operations +const ( + DefaultInstanceTimeout time.Duration = 3 * time.Minute // Wait for all instance pods to become ready + DefaultDeploymentTimeout time.Duration = 3 * time.Minute // Wait for deployment rollout to complete + DefaultNamespaceTimeout time.Duration = 5 * time.Minute // Wait for namespace deletion (includes finalizers) +) + +// constants for wait functions in health_check.go +const ( + // progress bar settings + ProgressBarWidth int = 40 + Saucer string = "█" + SaucerPadding string = "░" + BarStart string = "[" + BarEnd string = "]" + ThrottleDuration time.Duration = 100 * time.Millisecond + + // wait function settings + TickerDuration time.Duration = 2 * time.Second // checks health conditions every tick + IconReady string = "✓" // for pod/deployment status printouts + IconNotReady string = "✗" +) + +// OpenSlides K8s resource names and templates +const ( + // BackendmanageDeploymentName is the Kubernetes Deployment name for backendmanage + BackendmanageDeploymentName string = "backendmanage" + + // BackendmanageContainerName is the container name within the backendmanage deployment + BackendmanageContainerName string = "backendmanage" + + // BackendmanageImageTemplate is the format string for backendmanage container images. + BackendmanageImageTemplate string = "%s/openslides-backend:%s" + + // BackendmanagePatchTemplate is the JSON patch template for updating the backendmanage image. + BackendmanagePatchTemplate string = `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}` +) + +// OpenSlides backend API endpoints and defaults +const ( + // BackendHTTPScheme is the HTTP scheme used for backend connections + BackendHTTPScheme string = "http://" + + // BackendHandleRequestPath is the API endpoint for sending actions + BackendHandleRequestPath string = "/internal/handle_request" + + // BackendMigrationsPath is the API endpoint for migrations commands + BackendMigrationsPath string = "/internal/migrations" + + // BackendContentType is the Content-Type header for backend requests + BackendContentType string = "application/json" +) + +// PostgreSQL datastore environment variable keys (used by get command) +const ( + // EnvDatabaseHost is the environment variable for PostgreSQL host + EnvDatabaseHost string = "DATABASE_HOST" + + // EnvDatabasePort is the environment variable for PostgreSQL port + EnvDatabasePort string = "DATABASE_PORT" + + // EnvDatabaseUser is the environment variable for PostgreSQL user + EnvDatabaseUser string = "DATABASE_USER" + + // EnvDatabaseName is the environment variable for PostgreSQL database name + EnvDatabaseName string = "DATABASE_NAME" + + // EnvDatabasePasswordFile is the environment variable for PostgreSQL password file path + EnvDatabasePasswordFile string = "DATABASE_PASSWORD_FILE" + + // EnvOpenSlidesDevelopment is the environment variable for development mode + EnvOpenSlidesDevelopment string = "OPENSLIDES_DEVELOPMENT" +) + +// PostgreSQL datastore environment variable values +const ( + // DevelopmentModeDisabled is the value to disable OpenSlides development mode + DevelopmentModeDisabled string = "false" + + // DevelopmentModeEnabled is the value to enable OpenSlides development mode + DevelopmentModeEnabled string = "true" +) + +// OpenSlides datastore defaults +const ( + // DefaultOrganizationID is the organization ID in OpenSlides (always 1) + DefaultOrganizationID int = 1 + + // DefaultOrganizationFields are the default fields fetched for organization queries + DefaultOrganizationFields string = "id,name" +) + +// Migration command defaults and configuration +const ( + // DefaultMigrationProgressInterval is the default interval for checking migration progress + DefaultMigrationProgressInterval time.Duration = 1 * time.Second + + // MigrationStatusRunning indicates a migration is currently in progress + MigrationStatusRunning string = "migration_running" + + // MigrationMaxRetries is the maximum number of retry attempts for failed migration requests + MigrationMaxRetries int = 5 + + // MigrationRetryDelay is the delay between retry attempts + MigrationRetryDelay time.Duration = 5 * time.Second + + // MigrationTotalTimeout is the maximum time allowed for all retry attempts + MigrationTotalTimeout time.Duration = 3 * time.Minute +) + +// Migration stats field names (for ordered output) +var MigrationStatsFields = []string{ + "current_migration_index", + "target_migration_index", + "positions", + "events", + "partially_migrated_positions", + "fully_migrated_positions", + "status", +} diff --git a/internal/templating/config/config.go b/internal/instance/config/config.go similarity index 74% rename from internal/templating/config/config.go rename to internal/instance/config/config.go index 97b1ceb..688ec53 100644 --- a/internal/templating/config/config.go +++ b/internal/instance/config/config.go @@ -12,6 +12,7 @@ import ( "strings" "text/template" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/utils" @@ -22,21 +23,34 @@ import ( const ( ConfigHelp = "(Re)creates deployment configuration files" - ConfigHelpExtra = `This command (re)creates the deployment file(s) in the given directory. -For Docker Compose, this creates a docker-compose.yml file. -For Kubernetes, this creates a directory with multiple YAML manifests.` + ConfigHelpExtra = `(Re)creates deployment configuration files from templates. + +Generates deployment files (Docker Compose or Kubernetes manifests) using +templates and YAML configuration files. Multiple config files are deep-merged +in order, with later file's fields overriding earlier ones. + +Template functions available: + • marshalContent - Marshal YAML content with indentation + • envMapToK8S - Convert environment map to Kubernetes format + • readSecret - Read and base64-encode secrets from secrets/ directory + +Examples: + osmanage config ./my.instance.dir.org + osmanage config ./my.instance.dir.org --template ./custom.tmpl --config ./config.yaml + osmanage config ./my.instance.dir.org -t ./k8s-templates -c base.yaml -c overrides.yaml + osmanage config ./my.instance.dir.org --force` ) // Cmd returns the subcommand. func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "config directory", + Use: "config ", Short: ConfigHelp, Long: ConfigHelp + "\n\n" + ConfigHelpExtra, Args: cobra.ExactArgs(1), } - force := cmd.Flags().BoolP("force", "f", true, "overwrite existing files") + force := cmd.Flags().BoolP("force", "f", false, "overwrite existing files") customTemplate := cmd.Flags().StringP("template", "t", "", "custom template file or directory") configFiles := cmd.Flags().StringArrayP("config", "c", nil, "custom YAML config file (can be used multiple times)") cmd.MarkFlagsRequiredTogether("template", "config") @@ -121,8 +135,8 @@ func createFromTemplateFile(baseDir string, force bool, tplFile string, cfg map[ return fmt.Errorf("reading template file: %w", err) } - if err := os.MkdirAll(baseDir, os.ModePerm); err != nil { - return fmt.Errorf("creating directory: %w", err) + if err := os.MkdirAll(baseDir, constants.InstanceDirPerm); err != nil { + return fmt.Errorf("creating instance directory: %w", err) } // Extract filename from config if present, otherwise use a default @@ -135,8 +149,8 @@ func createFromTemplateDir(baseDir string, force bool, tplDir string, cfg map[st tplFS := os.DirFS(tplDir) - if err := os.MkdirAll(baseDir, os.ModePerm); err != nil { - return fmt.Errorf("creating directory: %w", err) + if err := os.MkdirAll(baseDir, constants.InstanceDirPerm); err != nil { + return fmt.Errorf("creating instance directory: %w", err) } return createFromFS(baseDir, force, tplFS, cfg) @@ -151,8 +165,10 @@ func createFromFS(baseDir string, force bool, tplFS fs.FS, cfg map[string]any) e targetPath := filepath.Join(baseDir, path) if d.IsDir() { - logger.Debug("Creating directory: %s", targetPath) - return os.MkdirAll(targetPath, os.ModePerm) + // Use appropriate permissions based on directory name + perm := getDirPermissions(filepath.Base(targetPath)) + logger.Debug("Creating directory: %s (perms: %04o)", targetPath, perm) + return os.MkdirAll(targetPath, perm) } logger.Debug("Processing template: %s", path) @@ -165,6 +181,18 @@ func createFromFS(baseDir string, force bool, tplFS fs.FS, cfg map[string]any) e }) } +// getDirPermissions returns appropriate permissions based on directory name +func getDirPermissions(dirName string) fs.FileMode { + switch dirName { + case constants.SecretsDirName: + return constants.SecretsDirPerm + case constants.StackDirName: + return constants.StackDirPerm + default: + return constants.InstanceDirPerm + } +} + func createDeploymentFile(filename string, force bool, tplData []byte, cfg map[string]any, baseDir string) error { tf := &TemplateFunctions{baseDir: baseDir} tmpl, err := template.New("deployment").Funcs(tf.GetFuncMap()).Parse(string(tplData)) @@ -179,7 +207,7 @@ func createDeploymentFile(filename string, force bool, tplData []byte, cfg map[s dir := filepath.Dir(filename) name := filepath.Base(filename) - return utils.CreateFile(dir, force, name, buf.Bytes()) + return utils.CreateFile(dir, force, name, buf.Bytes(), constants.StackFilePerm) } // getFilename extracts the filename from config, or returns a default @@ -187,7 +215,7 @@ func getFilename(cfg map[string]any) string { if fn, ok := cfg["filename"].(string); ok && fn != "" { return fn } - return "output.yml" + return constants.DefaultConfigFile } type TemplateFunctions struct { @@ -205,7 +233,7 @@ func (tf *TemplateFunctions) GetFuncMap() template.FuncMap { // ReadSecret reads a secret file from the secrets directory and returns it base64 encoded func (tf *TemplateFunctions) ReadSecret(name string) (string, error) { - secretPath := filepath.Join(tf.baseDir, "secrets", name) + secretPath := filepath.Join(tf.baseDir, constants.SecretsDirName, name) data, err := os.ReadFile(secretPath) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -221,13 +249,20 @@ func marshalContent(ws int, v any) (string, error) { if err != nil { return "", fmt.Errorf("marshalling content: %w", err) } - result := "\n" + + indent := strings.Repeat(" ", ws) + var result strings.Builder + result.WriteString("\n") + for line := range strings.SplitSeq(string(data), "\n") { if len(line) != 0 { - result += fmt.Sprintf("%s%s\n", strings.Repeat(" ", ws), line) + result.WriteString(indent) + result.WriteString(line) + result.WriteByte('\n') } } - return strings.TrimRight(result, "\n"), nil + + return strings.TrimRight(result.String(), "\n"), nil } func envMapToK8S(v map[string]any) []map[string]string { diff --git a/internal/templating/config/config_test.go b/internal/instance/config/config_test.go similarity index 91% rename from internal/templating/config/config_test.go rename to internal/instance/config/config_test.go index e913c5e..12f63b0 100644 --- a/internal/templating/config/config_test.go +++ b/internal/instance/config/config_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestNewConfig(t *testing.T) { @@ -81,12 +83,12 @@ defaults: tmpdir := t.TempDir() config1 := filepath.Join(tmpdir, "config1.yml") - if err := os.WriteFile(config1, []byte("host: 127.0.0.1\nport: 8000\n"), 0644); err != nil { + if err := os.WriteFile(config1, []byte("host: 127.0.0.1\nport: 8000\n"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config1: %v", err) } config2 := filepath.Join(tmpdir, "config2.yml") - if err := os.WriteFile(config2, []byte("port: 9000\nstackName: test-stack\n"), 0644); err != nil { + if err := os.WriteFile(config2, []byte("port: 9000\nstackName: test-stack\n"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config2: %v", err) } @@ -117,7 +119,7 @@ services: client: tag: latest replicas: 3 -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config1: %v", err) } @@ -127,7 +129,7 @@ services: client: foo: bar replicas: 5 -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config2: %v", err) } @@ -168,7 +170,7 @@ services: services: client: tag: latest -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config1: %v", err) } @@ -180,7 +182,7 @@ services: deeply: nested: value: 42 -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config2: %v", err) } @@ -215,7 +217,7 @@ services: defaults: containerRegistry: registry.example.com tag: latest -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config1: %v", err) } @@ -229,7 +231,7 @@ services: password: super-secret projector: replicas: 3 -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config2: %v", err) } @@ -316,8 +318,8 @@ func TestGetFilename(t *testing.T) { "other": "value", } result := getFilename(cfg) - if result != "output.yml" { - t.Errorf("Expected output.yml, got %s", result) + if result != constants.DefaultConfigFile { + t.Errorf("Expected %s, got %s", constants.DefaultConfigFile, result) } }) @@ -326,8 +328,8 @@ func TestGetFilename(t *testing.T) { "filename": "", } result := getFilename(cfg) - if result != "output.yml" { - t.Errorf("Expected output.yml for empty filename, got %s", result) + if result != constants.DefaultConfigFile { + t.Errorf("Expected %s for empty filename, got %s", constants.DefaultConfigFile, result) } }) @@ -336,19 +338,19 @@ func TestGetFilename(t *testing.T) { "filename": 123, } result := getFilename(cfg) - if result != "output.yml" { - t.Errorf("Expected output.yml for non-string filename, got %s", result) + if result != constants.DefaultConfigFile { + t.Errorf("Expected %s for non-string filename, got %s", constants.DefaultConfigFile, result) } }) } func TestTemplateFunctions(t *testing.T) { tmpdir := t.TempDir() - secretsDir := filepath.Join(tmpdir, "secrets") - if err := os.MkdirAll(secretsDir, 0755); err != nil { + secretsDir := filepath.Join(tmpdir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { t.Fatalf("failed to create secrets dir: %v", err) } - if err := os.WriteFile(filepath.Join(secretsDir, "test_secret"), []byte("secret123"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(secretsDir, "test_secret"), []byte("secret123"), constants.SecretFilePerm); err != nil { t.Fatalf("failed to write test secret: %v", err) } @@ -467,7 +469,7 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("invalid template path", func(t *testing.T) { cfg := map[string]any{ - "filename": "output.yml", + "filename": constants.DefaultConfigFile, } err := CreateDirAndFiles(tmpdir, false, "nonexistent-template", cfg) if err == nil { @@ -477,7 +479,7 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("template file", func(t *testing.T) { tplFile := filepath.Join(tmpdir, "template.yml") - if err := os.WriteFile(tplFile, []byte("test: {{ .url }}"), 0644); err != nil { + if err := os.WriteFile(tplFile, []byte("test: {{ .url }}"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } @@ -503,19 +505,19 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("template directory", func(t *testing.T) { tplDir := filepath.Join(tmpdir, "templates") - if err := os.MkdirAll(tplDir, 0755); err != nil { + if err := os.MkdirAll(tplDir, constants.StackDirPerm); err != nil { t.Fatalf("failed to create template dir: %v", err) } - if err := os.WriteFile(filepath.Join(tplDir, "file1.yml"), []byte("content1"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(tplDir, "file1.yml"), []byte("content1"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write file1: %v", err) } - if err := os.WriteFile(filepath.Join(tplDir, "file2.yml"), []byte("content2"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(tplDir, "file2.yml"), []byte("content2"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write file2: %v", err) } outDir := filepath.Join(tmpdir, "output2") cfg := map[string]any{ - "filename": "output.yml", + "filename": constants.DefaultConfigFile, } err := CreateDirAndFiles(outDir, true, tplDir, cfg) @@ -534,7 +536,7 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("template with nested config access", func(t *testing.T) { tplFile := filepath.Join(tmpdir, "nested-template.yml") - if err := os.WriteFile(tplFile, []byte("foo: {{ .services.client.foo }}\ntag: {{ .services.client.tag }}"), 0644); err != nil { + if err := os.WriteFile(tplFile, []byte("foo: {{ .services.client.foo }}\ntag: {{ .services.client.tag }}"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } @@ -570,7 +572,7 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("template with or operator fallback pattern", func(t *testing.T) { tplFile := filepath.Join(tmpdir, "or-template.yml") if err := os.WriteFile(tplFile, []byte(`tag: {{ or .services.auth.tag .defaults.tag }} -registry: {{ or .services.auth.containerRegistry .defaults.containerRegistry }}`), 0644); err != nil { +registry: {{ or .services.auth.containerRegistry .defaults.containerRegistry }}`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } @@ -616,7 +618,7 @@ registry: {{ or .services.auth.containerRegistry .defaults.containerRegistry }}` projector: image: {{ or .services.projector.containerRegistry .defaults.containerRegistry }}/openslides-projector:{{ or .services.projector.tag .defaults.tag }} replicas: {{ or .services.projector.replicas 1 }}` - if err := os.WriteFile(tplFile, []byte(templateContent), 0644); err != nil { + if err := os.WriteFile(tplFile, []byte(templateContent), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } diff --git a/internal/instance/create/create.go b/internal/instance/create/create.go new file mode 100644 index 0000000..f1ea84f --- /dev/null +++ b/internal/instance/create/create.go @@ -0,0 +1,123 @@ +package create + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + CreateHelp = "Create an OpenSlides instance with custom passwords" + CreateHelpExtra = `Creates an OpenSlides instance by setting up the secrets directory +with the provided database and superadmin passwords. + +This command: +1. Creates/secures the secrets directory (700 permissions) +2. Sets all secret files to 600 permissions +3. Writes the database password to postgres_password +4. Writes the superadmin password to superadmin + +The secrets directory must already exist (created by 'setup' command). + +Examples: + osmanage create ./my.instance.dir.org --db-password "mydbpass" --superadmin-password "myadminpass" + osmanage create ./my.instance.dir.org --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create ", + Short: CreateHelp, + Long: CreateHelp + "\n\n" + CreateHelpExtra, + Args: cobra.ExactArgs(1), + } + + dbPassword := cmd.Flags().String("db-password", "", "PostgreSQL database password (required)") + superadminPassword := cmd.Flags().String("superadmin-password", "", "Superadmin password (required)") + + _ = cmd.MarkFlagRequired("db-password") + _ = cmd.MarkFlagRequired("superadmin-password") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(*dbPassword) == "" { + return fmt.Errorf("--db-password cannot be empty") + } + if strings.TrimSpace(*superadminPassword) == "" { + return fmt.Errorf("--superadmin-password cannot be empty") + } + + logger.Info("=== K8S CREATE INSTANCE ===") + instanceDir := args[0] + logger.Debug("Instance directory: %s", instanceDir) + + if err := createInstance(instanceDir, *dbPassword, *superadminPassword); err != nil { + return fmt.Errorf("creating instance: %w", err) + } + + logger.Info("Instance created successfully") + return nil + } + + return cmd +} + +// createInstance sets up the secrets directory with the provided passwords +func createInstance(instanceDir, dbPassword, superadminPassword string) error { + secretsDir := filepath.Join(instanceDir, constants.SecretsDirName) + + if _, err := os.Stat(secretsDir); os.IsNotExist(err) { + return fmt.Errorf("secrets directory does not exist: %s (run 'setup' first)", secretsDir) + } + + logger.Info("Creating instance: %s", filepath.Base(instanceDir)) + + logger.Debug("Securing secrets directory: %s", secretsDir) + if err := secureSecretsDirectory(secretsDir); err != nil { + return fmt.Errorf("securing secrets directory: %w", err) + } + + pgPasswordPath := filepath.Join(secretsDir, constants.PgPasswordFile) + logger.Debug("Writing PostgreSQL password to: %s", pgPasswordPath) + if err := os.WriteFile(pgPasswordPath, []byte(dbPassword), constants.SecretFilePerm); err != nil { + return fmt.Errorf("writing postgres password: %w", err) + } + + superadminPath := filepath.Join(secretsDir, constants.AdminSecretsFile) + logger.Debug("Writing superadmin password to: %s", superadminPath) + if err := os.WriteFile(superadminPath, []byte(superadminPassword), constants.SecretFilePerm); err != nil { + return fmt.Errorf("writing superadmin password: %w", err) + } + + logger.Info("Passwords configured successfully") + return nil +} + +// secureSecretsDirectory sets restrictive permissions on the secrets directory and all files within +func secureSecretsDirectory(secretsDir string) error { + if err := os.Chmod(secretsDir, constants.SecretsDirPerm); err != nil { + return fmt.Errorf("setting directory permissions: %w", err) + } + + entries, err := os.ReadDir(secretsDir) + if err != nil { + return fmt.Errorf("reading secrets directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filePath := filepath.Join(secretsDir, entry.Name()) + if err := os.Chmod(filePath, constants.SecretFilePerm); err != nil { + return fmt.Errorf("setting permissions for %s: %w", entry.Name(), err) + } + } + + return nil +} diff --git a/internal/instance/create/create_test.go b/internal/instance/create/create_test.go new file mode 100644 index 0000000..9b4c171 --- /dev/null +++ b/internal/instance/create/create_test.go @@ -0,0 +1,247 @@ +package create + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" +) + +func TestSecureSecretsDirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + // Create a secrets directory with some files (wrong perm) + secretsDir := filepath.Join(tmpDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create secrets dir: %v", err) + } + + // Create test secret files with open permissions (to test they get secured) + testFiles := []string{"secret1", "secret2", "secret3"} + for _, filename := range testFiles { + path := filepath.Join(secretsDir, filename) + if err := os.WriteFile(path, []byte("test"), constants.StackFilePerm); err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + } + + // Secure the directory + err = secureSecretsDirectory(secretsDir) + if err != nil { + t.Fatalf("secureSecretsDirectory failed: %v", err) + } + + // Verify directory permissions (0700) + dirInfo, err := os.Stat(secretsDir) + if err != nil { + t.Fatalf("Failed to stat secrets directory: %v", err) + } + + if dirInfo.Mode().Perm() != constants.SecretsDirPerm { + t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), constants.SecretsDirPerm) + } + + // Verify all file permissions (0600) + for _, filename := range testFiles { + path := filepath.Join(secretsDir, filename) + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("Failed to stat file %s: %v", filename, err) + } + + if fileInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("File %s permissions = %v, want %v", filename, fileInfo.Mode().Perm(), constants.SecretFilePerm) + } + } +} + +func TestSecureSecretsDirectory_SkipsSubdirectories(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + // Create secrets directory + secretsDir := filepath.Join(tmpDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create secrets dir: %v", err) + } + + // Create a subdirectory within secrets + subDir := filepath.Join(secretsDir, "subdir") + if err := os.MkdirAll(subDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + // Create a file + testFile := filepath.Join(secretsDir, "secret1") + if err := os.WriteFile(testFile, []byte("test"), constants.StackFilePerm); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Secure the directory (should skip subdirectory) + err = secureSecretsDirectory(secretsDir) + if err != nil { + t.Fatalf("secureSecretsDirectory failed: %v", err) + } + + // Verify subdirectory permissions were NOT changed + subDirInfo, err := os.Stat(subDir) + if err != nil { + t.Fatalf("Failed to stat subdirectory: %v", err) + } + + // Should still have original permissions (not changed to secret file permissions) + if subDirInfo.Mode().Perm() == constants.SecretFilePerm { + t.Error("Subdirectory permissions should not be changed to SecretFilePerm") + } + + // Verify file permissions WERE changed + fileInfo, err := os.Stat(testFile) + if err != nil { + t.Fatalf("Failed to stat file: %v", err) + } + + if fileInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("File permissions = %v, want %v", fileInfo.Mode().Perm(), constants.SecretFilePerm) + } +} + +func TestCreateInstance(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + // Create secrets directory + secretsDir := filepath.Join(tmpDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { + t.Fatalf("Failed to create secrets dir: %v", err) + } + + // Create some existing secret files (simulating 'setup' output) + existingSecrets := map[string]string{ + constants.PgPasswordFile: "old-db-password", + constants.AdminSecretsFile: "old-admin-password", + constants.InternalAuthPassword: "some-auth-key", + } + + for filename, content := range existingSecrets { + path := filepath.Join(secretsDir, filename) + if err := os.WriteFile(path, []byte(content), constants.SecretFilePerm); err != nil { + t.Fatalf("Failed to create existing secret %s: %v", filename, err) + } + } + + // Run createInstance + dbPassword := "new-database-password" + superadminPassword := "new-superadmin-password" + + err = createInstance(tmpDir, dbPassword, superadminPassword) + if err != nil { + t.Fatalf("createInstance failed: %v", err) + } + + // Verify postgres_password was overwritten + pgContent, err := os.ReadFile(filepath.Join(secretsDir, constants.PgPasswordFile)) + if err != nil { + t.Fatalf("Failed to read postgres_password: %v", err) + } + if string(pgContent) != dbPassword { + t.Errorf("postgres_password = %q, want %q", string(pgContent), dbPassword) + } + + // Verify superadmin was overwritten + adminContent, err := os.ReadFile(filepath.Join(secretsDir, constants.AdminSecretsFile)) + if err != nil { + t.Fatalf("Failed to read superadmin: %v", err) + } + if string(adminContent) != superadminPassword { + t.Errorf("superadmin = %q, want %q", string(adminContent), superadminPassword) + } + + // Verify other secrets were not touched + authContent, err := os.ReadFile(filepath.Join(secretsDir, constants.InternalAuthPassword)) + if err != nil { + t.Fatalf("Failed to read internal_auth_password: %v", err) + } + if string(authContent) != existingSecrets[constants.InternalAuthPassword] { + t.Errorf("internal_auth_password was unexpectedly changed") + } + + // Verify all files have SecretFilePerm permissions + entries, err := os.ReadDir(secretsDir) + if err != nil { + t.Fatalf("Failed to read secrets directory: %v", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + path := filepath.Join(secretsDir, entry.Name()) + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("Failed to stat %s: %v", entry.Name(), err) + } + + if fileInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("File %s permissions = %v, want %v", entry.Name(), fileInfo.Mode().Perm(), constants.SecretFilePerm) + } + } + + // Verify directory has SecretsDirPerm permissions + dirInfo, err := os.Stat(secretsDir) + if err != nil { + t.Fatalf("Failed to stat secrets directory: %v", err) + } + + if dirInfo.Mode().Perm() != constants.SecretsDirPerm { + t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), constants.SecretsDirPerm) + } +} + +func TestCreateInstance_SecretsDirectoryNotExist(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + // Don't create secrets directory - should fail + err = createInstance(tmpDir, "password", "admin") + if err == nil { + t.Error("Expected error when secrets directory doesn't exist, got nil") + } + + // Error message should mention running 'setup' first + expectedMsg := "run 'setup' first" + if err != nil && !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Error should mention running 'setup', got: %v", err) + } +} diff --git a/internal/instance/remove/remove.go b/internal/instance/remove/remove.go new file mode 100644 index 0000000..dada244 --- /dev/null +++ b/internal/instance/remove/remove.go @@ -0,0 +1,83 @@ +package remove + +import ( + "fmt" + "os" + + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + RemoveHelp = "Remove an OpenSlides instance directory" + RemoveHelpExtra = `Removes the entire OpenSlides instance directory and all its contents. + +WARNING: This operation is irreversible! All configuration files, secrets, +and instance data in the directory will be permanently deleted. + +Examples: + osmanage remove ./my.instance.dir.org --force` +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "remove ", + Short: RemoveHelp, + Long: RemoveHelp + "\n\n" + RemoveHelpExtra, + Args: cobra.ExactArgs(1), + } + + force := cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S REMOVE INSTANCE ===") + instanceDir := args[0] + logger.Debug("Instance directory: %s", instanceDir) + + if err := removeInstance(instanceDir, *force); err != nil { + return fmt.Errorf("removing instance: %w", err) + } + + logger.Info("Instance removed successfully") + return nil + } + + return cmd +} + +// removeInstance removes the entire instance directory +func removeInstance(instanceDir string, force bool) error { + info, err := os.Stat(instanceDir) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("%s does not exist", instanceDir) + } + return fmt.Errorf("checking directory: %w", err) + } + + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", instanceDir) + } + + if !force { + logger.Warn("This will permanently delete: %s", instanceDir) + logger.Warn("All configuration files, secrets, and data will be lost!") + + fmt.Print("Are you sure you want to continue? [y/N]: ") + var response string + _, _ = fmt.Scanln(&response) + + if response != "y" && response != "Y" && response != "yes" && response != "YES" { + logger.Info("Removal cancelled") + return nil + } + } + + logger.Info("Removing instance directory: %s", instanceDir) + + if err := os.RemoveAll(instanceDir); err != nil { + return fmt.Errorf("removing directory: %w", err) + } + + return nil +} diff --git a/internal/instance/remove/remove_test.go b/internal/instance/remove/remove_test.go new file mode 100644 index 0000000..b2bcd43 --- /dev/null +++ b/internal/instance/remove/remove_test.go @@ -0,0 +1,250 @@ +package remove + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" +) + +func TestRemoveInstance_DirectoryExists(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + instanceDir := filepath.Join(tmpDir, "test-instance") + if err := os.MkdirAll(instanceDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create instance dir: %v", err) + } + + secretsDir := filepath.Join(instanceDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { + t.Fatalf("Failed to create secrets dir: %v", err) + } + + testFile := filepath.Join(secretsDir, "test_secret") + if err := os.WriteFile(testFile, []byte("secret"), constants.SecretFilePerm); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(instanceDir, true) + if err != nil { + t.Fatalf("removeInstance failed: %v", err) + } + + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + t.Errorf("Instance directory still exists after removal") + } +} + +func TestRemoveInstance_DirectoryNotExist(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + nonExistentDir := filepath.Join(tmpDir, "does-not-exist") + + err = removeInstance(nonExistentDir, true) + if err == nil { + t.Error("Expected error when removing non-existent directory, got nil") + } + + expectedMsg := "does not exist" + if err != nil && !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Error should mention directory doesn't exist, got: %v", err) + } +} + +func TestRemoveInstance_NotADirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + testFile := filepath.Join(tmpDir, "test-file") + if err := os.WriteFile(testFile, []byte("test"), constants.StackFilePerm); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + err = removeInstance(testFile, true) + if err == nil { + t.Error("Expected error when removing a file instead of directory, got nil") + } + + expectedMsg := "not a directory" + if err != nil && !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Error should mention it's not a directory, got: %v", err) + } +} + +func TestRemoveInstance_RemovesNestedStructure(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + instanceDir := filepath.Join(tmpDir, "complex-instance") + + // Create directories with appropriate permissions + dirs := map[string]os.FileMode{ + filepath.Join(instanceDir, constants.SecretsDirName): constants.SecretsDirPerm, + filepath.Join(instanceDir, constants.StackDirName): constants.StackDirPerm, + filepath.Join(instanceDir, "data", "postgres"): constants.InstanceDirPerm, + filepath.Join(instanceDir, "data", "redis"): constants.InstanceDirPerm, + } + + for dir, perm := range dirs { + if err := os.MkdirAll(dir, perm); err != nil { + t.Fatalf("Failed to create dir %s: %v", dir, err) + } + } + + // Create files with appropriate permissions + files := map[string]os.FileMode{ + filepath.Join(instanceDir, constants.SecretsDirName, constants.PgPasswordFile): constants.SecretFilePerm, + filepath.Join(instanceDir, constants.SecretsDirName, constants.AdminSecretsFile): constants.SecretFilePerm, + filepath.Join(instanceDir, constants.StackDirName, "deployment.yaml"): constants.StackFilePerm, + filepath.Join(instanceDir, "data", "postgres", "pg_data.db"): constants.StackFilePerm, + filepath.Join(instanceDir, "data", "redis", "dump.rdb"): constants.StackFilePerm, + } + + for file, perm := range files { + if err := os.WriteFile(file, []byte("test data"), perm); err != nil { + t.Fatalf("Failed to create file %s: %v", file, err) + } + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(instanceDir, true) + if err != nil { + t.Fatalf("removeInstance failed: %v", err) + } + + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + t.Error("Instance directory still exists after removal") + } + + if _, err := os.Stat(tmpDir); os.IsNotExist(err) { + t.Error("Parent directory should still exist") + } +} + +func TestRemoveInstance_WithForceFlag(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + instanceDir := filepath.Join(tmpDir, "test-instance") + if err := os.MkdirAll(instanceDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create instance dir: %v", err) + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(instanceDir, true) + if err != nil { + t.Fatalf("removeInstance with force=true failed: %v", err) + } + + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + t.Error("Instance directory still exists after removal") + } +} + +func TestRemoveInstance_EmptyDirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + instanceDir := filepath.Join(tmpDir, "empty-instance") + if err := os.MkdirAll(instanceDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create instance dir: %v", err) + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(instanceDir, true) + if err != nil { + t.Fatalf("removeInstance failed on empty directory: %v", err) + } + + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + t.Error("Empty directory still exists after removal") + } +} + +func TestRemoveInstance_WithSymlinks(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + instanceDir := filepath.Join(tmpDir, "test-instance") + if err := os.MkdirAll(instanceDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create instance dir: %v", err) + } + + targetFile := filepath.Join(tmpDir, "target.txt") + if err := os.WriteFile(targetFile, []byte("target"), constants.StackFilePerm); err != nil { + t.Fatalf("Failed to create target file: %v", err) + } + + symlinkPath := filepath.Join(instanceDir, "link") + if err := os.Symlink(targetFile, symlinkPath); err != nil { + t.Fatalf("Failed to create symlink: %v", err) + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(instanceDir, true) + if err != nil { + t.Fatalf("removeInstance failed: %v", err) + } + + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + t.Error("Instance directory still exists after removal") + } + + if _, err := os.Stat(targetFile); os.IsNotExist(err) { + t.Error("Symlink target should not be deleted") + } +} diff --git a/internal/templating/setup/setup.go b/internal/instance/setup/setup.go similarity index 72% rename from internal/templating/setup/setup.go rename to internal/instance/setup/setup.go index 6bce326..20b1b22 100644 --- a/internal/templating/setup/setup.go +++ b/internal/instance/setup/setup.go @@ -11,14 +11,14 @@ import ( "encoding/pem" "fmt" "io" - "io/fs" "math/big" "os" "path/filepath" "time" + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/instance/config" "github.com/OpenSlides/openslides-cli/internal/logger" - "github.com/OpenSlides/openslides-cli/internal/templating/config" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" @@ -26,17 +26,19 @@ import ( const ( SetupHelp = "Creates the required files for deployment" - SetupHelpExtra = `This command creates deployment configuration files (Docker Compose or Kubernetes). -It also creates the required secrets and directories for volumes containing persistent -database and SSL certs. Everything is created in the given directory.` - - DefaultSuperadminPasswordLength = 20 - DefaultPostgresPasswordLength = 40 - SecretsDirName = "secrets" - - subDirPerms fs.FileMode = 0770 - certCertName = "cert_crt" - certKeyName = "cert_key" + SetupHelpExtra = `Creates deployment configuration files and generates secrets for an OpenSlides instance. + +This command: +1. Creates secrets directory with secure permissions +2. Generates authentication tokens and passwords +3. Creates SSL certificates (if enableLocalHTTPS: true) +4. Generates deployment files from templates + +Examples: + osmanage setup ./my.instance.dir.org + osmanage setup ./my.instance.dir.org --force + osmanage setup ./my.instance.dir.org --template ./custom --config ./config.yaml + osmanage setup ./my.instance.dir.org --config ./base.yaml --config ./override.yaml` ) type SecretSpec struct { @@ -45,16 +47,16 @@ type SecretSpec struct { } var defaultSecrets = []SecretSpec{ - {"auth_token_key", randomSecret}, - {"auth_cookie_key", randomSecret}, - {"internal_auth_password", randomSecret}, - {"postgres_password", func() ([]byte, error) { return randomString(DefaultPostgresPasswordLength) }}, - {"superadmin", func() ([]byte, error) { return randomString(DefaultSuperadminPasswordLength) }}, + {constants.AuthTokenKey, randomSecret}, + {constants.AuthCookieKey, randomSecret}, + {constants.InternalAuthPassword, randomSecret}, + {constants.PgPasswordFile, func() ([]byte, error) { return randomString(constants.DefaultPostgresPasswordLength) }}, + {constants.AdminSecretsFile, func() ([]byte, error) { return randomString(constants.DefaultSuperadminPasswordLength) }}, } func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "setup directory", + Use: "setup ", Short: SetupHelp, Long: SetupHelp + "\n\n" + SetupHelpExtra, Args: cobra.ExactArgs(1), @@ -79,22 +81,22 @@ func Cmd() *cobra.Command { } // Create secrets directory - secrDir := filepath.Join(baseDir, SecretsDirName) - logger.Debug("Creating secrets directory: %s", secrDir) - if err := os.MkdirAll(secrDir, subDirPerms); err != nil { + secretsDir := filepath.Join(baseDir, constants.SecretsDirName) + logger.Debug("Creating secrets directory: %s", secretsDir) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { return fmt.Errorf("creating secrets directory: %w", err) } // Create secrets logger.Info("Creating secrets...") - if err := createSecrets(secrDir, *force, defaultSecrets); err != nil { + if err := createSecrets(secretsDir, *force, defaultSecrets); err != nil { return fmt.Errorf("creating secrets: %w", err) } // Create certificates if HTTPS is enabled if enableLocalHTTPS, ok := cfg["enableLocalHTTPS"].(bool); ok && enableLocalHTTPS { logger.Info("Creating SSL certificates...") - if err := createCerts(secrDir, *force); err != nil { + if err := createCerts(secretsDir, *force); err != nil { return fmt.Errorf("creating certificates: %w", err) } } @@ -120,7 +122,7 @@ func createSecrets(dir string, force bool, secrets []SecretSpec) error { if err != nil { return fmt.Errorf("generating secret %q: %w", spec.Name, err) } - if err := utils.CreateFile(dir, force, spec.Name, data); err != nil { + if err := utils.CreateFile(dir, force, spec.Name, data, constants.SecretFilePerm); err != nil { return fmt.Errorf("creating secret file %q: %w", spec.Name, err) } } @@ -131,7 +133,7 @@ func randomSecret() ([]byte, error) { buf := new(bytes.Buffer) b64e := base64.NewEncoder(base64.StdEncoding, buf) - if _, err := io.Copy(b64e, io.LimitReader(rand.Reader, 32)); err != nil { + if _, err := io.Copy(b64e, io.LimitReader(rand.Reader, constants.DefaultSecretBytesLength)); err != nil { if err := b64e.Close(); err != nil { return nil, fmt.Errorf("closing base64 encoder: %w", err) } @@ -145,14 +147,13 @@ func randomSecret() ([]byte, error) { } func randomString(length int) ([]byte, error) { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}:;<>,.?" - if length <= 0 { return nil, fmt.Errorf("length must be positive, got %d", length) } result := make([]byte, length) + charset := constants.PasswordCharset maxIndex := len(charset) randomBytes := make([]byte, length) @@ -201,7 +202,7 @@ func createCerts(dir string, force bool) error { if err := pem.Encode(buf1, &pem.Block{Type: "CERTIFICATE", Bytes: certData}); err != nil { return fmt.Errorf("encoding certificate: %w", err) } - if err := utils.CreateFile(dir, force, certCertName, buf1.Bytes()); err != nil { + if err := utils.CreateFile(dir, force, constants.CertCertName, buf1.Bytes(), constants.SecretFilePerm); err != nil { return fmt.Errorf("creating certificate file: %w", err) } @@ -214,7 +215,7 @@ func createCerts(dir string, force bool) error { if err := pem.Encode(buf2, &pem.Block{Type: "PRIVATE KEY", Bytes: keyData}); err != nil { return fmt.Errorf("encoding key: %w", err) } - if err := utils.CreateFile(dir, force, certKeyName, buf2.Bytes()); err != nil { + if err := utils.CreateFile(dir, force, constants.CertKeyName, buf2.Bytes(), constants.SecretFilePerm); err != nil { return fmt.Errorf("creating key file: %w", err) } diff --git a/internal/templating/setup/setup_test.go b/internal/instance/setup/setup_test.go similarity index 74% rename from internal/templating/setup/setup_test.go rename to internal/instance/setup/setup_test.go index 7b836bf..f562087 100644 --- a/internal/templating/setup/setup_test.go +++ b/internal/instance/setup/setup_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestRandomSecret(t *testing.T) { @@ -67,7 +69,7 @@ func TestRandomString(t *testing.T) { }) t.Run("contains only allowed characters", func(t *testing.T) { - const allowedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}:;<>,.?" + allowedChars := constants.PasswordCharset str, err := randomString(100) if err != nil { @@ -144,14 +146,23 @@ func TestCreateSecrets(t *testing.T) { if string(data2) != "secret2" { t.Errorf("Expected 'secret2', got %s", string(data2)) } + + // Verify permissions + info1, err := os.Stat(filepath.Join(tmpdir, "test_secret1")) + if err != nil { + t.Fatalf("failed to stat secret file: %v", err) + } + if info1.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Secret file permissions = %v, want %v", info1.Mode().Perm(), constants.SecretFilePerm) + } } func TestCreateSecrets_NoOverwrite(t *testing.T) { tmpdir := t.TempDir() // Create initial secret - if err := os.WriteFile(filepath.Join(tmpdir, "existing"), []byte("original"), 0644); err != nil { - t.Fatalf("failed to write template: %v", err) + if err := os.WriteFile(filepath.Join(tmpdir, "existing"), []byte("original"), constants.SecretFilePerm); err != nil { + t.Fatalf("failed to write initial secret: %v", err) } specs := []SecretSpec{ @@ -190,7 +201,8 @@ func TestCreateCerts(t *testing.T) { } // Check cert file - certData, err := os.ReadFile(filepath.Join(tmpdir, "cert_crt")) + certPath := filepath.Join(tmpdir, constants.CertCertName) + certData, err := os.ReadFile(certPath) if err != nil { t.Error("Expected cert_crt to be created") } @@ -198,24 +210,43 @@ func TestCreateCerts(t *testing.T) { t.Error("Expected PEM encoded certificate") } + // Verify cert permissions + certInfo, err := os.Stat(certPath) + if err != nil { + t.Fatalf("failed to stat cert file: %v", err) + } + if certInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Cert file permissions = %v, want %v", certInfo.Mode().Perm(), constants.SecretFilePerm) + } + // Check key file - keyData, err := os.ReadFile(filepath.Join(tmpdir, "cert_key")) + keyPath := filepath.Join(tmpdir, constants.CertKeyName) + keyData, err := os.ReadFile(keyPath) if err != nil { t.Error("Expected cert_key to be created") } if !strings.Contains(string(keyData), "BEGIN PRIVATE KEY") { t.Error("Expected PEM encoded private key") } + + // Verify key permissions + keyInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("failed to stat key file: %v", err) + } + if keyInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Key file permissions = %v, want %v", keyInfo.Mode().Perm(), constants.SecretFilePerm) + } } func TestDefaultSecrets(t *testing.T) { - // Verify default secrets are defined + // Verify default secrets are defined with correct names from constants expectedSecrets := []string{ - "auth_token_key", - "auth_cookie_key", - "internal_auth_password", - "postgres_password", - "superadmin", + constants.AuthTokenKey, + constants.AuthCookieKey, + constants.InternalAuthPassword, + constants.PgPasswordFile, + constants.AdminSecretsFile, } if len(defaultSecrets) != len(expectedSecrets) { @@ -230,33 +261,33 @@ func TestDefaultSecrets(t *testing.T) { // Test that postgres_password generates proper string for _, spec := range defaultSecrets { - if spec.Name == "postgres_password" { + if spec.Name == constants.PgPasswordFile { pwd, err := spec.Generator() if err != nil { t.Errorf("postgres_password generator error = %v", err) } - if len(pwd) != DefaultPostgresPasswordLength { - t.Errorf("Expected length %d, got %d", DefaultPostgresPasswordLength, len(pwd)) + if len(pwd) != constants.DefaultPostgresPasswordLength { + t.Errorf("Expected length %d, got %d", constants.DefaultPostgresPasswordLength, len(pwd)) } } } // Test superadmin password generator for _, spec := range defaultSecrets { - if spec.Name == "superadmin" { + if spec.Name == constants.AdminSecretsFile { pwd, err := spec.Generator() if err != nil { t.Errorf("Superadmin generator error = %v", err) } - if len(pwd) != DefaultSuperadminPasswordLength { - t.Errorf("Expected length %d, got %d", DefaultSuperadminPasswordLength, len(pwd)) + if len(pwd) != constants.DefaultSuperadminPasswordLength { + t.Errorf("Expected length %d, got %d", constants.DefaultSuperadminPasswordLength, len(pwd)) } } } // Test that base64-encoded secrets still work as expected for _, spec := range defaultSecrets { - if spec.Name == "auth_token_key" || spec.Name == "auth_cookie_key" || spec.Name == "internal_auth_password" { + if spec.Name == constants.AuthTokenKey || spec.Name == constants.AuthCookieKey || spec.Name == constants.InternalAuthPassword { secret, err := spec.Generator() if err != nil { t.Errorf("%s generator error = %v", spec.Name, err) @@ -288,8 +319,8 @@ defaults: containerRegistry: registry.example.com tag: 4.2.21 ` - if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { - t.Fatalf("failed to write template: %v", err) + if err := os.WriteFile(configFile, []byte(configContent), constants.StackFilePerm); err != nil { + t.Fatalf("failed to write config file: %v", err) } // Create a minimal template using camelCase config keys @@ -304,7 +335,7 @@ disablePostgres: {{ .disablePostgres }} registry: {{ .defaults.containerRegistry }} tag: {{ .defaults.tag }} ` - if err := os.WriteFile(templateFile, []byte(templateContent), 0644); err != nil { + if err := os.WriteFile(templateFile, []byte(templateContent), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } @@ -312,12 +343,21 @@ tag: {{ .defaults.tag }} outDir := filepath.Join(tmpdir, "output") t.Run("full setup flow", func(t *testing.T) { - // 1. Create secrets directory - secretsDir := filepath.Join(outDir, SecretsDirName) - if err := os.MkdirAll(secretsDir, 0755); err != nil { + // 1. Create secrets directory with correct permissions + secretsDir := filepath.Join(outDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { t.Fatal(err) } + // Verify secrets directory permissions + dirInfo, err := os.Stat(secretsDir) + if err != nil { + t.Fatalf("failed to stat secrets directory: %v", err) + } + if dirInfo.Mode().Perm() != constants.SecretsDirPerm { + t.Errorf("Secrets directory permissions = %v, want %v", dirInfo.Mode().Perm(), constants.SecretsDirPerm) + } + // 2. Create secrets if err := createSecrets(secretsDir, false, defaultSecrets); err != nil { t.Errorf("createSecrets() error = %v", err) @@ -332,21 +372,17 @@ tag: {{ .defaults.tag }} } // 4. Verify superadmin password length - superadminPath := filepath.Join(secretsDir, "superadmin") + superadminPath := filepath.Join(secretsDir, constants.AdminSecretsFile) pwd, _ := os.ReadFile(superadminPath) - if len(pwd) != DefaultSuperadminPasswordLength { - t.Errorf("Expected superadmin password length %d, got %d", DefaultSuperadminPasswordLength, len(pwd)) + if len(pwd) != constants.DefaultSuperadminPasswordLength { + t.Errorf("Expected superadmin password length %d, got %d", constants.DefaultSuperadminPasswordLength, len(pwd)) } // 5. Check postgres_password has correct length (not base64) - postgresPath := filepath.Join(secretsDir, "postgres_password") + postgresPath := filepath.Join(secretsDir, constants.PgPasswordFile) postgresPwd, _ := os.ReadFile(postgresPath) - if len(postgresPwd) != DefaultPostgresPasswordLength { - t.Errorf("Expected postgres password length %d, got %d", DefaultPostgresPasswordLength, len(postgresPwd)) - } - // Verify it's not base64 encoded (shouldn't end with =) - if postgresPwd[len(postgresPwd)-1] == '=' { - t.Error("Postgres password should not be base64 encoded") + if len(postgresPwd) != constants.DefaultPostgresPasswordLength { + t.Errorf("Expected postgres password length %d, got %d", constants.DefaultPostgresPasswordLength, len(postgresPwd)) } }) @@ -390,16 +426,16 @@ defaults: containerRegistry: registry.example.com tag: 4.2.21 ` - if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { - t.Fatalf("failed to write template: %v", err) + if err := os.WriteFile(configFile, []byte(configContent), constants.StackFilePerm); err != nil { + t.Fatalf("failed to write config file: %v", err) } outDir := filepath.Join(tmpdir, "output") - secretsDir := filepath.Join(outDir, SecretsDirName) + secretsDir := filepath.Join(outDir, constants.SecretsDirName) t.Run("creates HTTPS certificates when enabled", func(t *testing.T) { - // Create secrets directory - if err := os.MkdirAll(secretsDir, 0755); err != nil { + // Create secrets directory with correct permissions + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { t.Fatal(err) } @@ -414,8 +450,8 @@ defaults: } // Verify certificates exist - certPath := filepath.Join(secretsDir, "cert_crt") - keyPath := filepath.Join(secretsDir, "cert_key") + certPath := filepath.Join(secretsDir, constants.CertCertName) + keyPath := filepath.Join(secretsDir, constants.CertKeyName) if _, err := os.Stat(certPath); err != nil { t.Error("Expected cert_crt to be created when enableLocalHTTPS is true") diff --git a/internal/k8s/actions/apply.go b/internal/k8s/actions/apply.go new file mode 100644 index 0000000..95969d0 --- /dev/null +++ b/internal/k8s/actions/apply.go @@ -0,0 +1,123 @@ +package actions + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" +) + +const ( + // fieldManager identifies this client in Server-Side Apply operations + fieldManager string = "osmanage" + + // forceConflicts takes ownership of fields from other managers when conflicts occur + forceConflicts bool = true +) + +// applyManifest applies a single YAML manifest file using RESTMapper +func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath string) (string, error) { + logger.Debug("Applying manifest: %s", manifestPath) + + data, err := os.ReadFile(manifestPath) + if err != nil { + return "", fmt.Errorf("reading manifest: %w", err) + } + + var obj unstructured.Unstructured + if err := yaml.Unmarshal(data, &obj); err != nil { + return "", fmt.Errorf("parsing YAML: %w", err) + } + + namespace := obj.GetNamespace() + if namespace == "" && obj.GetKind() == "Namespace" { + namespace = obj.GetName() + } + + mapper, err := k8sClient.RESTMapper() + if err != nil { + return "", fmt.Errorf("getting REST mapper: %w", err) + } + + gvk := obj.GroupVersionKind() + + mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return "", fmt.Errorf("getting REST mapping for %s: %w", gvk.String(), err) + } + + dynamicClient, err := k8sClient.Dynamic() + if err != nil { + return "", fmt.Errorf("getting dynamic client: %w", err) + } + + var result *unstructured.Unstructured + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + if namespace == "" { + return "", fmt.Errorf("resource %s/%s is namespaced but has no namespace specified", + obj.GetKind(), obj.GetName()) + } + result, err = dynamicClient.Resource(mapping.Resource).Namespace(namespace).Apply( + ctx, + obj.GetName(), + &obj, + metav1.ApplyOptions{ + FieldManager: fieldManager, + Force: forceConflicts, + }, + ) + } else { + // Cluster-scoped resource (Namespace, ClusterRole, etc.) + result, err = dynamicClient.Resource(mapping.Resource).Apply( + ctx, + obj.GetName(), + &obj, + metav1.ApplyOptions{ + FieldManager: fieldManager, + Force: forceConflicts, + }, + ) + } + + if err != nil { + return namespace, fmt.Errorf("applying %s/%s: %w", obj.GetKind(), obj.GetName(), err) + } + + logger.Info("Applied %s: %s", result.GetKind(), result.GetName()) + return namespace, nil +} + +// applyDirectory applies all YAML files in a directory +func applyDirectory(ctx context.Context, k8sClient *client.Client, dirPath string) error { + files, err := os.ReadDir(dirPath) + if err != nil { + return fmt.Errorf("reading directory: %w", err) + } + + for _, file := range files { + if file.IsDir() { + continue + } + + if !isYAMLFile(file.Name()) { + logger.Debug("Skipping non-YAML file: %s", file.Name()) + continue + } + + manifestPath := filepath.Join(dirPath, file.Name()) + if _, err := applyManifest(ctx, k8sClient, manifestPath); err != nil { + logger.Warn("Failed to apply %s: %v", file.Name(), err) + continue + } + } + + return nil +} diff --git a/internal/k8s/actions/cluster_status.go b/internal/k8s/actions/cluster_status.go new file mode 100644 index 0000000..7229125 --- /dev/null +++ b/internal/k8s/actions/cluster_status.go @@ -0,0 +1,160 @@ +package actions + +import ( + "context" + "fmt" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ClusterStatusHelp = "Check Kubernetes cluster status" + ClusterStatusHelpExtra = `Checks the health of all nodes in the Kubernetes cluster. + +Examples: + osmanage k8s cluster-status + osmanage k8s cluster-status --kubeconfig ~/.kube/config` +) + +type NodeStatus struct { + Name string + Ready bool + Conditions []corev1.NodeCondition +} + +type ClusterStatus struct { + TotalNodes int + ReadyNodes int + Nodes []NodeStatus +} + +func ClusterStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cluster-status", + Short: ClusterStatusHelp, + Long: ClusterStatusHelp + "\n\n" + ClusterStatusHelpExtra, + Args: cobra.NoArgs, + } + + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S CLUSTER STATUS ===") + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + status, err := checkClusterStatus(ctx, k8sClient) + if err != nil { + return fmt.Errorf("checking cluster status: %w", err) + } + + fmt.Printf("cluster_status: %d %d\n", status.TotalNodes, status.ReadyNodes) + + logger.Info("Total nodes: %d", status.TotalNodes) + logger.Info("Ready nodes: %d", status.ReadyNodes) + + for _, node := range status.Nodes { + if node.Ready { + logger.Info("Node %s: Ready", node.Name) + } else { + logger.Info("Node %s: NotReady", node.Name) + for _, cond := range node.Conditions { + if cond.Status == corev1.ConditionTrue && cond.Type != corev1.NodeReady { + logger.Debug(" - %s: %s (Reason: %s)", cond.Type, cond.Status, cond.Reason) + } + } + } + } + + if status.ReadyNodes != status.TotalNodes { + return fmt.Errorf("cluster is not healthy: %d/%d nodes ready", status.ReadyNodes, status.TotalNodes) + } + + logger.Info("Cluster is healthy") + return nil + } + + return cmd +} + +// checkClusterStatus checks the overall cluster health +func checkClusterStatus(ctx context.Context, k8sClient *client.Client) (*ClusterStatus, error) { + nodes, err := k8sClient.Clientset().CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing nodes: %w", err) + } + + status := &ClusterStatus{ + TotalNodes: len(nodes.Items), + Nodes: make([]NodeStatus, 0, len(nodes.Items)), + } + + for _, node := range nodes.Items { + nodeStatus := NodeStatus{ + Name: node.Name, + Ready: isNodeReady(&node), + Conditions: node.Status.Conditions, + } + + if nodeStatus.Ready { + status.ReadyNodes++ + } + + status.Nodes = append(status.Nodes, nodeStatus) + } + + return status, nil +} + +// isNodeReady checks if a node is ready +func isNodeReady(node *corev1.Node) bool { + for _, condition := range node.Status.Conditions { + if condition.Type == corev1.NodeReady { + return condition.Status == corev1.ConditionTrue + } + } + return false +} + +// IsNodeHealthy checks if a node is healthy (no pressure conditions) +func IsNodeHealthy(node *corev1.Node) bool { + if !isNodeReady(node) { + return false + } + + pressureTypes := []corev1.NodeConditionType{ + corev1.NodeMemoryPressure, + corev1.NodeDiskPressure, + corev1.NodePIDPressure, + corev1.NodeNetworkUnavailable, + } + + for _, pressureType := range pressureTypes { + condition := GetNodeCondition(node, pressureType) + if condition != nil && condition.Status == corev1.ConditionTrue { + return false + } + } + + return true +} + +// GetNodeCondition retrieves a specific condition from a node +func GetNodeCondition(node *corev1.Node, conditionType corev1.NodeConditionType) *corev1.NodeCondition { + for _, condition := range node.Status.Conditions { + if condition.Type == conditionType { + return &condition + } + } + return nil +} diff --git a/internal/k8s/actions/cluster_status_test.go b/internal/k8s/actions/cluster_status_test.go new file mode 100644 index 0000000..febf8e3 --- /dev/null +++ b/internal/k8s/actions/cluster_status_test.go @@ -0,0 +1,254 @@ +package actions + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestIsNodeReady_Ready(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + }, + }, + }, + } + + if !isNodeReady(node) { + t.Error("Expected node to be ready") + } +} + +func TestIsNodeReady_NotReady(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionFalse, + }, + }, + }, + } + + if isNodeReady(node) { + t.Error("Expected node to not be ready") + } +} + +func TestIsNodeReady_NoCondition(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{}, + }, + } + + if isNodeReady(node) { + t.Error("Expected node to not be ready when no Ready condition exists") + } +} + +func TestIsNodeReady_MultipleConditions(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeDiskPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, + } + + if !isNodeReady(node) { + t.Error("Expected node to be ready even with multiple conditions") + } +} + +func TestIsNodeHealthy_Healthy(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeDiskPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodePIDPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeNetworkUnavailable, Status: corev1.ConditionFalse}, + }, + }, + } + + if !IsNodeHealthy(node) { + t.Error("Expected node to be healthy") + } +} + +func TestIsNodeHealthy_NotReady(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionFalse}, + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionFalse}, + }, + }, + } + + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy when not ready") + } +} + +func TestIsNodeHealthy_MemoryPressure(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionTrue}, + }, + }, + } + + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy with memory pressure") + } +} + +func TestIsNodeHealthy_DiskPressure(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodeDiskPressure, Status: corev1.ConditionTrue}, + }, + }, + } + + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy with disk pressure") + } +} + +func TestIsNodeHealthy_PIDPressure(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodePIDPressure, Status: corev1.ConditionTrue}, + }, + }, + } + + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy with PID pressure") + } +} + +func TestIsNodeHealthy_NetworkUnavailable(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodeNetworkUnavailable, Status: corev1.ConditionTrue}, + }, + }, + } + + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy with network unavailable") + } +} + +func TestGetNodeCondition_Exists(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + Reason: "KubeletReady", + }, + { + Type: corev1.NodeMemoryPressure, + Status: corev1.ConditionFalse, + }, + }, + }, + } + + condition := GetNodeCondition(node, corev1.NodeReady) + if condition == nil { + t.Fatal("Expected to find Ready condition") + return + } + + if condition.Type != corev1.NodeReady { + t.Errorf("Expected condition type %v, got %v", corev1.NodeReady, condition.Type) + } + + if condition.Status != corev1.ConditionTrue { + t.Errorf("Expected condition status %v, got %v", corev1.ConditionTrue, condition.Status) + } + + if condition.Reason != "KubeletReady" { + t.Errorf("Expected reason 'KubeletReady', got %v", condition.Reason) + } +} + +func TestGetNodeCondition_NotExists(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, + } + + condition := GetNodeCondition(node, corev1.NodeMemoryPressure) + if condition != nil { + t.Error("Expected nil when condition doesn't exist") + } +} + +func TestGetNodeCondition_EmptyConditions(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{}, + }, + } + + condition := GetNodeCondition(node, corev1.NodeReady) + if condition != nil { + t.Error("Expected nil when no conditions exist") + } +} + +func TestGetNodeCondition_MultipleConditions(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeDiskPressure, Status: corev1.ConditionTrue}, + {Type: corev1.NodePIDPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, + } + + // Test finding DiskPressure condition + condition := GetNodeCondition(node, corev1.NodeDiskPressure) + if condition == nil { + t.Fatal("Expected to find DiskPressure condition") + return + } + + if condition.Type != corev1.NodeDiskPressure { + t.Errorf("Expected DiskPressure, got %v", condition.Type) + } + + if condition.Status != corev1.ConditionTrue { + t.Errorf("Expected condition status True, got %v", condition.Status) + } +} diff --git a/internal/k8s/actions/health.go b/internal/k8s/actions/health.go new file mode 100644 index 0000000..0d5303d --- /dev/null +++ b/internal/k8s/actions/health.go @@ -0,0 +1,55 @@ +package actions + +import ( + "context" + "fmt" + + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + HealthHelp = "Check health status of an OpenSlides instance" + HealthHelpExtra = `Checks if all pods in the instance namespace are ready and running. + +Examples: + osmanage k8s health ./my.instance.dir.org + osmanage k8s health ./my.instance.dir.org --wait --timeout 30s` +) + +func HealthCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "health", + Short: HealthHelp, + Long: HealthHelp + "\n\n" + HealthHelpExtra, + Args: cobra.ExactArgs(1), + } + + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + wait := cmd.Flags().Bool("wait", false, "Wait for instance to become healthy") + timeout := cmd.Flags().Duration("timeout", constants.DefaultInstanceTimeout, "Timeout for instance health check") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S HEALTH CHECK ===") + instanceDir := args[0] + namespace := extractNamespace(instanceDir) + logger.Debug("Namespace: %s", namespace) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + if *wait { + return waitForInstanceHealthy(ctx, k8sClient, namespace, *timeout) + } + + return checkHealth(ctx, k8sClient, namespace) + } + + return cmd +} diff --git a/internal/k8s/actions/health_check.go b/internal/k8s/actions/health_check.go new file mode 100644 index 0000000..d693889 --- /dev/null +++ b/internal/k8s/actions/health_check.go @@ -0,0 +1,296 @@ +package actions + +import ( + "context" + "fmt" + "time" + + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/schollz/progressbar/v3" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// HealthStatus represents the health status of an instance +type HealthStatus struct { + Healthy bool + Ready int + Total int + Pods []corev1.Pod +} + +// getHealthStatus returns health metrics +func getHealthStatus(ctx context.Context, k8sClient *client.Client, namespace string) (*HealthStatus, error) { + pods, err := k8sClient.Clientset().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing pods: %w", err) + } + + total := len(pods.Items) + if total == 0 { + return &HealthStatus{ + Healthy: false, + Ready: 0, + Total: 0, + Pods: nil, + }, nil + } + + ready := 0 + for _, pod := range pods.Items { + if isPodReady(&pod) { + ready++ + } + } + + return &HealthStatus{ + Healthy: ready == total, + Ready: ready, + Total: total, + Pods: pods.Items, + }, nil +} + +// Helper to print instance pod status +func printHealthStatus(namespace string, status *HealthStatus) { + if status.Total == 0 { + fmt.Printf("No pods found in namespace %s\n", namespace) + return + } + + fmt.Printf("\nNamespace: %s\n", namespace) + fmt.Printf("Ready: %d/%d pods\n\n", status.Ready, status.Total) + fmt.Println("Pod Status:") + + for _, pod := range status.Pods { + ready := isPodReady(&pod) + icon := constants.IconNotReady + if ready { + icon = constants.IconReady + } + fmt.Printf(" %s %-50s %s\n", icon, pod.Name, pod.Status.Phase) + } + fmt.Println() +} + +// checkHealth checks the current health status and prints details +func checkHealth(ctx context.Context, k8sClient *client.Client, namespace string) error { + status, err := getHealthStatus(ctx, k8sClient, namespace) + if err != nil { + return fmt.Errorf("getting health status: %w", err) + } + + printHealthStatus(namespace, status) + + if !status.Healthy { + return fmt.Errorf("instance is not healthy: %d/%d pods ready", status.Ready, status.Total) + } + + logger.Info("Instance is healthy") + return nil +} + +// waitForInstanceHealthy waits for instance to become healthy +func waitForInstanceHealthy(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { + logger.Info("Waiting for instance to become healthy (timeout: %v)", timeout) + + ticker := time.NewTicker(constants.TickerDuration) + defer ticker.Stop() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var lastStatus *HealthStatus + var bar *progressbar.ProgressBar + + for { + select { + case <-ticker.C: + status, err := getHealthStatus(ctx, k8sClient, namespace) + if err != nil { + logger.Debug("Error checking health: %v", err) + continue + } + lastStatus = status + + if bar == nil && status.Total > 0 { + bar = createProgressBar(status.Total, "Pods ready") + } + + if bar != nil { + if err := bar.Set(status.Ready); err != nil { + return fmt.Errorf("setting progress bar: %w", err) + } + } + + if status.Healthy { + if bar != nil { + if err := bar.Finish(); err != nil { + return fmt.Errorf("finishing progress bar: %w", err) + } + } + logger.Info("Instance is healthy: %d/%d pods ready", status.Ready, status.Total) + return nil + } + + case <-timeoutCtx.Done(): + if bar != nil { + if err := bar.Finish(); err != nil { + return fmt.Errorf("finishing progress bar: %w", err) + } + } + logger.Warn("Timeout reached. Current status:") + if lastStatus != nil { + printHealthStatus(namespace, lastStatus) + } + return fmt.Errorf("timeout waiting for instance to become healthy") + } + } +} + +func createProgressBar(max int, description string) *progressbar.ProgressBar { + return progressbar.NewOptions(max, + progressbar.OptionSetDescription(description), + progressbar.OptionSetWidth(constants.ProgressBarWidth), + progressbar.OptionShowCount(), + progressbar.OptionSetTheme(progressbar.Theme{ + Saucer: constants.Saucer, + SaucerPadding: constants.SaucerPadding, + BarStart: constants.BarStart, + BarEnd: constants.BarEnd, + }), + progressbar.OptionThrottle(constants.ThrottleDuration), + progressbar.OptionClearOnFinish(), + ) +} + +// isPodReady checks if a pod is ready +func isPodReady(pod *corev1.Pod) bool { + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + return condition.Status == corev1.ConditionTrue + } + } + return false +} + +// namespaceIsActive checks if a namespace exists and is active +func namespaceIsActive(ctx context.Context, k8sClient *client.Client, namespace string) (bool, error) { + ns, err := k8sClient.Clientset().CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("getting namespace: %w", err) + } + + return ns.Status.Phase == corev1.NamespaceActive, nil +} + +// Helper to print deployment status +func printDeploymentStatus(namespace, name string, deployment *appsv1.Deployment) { + fmt.Printf("\nDeployment: %s (namespace: %s)\n", name, namespace) + fmt.Printf("Generation: %d/%d (observed/current)\n", + deployment.Status.ObservedGeneration, + deployment.Generation) + fmt.Printf("Replicas:\n") + fmt.Printf(" Desired: %d\n", *deployment.Spec.Replicas) + fmt.Printf(" Current: %d\n", deployment.Status.Replicas) + fmt.Printf(" Ready: %d\n", deployment.Status.ReadyReplicas) + fmt.Printf(" Updated: %d\n", deployment.Status.UpdatedReplicas) + fmt.Printf(" Available: %d\n", deployment.Status.AvailableReplicas) + + if len(deployment.Status.Conditions) > 0 { + fmt.Println("\nConditions:") + for _, condition := range deployment.Status.Conditions { + icon := constants.IconReady + if condition.Status != corev1.ConditionTrue { + icon = constants.IconNotReady + } + fmt.Printf(" %s %-20s %s\n", icon, condition.Type, condition.Message) + } + } + fmt.Println() +} + +// waitForDeploymentReady waits for a specific deployment to be ready +func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, namespace, deploymentName string, timeout time.Duration) error { + logger.Debug("Waiting for deployment %s to be ready (timeout: %v)", deploymentName, timeout) + + ticker := time.NewTicker(constants.TickerDuration) + defer ticker.Stop() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var lastDeployment *appsv1.Deployment + for { + select { + case <-ticker.C: + deployment, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Get(timeoutCtx, deploymentName, metav1.GetOptions{}) + if err != nil { + logger.Debug("Error getting deployment: %v", err) + continue + } + + lastDeployment = deployment + + if deployment.Status.ObservedGeneration >= deployment.Generation && + deployment.Status.UpdatedReplicas == *deployment.Spec.Replicas && + deployment.Status.AvailableReplicas == *deployment.Spec.Replicas && + deployment.Status.ReadyReplicas == *deployment.Spec.Replicas && + deployment.Status.Replicas == *deployment.Spec.Replicas { + + logger.Info("Deployment %s is ready with %d replicas", deploymentName, *deployment.Spec.Replicas) + return nil + } + + logger.Debug("Deployment %s: %d/%d replicas ready, %d total (generation: %d/%d)", + deploymentName, + deployment.Status.ReadyReplicas, + *deployment.Spec.Replicas, + deployment.Status.Replicas, + deployment.Status.ObservedGeneration, + deployment.Generation) + + case <-timeoutCtx.Done(): + logger.Warn("Timeout reached. Deployment status:") + if lastDeployment != nil { + printDeploymentStatus(namespace, deploymentName, lastDeployment) + } + + return fmt.Errorf("timeout waiting for deployment %s to become ready", deploymentName) + } + } +} + +// waitForNamespaceDeletion waits for a namespace to be completely deleted +func waitForNamespaceDeletion(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { + clientset := k8sClient.Clientset() + + ticker := time.NewTicker(constants.TickerDuration) + defer ticker.Stop() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case <-ticker.C: + _, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + logger.Debug("Namespace %s successfully deleted", namespace) + return nil + } + logger.Debug("Namespace %s still terminating...", namespace) + + case <-timeoutCtx.Done(): + return fmt.Errorf("timeout waiting for namespace %s to be deleted", namespace) + } + } +} diff --git a/internal/k8s/actions/health_check_test.go b/internal/k8s/actions/health_check_test.go new file mode 100644 index 0000000..235752c --- /dev/null +++ b/internal/k8s/actions/health_check_test.go @@ -0,0 +1,56 @@ +package actions + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestIsPodReady_Ready(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } + if !isPodReady(pod) { + t.Error("Expected pod to be ready") + } +} + +func TestIsPodReady_NotReady(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionFalse}, + }, + }, + } + if isPodReady(pod) { + t.Error("Expected pod to not be ready") + } +} + +func TestIsPodReady_NoCondition(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{Conditions: []corev1.PodCondition{}}, + } + if isPodReady(pod) { + t.Error("Expected pod to not be ready when no Ready condition exists") + } +} + +func TestIsPodReady_MultipleConditions(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodScheduled, Status: corev1.ConditionTrue}, + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } + if !isPodReady(pod) { + t.Error("Expected pod to be ready even with multiple conditions") + } +} diff --git a/internal/k8s/actions/helpers.go b/internal/k8s/actions/helpers.go new file mode 100644 index 0000000..882441c --- /dev/null +++ b/internal/k8s/actions/helpers.go @@ -0,0 +1,27 @@ +package actions + +import ( + "os" + "path/filepath" + "strings" +) + +// extractNamespace gets the namespace from instance directory path +// Example: "/real/path/to/my.instance.dir.url" -> "myinstancedirurl" +func extractNamespace(instanceDir string) string { + dirName := filepath.Base(instanceDir) + namespace := strings.ReplaceAll(dirName, ".", "") + return namespace +} + +// fileExists checks if a file exists +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// isYAMLFile checks if filename has YAML extension +func isYAMLFile(filename string) bool { + ext := filepath.Ext(filename) + return ext == ".yaml" || ext == ".yml" +} diff --git a/internal/k8s/actions/helpers_test.go b/internal/k8s/actions/helpers_test.go new file mode 100644 index 0000000..195b4da --- /dev/null +++ b/internal/k8s/actions/helpers_test.go @@ -0,0 +1,125 @@ +package actions + +import ( + "os" + "testing" +) + +func TestExtractNamespace(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple directory", + input: "my-instance", + expected: "my-instance", + }, + { + name: "directory with dots", + input: "my.instance.org", + expected: "myinstanceorg", + }, + { + name: "full path with dots", + input: "/home/user/projects/my.instance.org", + expected: "myinstanceorg", + }, + { + name: "nested path without dots", + input: "/var/lib/test/prod-instance", + expected: "prod-instance", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractNamespace(tt.input) + if result != tt.expected { + t.Errorf("extractNamespace(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestFileExists(t *testing.T) { + // Create a temporary file + tmpFile, err := os.CreateTemp("", "test-file-*") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer func() { + _ = tmpFile.Close() + _ = os.Remove(tmpFile.Name()) + }() + + tests := []struct { + name string + path string + expected bool + }{ + { + name: "existing file", + path: tmpFile.Name(), + expected: true, + }, + { + name: "non-existing file", + path: "/tmp/definitely-does-not-exist-12345", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := fileExists(tt.path) + if result != tt.expected { + t.Errorf("fileExists(%q) = %v, want %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestIsYAMLFile(t *testing.T) { + tests := []struct { + name string + filename string + expected bool + }{ + { + name: "yaml extension", + filename: "deployment.yaml", + expected: true, + }, + { + name: "yml extension", + filename: "service.yml", + expected: true, + }, + { + name: "json file", + filename: "config.json", + expected: false, + }, + { + name: "no extension", + filename: "Makefile", + expected: false, + }, + { + name: "yaml in path but not extension", + filename: "/path/yaml/file.txt", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isYAMLFile(tt.filename) + if result != tt.expected { + t.Errorf("isYAMLFile(%q) = %v, want %v", tt.filename, result, tt.expected) + } + }) + } +} diff --git a/internal/k8s/actions/scale.go b/internal/k8s/actions/scale.go new file mode 100644 index 0000000..f2c94c4 --- /dev/null +++ b/internal/k8s/actions/scale.go @@ -0,0 +1,87 @@ +package actions + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + ScaleHelp = "Scales an OpenSlides service deployment" + ScaleHelpExtra = `Applies the deployment manifest for a specific service after replicas have been modified. + +Note: You must edit the deployment file to change the replica count before running this command. + +Examples: + osmanage k8s scale ./my.instance.dir.org --service backendmanage + osmanage k8s scale ./my.instance.dir.org --service autoupdate --skip-ready-check + osmanage k8s scale ./my.instance.dir.org --service search --kubeconfig ~/.kube/config --timeout 30s` +) + +func ScaleCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "scale ", + Short: ScaleHelp, + Long: ScaleHelp + "\n\n" + ScaleHelpExtra, + Args: cobra.ExactArgs(1), + } + + service := cmd.Flags().String("service", "", "Service deployment to scale (required)") + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for deployment to become ready") + timeout := cmd.Flags().Duration("timeout", constants.DefaultDeploymentTimeout, "Timeout for deployment rollout check") + + _ = cmd.MarkFlagRequired("service") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(*service) == "" { + return fmt.Errorf("--service cannot be empty") + } + + logger.Info("=== K8S SCALE SERVICE ===") + instanceDir := args[0] + logger.Debug("Instance directory: %s", instanceDir) + logger.Info("Service: %s", *service) + + namespace := extractNamespace(instanceDir) + logger.Info("Namespace: %s", namespace) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + // Construct path to deployment file + deploymentFile := fmt.Sprintf(constants.DeploymentFileTemplate, *service) + deploymentPath := filepath.Join(instanceDir, constants.StackDirName, deploymentFile) + + logger.Info("Applying deployment manifest: %s", deploymentPath) + if _, err := applyManifest(ctx, k8sClient, deploymentPath); err != nil { + return fmt.Errorf("applying deployment: %w", err) + } + + if *skipReadyCheck { + logger.Info("Skipping ready check") + return nil + } + + logger.Info("Waiting for deployment to become ready...") + // Wait for the specific deployment (OpenSlides service name is deployment name) + if err := waitForDeploymentReady(ctx, k8sClient, namespace, *service, *timeout); err != nil { + return fmt.Errorf("waiting for deployment ready: %w", err) + } + + logger.Info("Service scaled successfully") + return nil + } + + return cmd +} diff --git a/internal/k8s/actions/start.go b/internal/k8s/actions/start.go new file mode 100644 index 0000000..c530547 --- /dev/null +++ b/internal/k8s/actions/start.go @@ -0,0 +1,84 @@ +package actions + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + StartHelp = "Start an OpenSlides instance" + StartHelpExtra = `Applies Kubernetes manifests to start an OpenSlides instance. + +Examples: + osmanage k8s start ./my.instance.dir.org + osmanage k8s start ./my.instance.dir.org --skip-ready-check + osmanage k8s start ./my.instance.dir.org --kubeconfig ~/.kube/config --timeout 30s` +) + +func StartCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "start ", + Short: StartHelp, + Long: StartHelp + "\n\n" + StartHelpExtra, + Args: cobra.ExactArgs(1), + } + + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for instance to become ready") + timeout := cmd.Flags().Duration("timeout", constants.DefaultInstanceTimeout, "Timeout for instance health check") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S START INSTANCE ===") + instanceDir := args[0] + logger.Debug("Instance directory: %s", instanceDir) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + namespacePath := filepath.Join(instanceDir, constants.NamespaceYAML) + namespace, err := applyManifest(ctx, k8sClient, namespacePath) + if err != nil { + return fmt.Errorf("applying namespace: %w", err) + } + logger.Info("Applied namespace: %s", namespace) + + tlsSecretPath := filepath.Join(instanceDir, constants.SecretsDirName, constants.TlsCertSecretYAML) + if fileExists(tlsSecretPath) { + logger.Info("Found and applying %s", tlsSecretPath) + if _, err := applyManifest(ctx, k8sClient, tlsSecretPath); err != nil { + return fmt.Errorf("applying TLS secret: %w", err) + } + } + + stackDir := filepath.Join(instanceDir, constants.StackDirName) + logger.Info("Applying stack manifests from: %s", stackDir) + if err := applyDirectory(ctx, k8sClient, stackDir); err != nil { + return fmt.Errorf("applying stack: %w", err) + } + + if *skipReadyCheck { + logger.Info("Skipping ready check") + return nil + } + + logger.Info("Waiting for instance to become ready...") + if err := waitForInstanceHealthy(ctx, k8sClient, namespace, *timeout); err != nil { + return fmt.Errorf("waiting for ready: %w", err) + } + + logger.Info("Instance started successfully") + return nil + } + + return cmd +} diff --git a/internal/k8s/actions/stop.go b/internal/k8s/actions/stop.go new file mode 100644 index 0000000..cdafa27 --- /dev/null +++ b/internal/k8s/actions/stop.go @@ -0,0 +1,116 @@ +package actions + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +const ( + StopHelp = "Stop an OpenSlides instance" + StopHelpExtra = `Stops an OpenSlides instance by deleting its Kubernetes namespace. +If a TLS certificate secret exists, it will be saved before deletion. + +Examples: + osmanage k8s stop ./my.instance.dir.org --kubeconfig ~/.kube/config` +) + +func StopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop ", + Short: StopHelp, + Long: StopHelp + "\n\n" + StopHelpExtra, + Args: cobra.ExactArgs(1), + } + + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + timeout := cmd.Flags().Duration("timeout", constants.DefaultNamespaceTimeout, "timeout for namespace deletion") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S STOP INSTANCE ===") + instanceDir := args[0] + + logger.Debug("Instance directory: %s", instanceDir) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + namespace := extractNamespace(instanceDir) + if err := saveTLSSecret(ctx, k8sClient, namespace, instanceDir); err != nil { + logger.Warn("Failed to save TLS secret: %v", err) + } + + logger.Info("Stopping instance: %s", namespace) + if err := deleteNamespace(ctx, k8sClient, namespace, *timeout); err != nil { + return fmt.Errorf("deleting namespace: %w", err) + } + + logger.Info("Instance stopped successfully") + return nil + } + + return cmd +} + +// saveTLSSecret saves the TLS certificate secret to a YAML file if it exists +func saveTLSSecret(ctx context.Context, k8sClient *client.Client, namespace, instanceDir string) error { + clientset := k8sClient.Clientset() + + secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, constants.TlsCertSecret, metav1.GetOptions{}) + if err != nil { + logger.Debug("TLS secret %s not found in namespace %s", constants.TlsCertSecret, namespace) + return nil + } + + secretYAML, err := yaml.Marshal(secret) + if err != nil { + return fmt.Errorf("marshaling secret to YAML: %w", err) + } + + secretsDir := filepath.Join(instanceDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { + return fmt.Errorf("creating secrets directory: %w", err) + } + + secretPath := filepath.Join(secretsDir, constants.TlsCertSecretYAML) + if err := os.WriteFile(secretPath, secretYAML, constants.SecretFilePerm); err != nil { + return fmt.Errorf("writing secret file: %w", err) + } + + logger.Info("Saved TLS secret to: %s", secretPath) + return nil +} + +// deleteNamespace deletes a Kubernetes namespace +func deleteNamespace(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { + clientset := k8sClient.Clientset() + + logger.Debug("Deleting namespace: %s", namespace) + + deletePolicy := metav1.DeletePropagationForeground + err := clientset.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{ + PropagationPolicy: &deletePolicy, + }) + if err != nil { + return fmt.Errorf("deleting namespace %s: %w", namespace, err) + } + + logger.Info("Namespace %s deletion initiated", namespace) + + logger.Debug("Waiting for namespace to be fully deleted...") + return waitForNamespaceDeletion(ctx, k8sClient, namespace, timeout) +} diff --git a/internal/k8s/actions/update_backendmanage.go b/internal/k8s/actions/update_backendmanage.go new file mode 100644 index 0000000..f715983 --- /dev/null +++ b/internal/k8s/actions/update_backendmanage.go @@ -0,0 +1,138 @@ +package actions + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +const ( + UpdateBackendmanageHelp = "Updates an OpenSlides instance's backend." + UpdateBackendmanageHelpExtra = `Updates the backendmanage service deployment image tag and registry to new version. + +Examples: + osmanage k8s update-backendmanage ./my.instance.dir.org --kubeconfig ~/.kube/config --tag 4.2.23 --container-registry myRegistry + osmanage k8s update-backendmanage ./my.instance.dir.org --tag 4.2.23 --container-registry myRegistry --timeout 30s + osmanage k8s update-backendmanage ./my.instance.dir.org --tag 4.2.23 --container-registry myRegistry --revert --timeout 30s` +) + +func UpdateBackendmanageCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update-backendmanage ", + Short: UpdateBackendmanageHelp, + Long: UpdateBackendmanageHelp + "\n\n" + UpdateBackendmanageHelpExtra, + Args: cobra.ExactArgs(1), + } + + tag := cmd.Flags().StringP("tag", "t", "", "Image tag (required)") + containerRegistry := cmd.Flags().String("container-registry", "", "Container registry (required)") + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + revert := cmd.Flags().Bool("revert", false, "Changes image back with given tag and registry") + timeout := cmd.Flags().Duration("timeout", constants.DefaultDeploymentTimeout, "Timeout for deployment rollout check") + + _ = cmd.MarkFlagRequired("tag") + _ = cmd.MarkFlagRequired("container-registry") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(*tag) == "" { + return fmt.Errorf("--tag cannot be empty") + } + if strings.TrimSpace(*containerRegistry) == "" { + return fmt.Errorf("--container-registry cannot be empty") + } + + logger.Info("=== K8S UPDATE/REVERT BACKENDMANAGE ===") + instanceDir := args[0] + namespace := extractNamespace(instanceDir) + + logger.Info("Namespace: %s", namespace) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + if *revert { + if err := revertBackendmanage(ctx, k8sClient, namespace, *tag, *containerRegistry, *timeout); err != nil { + return err + } + + logger.Info("Successfully reverted backendmanage") + } else { + if err := updateBackendmanage(ctx, k8sClient, namespace, *tag, *containerRegistry, *timeout); err != nil { + return err + } + + logger.Info("Successfully updated backendmanage") + } + return nil + } + + return cmd +} + +func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string, timeout time.Duration) error { + image := fmt.Sprintf(constants.BackendmanageImageTemplate, containerRegistry, tag) + + logger.Info("Updating deployment to image: %s", image) + + patch := fmt.Appendf(nil, constants.BackendmanagePatchTemplate, constants.BackendmanageContainerName, image) + + updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( + ctx, + constants.BackendmanageDeploymentName, + types.StrategicMergePatchType, + patch, + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("patching deployment: %w", err) + } + + logger.Info("Patch applied (generation: %d)", updated.Generation) + + logger.Info("Waiting for rollout to complete...") + if err := waitForDeploymentReady(ctx, k8sClient, namespace, constants.BackendmanageDeploymentName, timeout); err != nil { + return fmt.Errorf("rollout failed: %w", err) + } + + return nil +} + +func revertBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string, timeout time.Duration) error { + image := fmt.Sprintf(constants.BackendmanageImageTemplate, containerRegistry, tag) + + logger.Info("Reverting deployment to image: %s", image) + + patch := fmt.Appendf(nil, constants.BackendmanagePatchTemplate, constants.BackendmanageContainerName, image) + + updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( + ctx, + constants.BackendmanageDeploymentName, + types.StrategicMergePatchType, + patch, + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("patching deployment: %w", err) + } + + logger.Info("Patch applied (generation: %d)", updated.Generation) + + logger.Info("Waiting for rollout to complete...") + if err := waitForDeploymentReady(ctx, k8sClient, namespace, constants.BackendmanageDeploymentName, timeout); err != nil { + return fmt.Errorf("rollout failed: %w", err) + } + + return nil +} diff --git a/internal/k8s/actions/update_instance.go b/internal/k8s/actions/update_instance.go new file mode 100644 index 0000000..6ab1bff --- /dev/null +++ b/internal/k8s/actions/update_instance.go @@ -0,0 +1,88 @@ +package actions + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + UpdateInstanceHelp = "Updates an OpenSlides instance." + UpdateInstanceHelpExtra = `Updates the instance by applying new manifest files from the instance directory. + +Examples: + osmanage k8s update-instance ./my.instance.dir.org + osmanage k8s update-instance ./my.instance.dir.org --skip-ready-check + osmanage k8s update-instance ./my.instance.dir.org --kubeconfig ~/.kube/config` +) + +func UpdateInstanceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update-instance ", + Short: UpdateInstanceHelp, + Long: UpdateInstanceHelp + "\n\n" + UpdateInstanceHelpExtra, + Args: cobra.ExactArgs(1), + } + + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for instance to become ready") + timeout := cmd.Flags().Duration("timeout", constants.DefaultInstanceTimeout, "Timeout for instance health check") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S UPDATE INSTANCE ===") + instanceDir := args[0] + + logger.Debug("Instance directory: %s", instanceDir) + + namespace := extractNamespace(instanceDir) + logger.Info("Namespace: %s", namespace) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + isActive, err := namespaceIsActive(ctx, k8sClient, namespace) + if err != nil { + return fmt.Errorf("checking namespace: %w", err) + } + + if !isActive { + logger.Info("%s is not running.", namespace) + logger.Info("The configuration has been updated and the instance will be upgraded upon its next start.") + logger.Info("Note that the next start might take a long time due to pending migrations.") + logger.Info("Consider starting the instance and running migrations now.") + logger.Info("Alternatively, downgrade for now and run migrations in the background once the instance is started.") + return nil + } + + logger.Info("Updating OpenSlides services.") + + stackDir := filepath.Join(instanceDir, constants.StackDirName) + if err := applyDirectory(ctx, k8sClient, stackDir); err != nil { + return fmt.Errorf("applying stack: %w", err) + } + + if *skipReadyCheck { + logger.Info("Skip ready check.") + return nil + } + + logger.Info("Waiting for instance to become ready...") + if err := waitForInstanceHealthy(ctx, k8sClient, namespace, *timeout); err != nil { + return fmt.Errorf("waiting for instance health: %w", err) + } + + logger.Info("Instance updated successfully") + return nil + } + + return cmd +} diff --git a/internal/k8s/client/client.go b/internal/k8s/client/client.go new file mode 100644 index 0000000..c3323a0 --- /dev/null +++ b/internal/k8s/client/client.go @@ -0,0 +1,133 @@ +package client + +import ( + "fmt" + "path/filepath" + "sync" + + "github.com/OpenSlides/openslides-cli/internal/logger" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" +) + +// Client wraps Kubernetes client components with lazy initialization +type Client struct { + clientset *kubernetes.Clientset + config *rest.Config + + dynamicClient dynamic.Interface + dynamicOnce sync.Once + dynamicErr error + + restMapper meta.RESTMapper + mapperOnce sync.Once + mapperErr error +} + +// New creates a Kubernetes client from the given kubeconfig path. +// If kubeconfigPath is empty, attempts to use in-cluster config first, +// then falls back to the default kubeconfig location ($HOME/.kube/config). +func New(kubeconfigPath string) (*Client, error) { + var config *rest.Config + var err error + var source string + + if kubeconfigPath != "" { + // Use provided kubeconfig path + config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig from %s: %w", kubeconfigPath, err) + } + source = fmt.Sprintf("kubeconfig: %s", kubeconfigPath) + } else { + // Try in-cluster config first + config, err = rest.InClusterConfig() + if err == nil { + source = "in-cluster service account" + } else { + // Fall back to default kubeconfig location + kubeconfigPath = getDefaultKubeconfigPath() + if kubeconfigPath == "" { + return nil, fmt.Errorf("failed to get in-cluster config and could not determine home directory") + } + + config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) + if err != nil { + return nil, fmt.Errorf("failed to create k8s config: not running in-cluster and no valid kubeconfig found at %s: %w", kubeconfigPath, err) + } + source = fmt.Sprintf("kubeconfig: %s", kubeconfigPath) + } + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create k8s clientset: %w", err) + } + + logger.Debug("Kubernetes client initialized from %s", source) + + return &Client{ + clientset: clientset, + config: config, + }, nil +} + +// getDefaultKubeconfigPath returns the standard kubeconfig location +func getDefaultKubeconfigPath() string { + home := homedir.HomeDir() + if home == "" { + return "" + } + + return filepath.Join( + home, + clientcmd.RecommendedHomeDir, // ".kube" + clientcmd.RecommendedFileName, // "config" + ) +} + +// Clientset returns the underlying Kubernetes clientset +func (c *Client) Clientset() *kubernetes.Clientset { + return c.clientset +} + +// Config returns the underlying Kubernetes REST config +func (c *Client) Config() *rest.Config { + return c.config +} + +// Dynamic returns a cached dynamic client, creating it on first call. +// The dynamic client is used for working with unstructured Kubernetes resources. +func (c *Client) Dynamic() (dynamic.Interface, error) { + c.dynamicOnce.Do(func() { + c.dynamicClient, c.dynamicErr = dynamic.NewForConfig(c.config) + if c.dynamicErr == nil { + logger.Debug("Dynamic client initialized") + } + }) + return c.dynamicClient, c.dynamicErr +} + +// RESTMapper returns a cached REST mapper, creating it on first call. +// The REST mapper is used to convert between GVK (GroupVersionKind) and +// GVR (GroupVersionResource) for Kubernetes API resources. +func (c *Client) RESTMapper() (meta.RESTMapper, error) { + c.mapperOnce.Do(func() { + apiGroupResources, err := restmapper.GetAPIGroupResources(c.clientset.Discovery()) + if err != nil { + c.mapperErr = fmt.Errorf("getting API group resources: %w", err) + return + } + + c.restMapper = restmapper.NewDiscoveryRESTMapper(apiGroupResources) + logger.Debug("REST mapper initialized") + }) + + return c.restMapper, c.mapperErr +} diff --git a/internal/actions/action/action.go b/internal/manage/actions/action/action.go similarity index 62% rename from internal/actions/action/action.go rename to internal/manage/actions/action/action.go index 1d59617..a7dcc9b 100644 --- a/internal/actions/action/action.go +++ b/internal/manage/actions/action/action.go @@ -4,8 +4,8 @@ import ( "encoding/json" "fmt" - "github.com/OpenSlides/openslides-cli/internal/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" @@ -15,7 +15,23 @@ const ( ActionHelp = "Calls an arbitrary OpenSlides action" ActionHelpExtra = `This command calls an OpenSlides backend action with the given JSON formatted payload. Provide the payload directly or use the --file flag with a -file or use this flag with - to read from stdin.` +file or use this flag with - to read from stdin. + +Examples: + osmanage action meeting.create '[{"name": "Annual Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + osmanage action meeting.create \ + --file create_meeting.json \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + echo '[{"name": "Test Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' | osmanage action meeting.create \ + --file - \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + ` ) func Cmd() *cobra.Command { @@ -26,10 +42,13 @@ func Cmd() *cobra.Command { Args: cobra.RangeArgs(1, 2), } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") payloadFile := cmd.Flags().StringP("file", "f", "", "JSON file with the payload, or - for stdin") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== ACTION ===") diff --git a/internal/actions/createuser/createuser.go b/internal/manage/actions/createuser/createuser.go similarity index 68% rename from internal/actions/createuser/createuser.go rename to internal/manage/actions/createuser/createuser.go index a421fff..c2668c8 100644 --- a/internal/actions/createuser/createuser.go +++ b/internal/manage/actions/createuser/createuser.go @@ -4,8 +4,8 @@ import ( "encoding/json" "fmt" - "github.com/OpenSlides/openslides-cli/internal/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" @@ -15,7 +15,23 @@ const ( CreateUserHelp = "Creates a new user in OpenSlides" CreateUserHelpExtra = `This command creates a new user with the given user data in JSON format. Provide the user data as an argument, or use the --file flag with a file path, -or use --file=- to read from stdin.` +or use --file=- to read from stdin. + +Examples: + osmanage create-user '{"username": "myuser", "default_password": "mypwd"}' \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + osmanage create-user + --file user.json \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + echo '{"username": "myuser", "default_password": "mypwd"}' | osmanage create-user \ + --file - \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password +` ) func Cmd() *cobra.Command { @@ -26,10 +42,13 @@ func Cmd() *cobra.Command { Args: cobra.RangeArgs(0, 1), } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") userFile := cmd.Flags().StringP("file", "f", "", "JSON file with user data, or - for stdin") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== CREATE USER ===") diff --git a/internal/actions/get/get.go b/internal/manage/actions/get/get.go similarity index 86% rename from internal/actions/get/get.go rename to internal/manage/actions/get/get.go index 17656d3..070b723 100644 --- a/internal/actions/get/get.go +++ b/internal/manage/actions/get/get.go @@ -15,6 +15,7 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-go/datastore" @@ -28,10 +29,29 @@ const ( Use options to narrow down output. Examples: - osmanage get user --fields first_name,last_name --filter id=1 - osmanage get user --filter is_active=true - osmanage get meeting --fields name,start_time --filter-raw '{"field":"start_time","operator":">=","value":1609459200}' - osmanage get user --filter-raw '{"and_filter":[{"field":"first_name","operator":"~=","value":"Ad"},{"field":"is_active","operator":"=","value":true}]}' + # Filter by field + osmanage get user --filter is_active=true \ + --postgres-host localhost --postgres-port 5432 \ + --postgres-user openslides --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + + # Select specific fields + osmanage get user --fields first_name,last_name,email \ + --postgres-host localhost --postgres-port 5432 \ + --postgres-user openslides --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + + # Complex filter with operators + osmanage get meeting --filter-raw '{"field":"start_time","operator":">=","value":1609459200}' \ + --postgres-host localhost --postgres-port 5432 \ + --postgres-user openslides --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + + # Combined AND filter + osmanage get user --filter-raw '{"and_filter":[{"field":"first_name","operator":"~=","value":"^Ad"},{"field":"is_active","operator":"=","value":true}]}' \ + --postgres-host localhost --postgres-port 5432 \ + --postgres-user openslides --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password Supported operators in filter-raw: = : Equal @@ -42,6 +62,11 @@ Supported operators in filter-raw: <= : Less than or equal ~= : Regex match (pattern matching) +Supported collections: + - user + - meeting + - organization + Note: Filtering is done in-memory after fetching. Field selection reduces memory usage by only loading requested fields.` ) @@ -64,11 +89,18 @@ func Cmd() *cobra.Command { } // PostgreSQL connection flags - postgresHost := cmd.Flags().String("postgres-host", "localhost", "PostgreSQL host") - postgresPort := cmd.Flags().String("postgres-port", "5432", "PostgreSQL port") - postgresUser := cmd.Flags().String("postgres-user", "instance_user", "PostgreSQL user") - postgresDatabase := cmd.Flags().String("postgres-database", "instance_db", "PostgreSQL database") - postgresPasswordFile := cmd.Flags().String("postgres-password-file", "/secrets/postgres-password", "PostgreSQL password file") + postgresHost := cmd.Flags().String("postgres-host", "", "PostgreSQL host (required)") + postgresPort := cmd.Flags().String("postgres-port", "", "PostgreSQL port (required)") + postgresUser := cmd.Flags().String("postgres-user", "", "PostgreSQL user (required)") + postgresDatabase := cmd.Flags().String("postgres-database", "", "PostgreSQL database (required)") + postgresPasswordFile := cmd.Flags().String("postgres-password-file", "", "PostgreSQL password file (required)") + + // Mark PostgreSQL flags as required + _ = cmd.MarkFlagRequired("postgres-host") + _ = cmd.MarkFlagRequired("postgres-port") + _ = cmd.MarkFlagRequired("postgres-user") + _ = cmd.MarkFlagRequired("postgres-database") + _ = cmd.MarkFlagRequired("postgres-password-file") // Query flags fields := cmd.Flags().StringSlice("fields", nil, "only include the provided fields in output") @@ -102,12 +134,12 @@ func Cmd() *cobra.Command { // Create environment map for datastore connection envMap := map[string]string{ - "DATABASE_HOST": *postgresHost, - "DATABASE_PORT": *postgresPort, - "DATABASE_USER": *postgresUser, - "DATABASE_NAME": *postgresDatabase, - "DATABASE_PASSWORD_FILE": *postgresPasswordFile, - "OPENSLIDES_DEVELOPMENT": "false", + constants.EnvDatabaseHost: *postgresHost, + constants.EnvDatabasePort: *postgresPort, + constants.EnvDatabaseUser: *postgresUser, + constants.EnvDatabaseName: *postgresDatabase, + constants.EnvDatabasePasswordFile: *postgresPasswordFile, + constants.EnvOpenSlidesDevelopment: constants.DevelopmentModeDisabled, } // Initialize datastore flow @@ -163,7 +195,7 @@ func queryUsers(ctx context.Context, fetch *dsfetch.Fetch, filter map[string]str // Get user IDs from organization var userIDs []int - fetch.Organization_UserIDs(1).Lazy(&userIDs) + fetch.Organization_UserIDs(constants.DefaultOrganizationID).Lazy(&userIDs) if err := fetch.Execute(ctx); err != nil { return nil, fmt.Errorf("fetching user IDs: %w", err) } @@ -213,8 +245,8 @@ func queryMeetings(ctx context.Context, fetch *dsfetch.Fetch, filter map[string] // Get active and archived meeting IDs var activeMeetingIDs, archivedMeetingIDs []int - fetch.Organization_ActiveMeetingIDs(1).Lazy(&activeMeetingIDs) - fetch.Organization_ArchivedMeetingIDs(1).Lazy(&archivedMeetingIDs) + fetch.Organization_ActiveMeetingIDs(constants.DefaultOrganizationID).Lazy(&activeMeetingIDs) + fetch.Organization_ArchivedMeetingIDs(constants.DefaultOrganizationID).Lazy(&archivedMeetingIDs) if err := fetch.Execute(ctx); err != nil { return nil, fmt.Errorf("fetching meeting IDs: %w", err) @@ -264,21 +296,22 @@ func queryMeetings(ctx context.Context, fetch *dsfetch.Fetch, filter map[string] func queryOrganization(ctx context.Context, fetch *dsfetch.Fetch, fields []string, existsOnly bool) (any, error) { if existsOnly { var orgID int - fetch.Organization_ID(1).Lazy(&orgID) + fetch.Organization_ID(constants.DefaultOrganizationID).Lazy(&orgID) if err := fetch.Execute(ctx); err != nil { return false, nil } - return orgID == 1, nil + return orgID == constants.DefaultOrganizationID, nil } fieldsToFetch := fields if len(fieldsToFetch) == 0 { - fieldsToFetch = []string{"id", "name"} + // Use default organization fields + fieldsToFetch = strings.Split(constants.DefaultOrganizationFields, ",") } org := make(map[string]any) for _, field := range fieldsToFetch { - value, err := fetchField(fetch, "organization", 1, field) + value, err := fetchField(fetch, "organization", constants.DefaultOrganizationID, field) if err != nil { return nil, fmt.Errorf("fetching organization field %s: %w", field, err) } diff --git a/internal/actions/get/get_test.go b/internal/manage/actions/get/get_test.go similarity index 100% rename from internal/actions/get/get_test.go rename to internal/manage/actions/get/get_test.go diff --git a/internal/actions/initialdata/initialdata.go b/internal/manage/actions/initialdata/initialdata.go similarity index 74% rename from internal/actions/initialdata/initialdata.go rename to internal/manage/actions/initialdata/initialdata.go index 2917dcb..62e2cab 100644 --- a/internal/actions/initialdata/initialdata.go +++ b/internal/manage/actions/initialdata/initialdata.go @@ -5,9 +5,10 @@ import ( "encoding/json" "fmt" "os" + "strings" - "github.com/OpenSlides/openslides-cli/internal/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" @@ -20,7 +21,20 @@ Provide initial data via --file flag with a JSON file path, or use --file=- to r If no file is provided, empty initialization data will be used. This command also sets the superadmin (user 1) password from the superadmin password file. -It returns an error if the datastore is not empty.` +It returns an error if the datastore is not empty. + +Examples: + osmanage initial-data \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/initial_auth_password \ + --superadmin-password-file ./my.instance.dir.org/secrets/superadmin + + osmanage initial-data \ + --file initial.json + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/initial_auth_password \ + --superadmin-password-file ./my.instance.dir.org/secrets/superadmin +` ) func Cmd() *cobra.Command { @@ -31,12 +45,20 @@ func Cmd() *cobra.Command { Args: cobra.NoArgs, } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") - superadminPasswordFile := cmd.Flags().String("superadmin-password-file", "secrets/superadmin", "file with superadmin password") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") + superadminPasswordFile := cmd.Flags().String("superadmin-password-file", "", "file with superadmin password (required)") dataFile := cmd.Flags().StringP("file", "f", "", "JSON file with initial data, or - for stdin") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + _ = cmd.MarkFlagRequired("superadmin-password-file") + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(*superadminPasswordFile) == "" { + return fmt.Errorf("--superadmin-password-file cannot be empty") + } + logger.Info("=== INITIAL DATA ===") var data []byte diff --git a/internal/actions/integration_test.go b/internal/manage/actions/integration_test.go similarity index 100% rename from internal/actions/integration_test.go rename to internal/manage/actions/integration_test.go diff --git a/internal/manage/actions/migrations/migrations.go b/internal/manage/actions/migrations/migrations.go new file mode 100644 index 0000000..aaf5f6b --- /dev/null +++ b/internal/manage/actions/migrations/migrations.go @@ -0,0 +1,351 @@ +package migrations + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/OpenSlides/openslides-cli/internal/constants" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" + "github.com/OpenSlides/openslides-cli/internal/utils" +) + +const ( + MigrationsHelp = "Wrapper to the OpenSlides backend migration tool" + MigrationsHelpExtra = `Run database migrations on the OpenSlides datastore. + +Examples: + # Check migration status + osmanage migrations stats \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Prepare migrations (dry run) + osmanage migrations migrate \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Apply migrations to datastore + osmanage migrations finalize \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Reset unapplied migrations + osmanage migrations reset \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Check progress of running migration + osmanage migrations progress \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Custom progress interval + osmanage migrations finalize \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password \ + --interval 2s + +Available commands: + migrate Prepare migrations (dry run) + finalize Apply migrations to datastore + reset Reset unapplied migrations + clear-collectionfield-tables Clear auxiliary tables (offline only) + stats Show migration statistics + progress Check running migration progress` +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "migrations", + Short: MigrationsHelp, + Long: MigrationsHelp + "\n\n" + MigrationsHelpExtra, + } + + cmd.AddCommand( + migrateCmd(), + finalizeCmd(), + resetCmd(), + clearCollectionfieldTablesCmd(), + statsCmd(), + progressCmd(), + ) + + return cmd +} + +func migrateCmd() *cobra.Command { + return createMigrationCmd("migrate", "Prepare migrations but do not apply them to the datastore", true) +} + +func finalizeCmd() *cobra.Command { + return createMigrationCmd("finalize", "Prepare migrations and apply them to the datastore", true) +} + +func resetCmd() *cobra.Command { + return createMigrationCmd("reset", "Reset unapplied migrations", false) +} + +func clearCollectionfieldTablesCmd() *cobra.Command { + return createMigrationCmd("clear-collectionfield-tables", "Clear all data from auxiliary tables (only when OpenSlides is offline)", false) +} + +func statsCmd() *cobra.Command { + return createMigrationCmd("stats", "Print statistics about the current migration state", false) +} + +func progressCmd() *cobra.Command { + return createMigrationCmd("progress", "Query the progress of a currently running migration command", false) +} + +// createMigrationCmd creates a migration subcommand with standard flags +func createMigrationCmd(name, description string, withProgressTracking bool) *cobra.Command { + cmd := &cobra.Command{ + Use: name, + Short: description, + Args: cobra.NoArgs, + } + + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") + + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + + var progressInterval *time.Duration + if withProgressTracking { + progressInterval = cmd.Flags().Duration("interval", constants.DefaultMigrationProgressInterval, + "interval for progress checks (set 0 to disable progress tracking)") + } + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== MIGRATIONS: %s ===", strings.ToUpper(name)) + + authPassword, err := utils.ReadPassword(*passwordFile) + if err != nil { + return fmt.Errorf("reading password: %w", err) + } + + cl := client.New(*address, authPassword) + + // Execute migration command + response, err := sendMigrationCommand(cl, name) + if err != nil { + return fmt.Errorf("executing migration command: %w", err) + } + + // Handle response based on whether progress tracking is enabled + if withProgressTracking && progressInterval != nil && *progressInterval > 0 && response.Running() { + return trackMigrationProgress(cl, response, *progressInterval, name) + } + + // No progress tracking - just print output + output, err := response.GetOutput(name) + if err != nil { + return fmt.Errorf("formatting output: %w", err) + } + + fmt.Print(output) + return nil + } + + return cmd +} + +// sendMigrationCommand sends a migration command with retry logic +func sendMigrationCommand(cl *client.Client, command string) (*MigrationResponse, error) { + logger.Debug("Sending migration command: %s", command) + + ctx, cancel := context.WithTimeout(context.Background(), constants.MigrationTotalTimeout) + defer cancel() + + var lastErr error + + for attempt := 0; attempt < constants.MigrationMaxRetries; attempt++ { + // Check if context expired + if ctx.Err() != nil { + return nil, fmt.Errorf("migration command timed out after %v: %w", constants.MigrationTotalTimeout, ctx.Err()) + } + + // Wait before retry (except first attempt) + if attempt > 0 { + logger.Warn("Retry attempt %d/%d after %v (previous error: %v)", + attempt, constants.MigrationMaxRetries, constants.MigrationRetryDelay, lastErr) + + select { + case <-time.After(constants.MigrationRetryDelay): + // Continue to next attempt + case <-ctx.Done(): + return nil, fmt.Errorf("migration command cancelled during retry: %w", ctx.Err()) + } + } + + // Send request + resp, err := cl.SendMigrations(command) + if err != nil { + lastErr = fmt.Errorf("sending request: %w", err) + if isRetryableError(err) && attempt < constants.MigrationMaxRetries-1 { + logger.Debug("Retryable error: %v", err) + continue + } + return nil, lastErr + } + + // Check response + body, err := client.CheckResponse(resp) + if err != nil { + lastErr = err + if isRetryableError(err) && attempt < constants.MigrationMaxRetries-1 { + logger.Debug("Retryable error: %v", err) + continue + } + return nil, lastErr + } + + // Parse response + var migrationResp MigrationResponse + if err := json.Unmarshal(body, &migrationResp); err != nil { + logger.Error("Failed to unmarshal migration response: %v", err) + return nil, fmt.Errorf("unmarshalling response: %w", err) + } + + logger.Debug("Migration response - Success: %v, Status: %s, Running: %v", + migrationResp.Success, migrationResp.Status, migrationResp.Running()) + + return &migrationResp, nil + } + + return nil, fmt.Errorf("migration command failed after %d retries: %w", constants.MigrationMaxRetries, lastErr) +} + +// trackMigrationProgress polls migration progress until completion +func trackMigrationProgress(cl *client.Client, initialResponse *MigrationResponse, interval time.Duration, command string) error { + fmt.Println("Progress:") + logger.Debug("Starting progress tracking with interval: %v", interval) + + for { + time.Sleep(interval) + + response, err := sendMigrationCommand(cl, "progress") + if err != nil { + return fmt.Errorf("checking progress: %w", err) + } + + // Print progress output + output, err := response.GetOutput("progress") + if err != nil { + return fmt.Errorf("formatting progress output: %w", err) + } + fmt.Print(output) + + // Check if migration failed + if response.Faulty() { + logger.Error("Migration command failed") + return fmt.Errorf("migration failed: %s", response.Exception) + } + + // Check if migration completed + if !response.Running() { + logger.Info("Migration completed") + break + } + } + + return nil +} + +// isRetryableError determines if an error should trigger a retry +func isRetryableError(err error) bool { + if err == nil { + return false + } + + errStr := strings.ToLower(err.Error()) + + // Network-related errors + retryablePatterns := []string{ + "connection refused", + "connection reset", + "timeout", + "temporary failure", + "no such host", + "network is unreachable", + "eof", + "broken pipe", + "i/o timeout", + } + + for _, pattern := range retryablePatterns { + if strings.Contains(errStr, pattern) { + return true + } + } + + // HTTP server errors (5xx) + serverErrors := []string{"server error", "503", "502", "504"} + for _, code := range serverErrors { + if strings.Contains(errStr, code) { + return true + } + } + + return false +} + +// MigrationResponse represents the response from a migration command +type MigrationResponse struct { + Success bool `json:"success"` + Status string `json:"status"` + Output string `json:"output"` + Exception string `json:"exception"` + Stats json.RawMessage `json:"stats"` +} + +// GetOutput returns the formatted output for the migration response +func (mr *MigrationResponse) GetOutput(command string) (string, error) { + if mr.Faulty() { + return mr.formatAll() + } + if command == "stats" { + return mr.formatStats() + } + return mr.Output, nil +} + +// formatStats formats the stats JSON into a readable string +func (mr *MigrationResponse) formatStats() (string, error) { + var stats map[string]any + if err := json.Unmarshal(mr.Stats, &stats); err != nil { + return "", fmt.Errorf("unmarshalling stats: %w", err) + } + + var sb strings.Builder + for _, field := range constants.MigrationStatsFields { + if value, ok := stats[field]; ok { + sb.WriteString(fmt.Sprintf("%s: %v\n", field, value)) + } + } + + return sb.String(), nil +} + +// formatAll formats all response fields +func (mr *MigrationResponse) formatAll() (string, error) { + return fmt.Sprintf("Success: %v\nStatus: %s\nOutput: %s\nException: %s\n", + mr.Success, mr.Status, mr.Output, mr.Exception), nil +} + +// Faulty returns true if the migration failed +func (mr *MigrationResponse) Faulty() bool { + return !mr.Success || mr.Exception != "" +} + +// Running returns true if the migration is currently in progress +func (mr *MigrationResponse) Running() bool { + return mr.Status == constants.MigrationStatusRunning +} diff --git a/internal/actions/migrations/migrations_test.go b/internal/manage/actions/migrations/migrations_test.go similarity index 92% rename from internal/actions/migrations/migrations_test.go rename to internal/manage/actions/migrations/migrations_test.go index 8f3600b..346822d 100644 --- a/internal/actions/migrations/migrations_test.go +++ b/internal/manage/actions/migrations/migrations_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestMigrationResponse_Faulty(t *testing.T) { @@ -43,7 +45,7 @@ func TestMigrationResponse_Running(t *testing.T) { status string running bool }{ - {"running", "migration_running", true}, + {"running", constants.MigrationStatusRunning, true}, {"completed", "completed", false}, {"failed", "failed", false}, {"empty", "", false}, @@ -72,6 +74,7 @@ func TestMigrationResponse_GetOutput(t *testing.T) { t.Errorf("Expected 'Migration completed', got %s", output) } }) + t.Run("stats command", func(t *testing.T) { stats := map[string]any{ "current_migration_index": 68, @@ -93,6 +96,7 @@ func TestMigrationResponse_GetOutput(t *testing.T) { } // Verify all expected fields are present + // Using subset of MigrationStatsFields for validation expectedFields := []string{ "current_migration_index", "target_migration_index", @@ -106,6 +110,7 @@ func TestMigrationResponse_GetOutput(t *testing.T) { } } }) + t.Run("faulty response", func(t *testing.T) { resp := MigrationResponse{ Success: false, @@ -133,7 +138,7 @@ func TestMigrationResponse_FormatStats(t *testing.T) { "fully_migrated_positions": 0, } statsJSON, _ := json.Marshal(stats) - resp := MigrationResponse{Stats: statsJSON} + resp := &MigrationResponse{Stats: statsJSON} output, err := resp.formatStats() if err != nil { @@ -171,7 +176,7 @@ func TestMigrationResponse_FormatStats(t *testing.T) { "current_migration_index": 70, } statsJSON, _ := json.Marshal(stats) - resp := MigrationResponse{Stats: statsJSON} + resp := &MigrationResponse{Stats: statsJSON} output, err := resp.formatStats() if err != nil { @@ -188,7 +193,7 @@ func TestMigrationResponse_FormatStats(t *testing.T) { }) t.Run("invalid JSON", func(t *testing.T) { - resp := MigrationResponse{Stats: json.RawMessage("invalid json")} + resp := &MigrationResponse{Stats: json.RawMessage("invalid json")} _, err := resp.formatStats() if err == nil { @@ -209,6 +214,8 @@ func TestIsRetryableError(t *testing.T) { {"timeout", "i/o timeout", true}, {"eof", "unexpected EOF", true}, {"server error 503", "server returned 503", true}, + {"server error 502", "bad gateway 502", true}, + {"server error 504", "gateway timeout 504", true}, {"client error 404", "404 not found", false}, {"auth error", "unauthorized", false}, {"parse error", "invalid JSON", false}, diff --git a/internal/actions/set/set.go b/internal/manage/actions/set/set.go similarity index 73% rename from internal/actions/set/set.go rename to internal/manage/actions/set/set.go index ad2425b..fde5945 100644 --- a/internal/actions/set/set.go +++ b/internal/manage/actions/set/set.go @@ -6,8 +6,8 @@ import ( "sort" "strings" - "github.com/OpenSlides/openslides-cli/internal/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" @@ -18,8 +18,22 @@ const ( SetHelpExtra = `This command calls an OpenSlides backend action with the given JSON formatted payload. Provide the payload directly or use the --file flag with a file or use this flag with - to read from stdin. Only the following update actions are -supported: - ` +supported: [agenda_item, committee, group, meeting, motion, organization_tag, organization, projector, theme, topic, user] + +Examples: + osmanage set user '[{"id": 5, "first_name": "Jane", "last_name": "Smith"}]' + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + osmanage set user \ + --file user.json \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + echo '[{"id": 5, "first_name": "Jane", "last_name": "Smith"}]' | osmanage set user \ + --file - \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password` ) var actionMap = map[string]string{ @@ -44,10 +58,13 @@ func Cmd() *cobra.Command { Args: cobra.RangeArgs(1, 2), } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") payloadFile := cmd.Flags().StringP("file", "f", "", "JSON file with the payload, or - for stdin") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== SET ACTION ===") diff --git a/internal/actions/set/set_test.go b/internal/manage/actions/set/set_test.go similarity index 100% rename from internal/actions/set/set_test.go rename to internal/manage/actions/set/set_test.go diff --git a/internal/actions/setpassword/setpassword.go b/internal/manage/actions/setpassword/setpassword.go similarity index 73% rename from internal/actions/setpassword/setpassword.go rename to internal/manage/actions/setpassword/setpassword.go index 0d7a76d..848d97c 100644 --- a/internal/actions/setpassword/setpassword.go +++ b/internal/manage/actions/setpassword/setpassword.go @@ -3,9 +3,10 @@ package setpassword import ( "encoding/json" "fmt" + "strings" - "github.com/OpenSlides/openslides-cli/internal/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" @@ -24,15 +25,24 @@ func Cmd() *cobra.Command { Args: cobra.NoArgs, } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") - userID := cmd.Flags().Int64P("user_id", "u", 0, "ID of the user account") - password := cmd.Flags().StringP("password", "p", "", "new password of the user") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") + password := cmd.Flags().StringP("password", "p", "", "new password of the user (required)") + userID := cmd.Flags().Int64P("user_id", "u", 0, "ID of the user account (required)") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") _ = cmd.MarkFlagRequired("user_id") _ = cmd.MarkFlagRequired("password") cmd.RunE = func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(*password) == "" { + return fmt.Errorf("--password cannot be empty") + } + if *userID == 0 { + return fmt.Errorf("--user_id cannot be empty or less than 1") + } + logger.Info("=== SET PASSWORD ===") logger.Debug("Setting password for user ID: %d", *userID) diff --git a/internal/client/client.go b/internal/manage/client/client.go similarity index 91% rename from internal/client/client.go rename to internal/manage/client/client.go index 5e01427..fd4c13c 100644 --- a/internal/client/client.go +++ b/internal/manage/client/client.go @@ -10,15 +10,10 @@ import ( "strings" "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/logger" ) -const ( - httpScheme = "http://" - handleRequestPath = "/internal/handle_request" - migrationsPath = "/internal/migrations" -) - type Client struct { address string password string @@ -36,7 +31,7 @@ func New(address, password string) *Client { // buildURL constructs the full URL from the client's address and the given path. func (c *Client) buildURL(path string) string { - return httpScheme + c.address + path + return constants.BackendHTTPScheme + c.address + path } // escapeForShell escapes single quotes in a string for safe use in shell commands. @@ -90,7 +85,7 @@ func (c *Client) SendAction(action string, rawData []byte) (*http.Response, erro return nil, fmt.Errorf("marshalling payload: %w", err) } - url := c.buildURL(handleRequestPath) + url := c.buildURL(constants.BackendHandleRequestPath) req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { @@ -99,11 +94,11 @@ func (c *Client) SendAction(action string, rawData []byte) (*http.Response, erro } authHeader := base64.StdEncoding.EncodeToString([]byte(c.password)) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", constants.BackendContentType) req.Header.Set("Authorization", authHeader) logCurlCommand("POST", url, map[string]string{ - "Content-Type": "application/json", + "Content-Type": constants.BackendContentType, "Authorization": authHeader, }, body) @@ -135,7 +130,7 @@ func (c *Client) SendMigrations(command string) (*http.Response, error) { return nil, fmt.Errorf("marshalling payload: %w", err) } - url := c.buildURL(migrationsPath) + url := c.buildURL(constants.BackendMigrationsPath) req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { @@ -144,11 +139,11 @@ func (c *Client) SendMigrations(command string) (*http.Response, error) { } authHeader := base64.StdEncoding.EncodeToString([]byte(c.password)) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", constants.BackendContentType) req.Header.Set("Authorization", authHeader) logCurlCommand("POST", url, map[string]string{ - "Content-Type": "application/json", + "Content-Type": constants.BackendContentType, "Authorization": authHeader, }, body) diff --git a/internal/client/client_test.go b/internal/manage/client/client_test.go similarity index 89% rename from internal/client/client_test.go rename to internal/manage/client/client_test.go index 77ab702..2426c5a 100644 --- a/internal/client/client_test.go +++ b/internal/manage/client/client_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestNew(t *testing.T) { @@ -30,8 +32,8 @@ func TestBuildURL(t *testing.T) { path string want string }{ - {"handle request", handleRequestPath, "http://localhost:9002/internal/handle_request"}, - {"migrations", migrationsPath, "http://localhost:9002/internal/migrations"}, + {"handle request", constants.BackendHandleRequestPath, "http://localhost:9002/internal/handle_request"}, + {"migrations", constants.BackendMigrationsPath, "http://localhost:9002/internal/migrations"}, {"custom path", "/custom", "http://localhost:9002/custom"}, } @@ -49,14 +51,14 @@ func TestSendAction(t *testing.T) { t.Run("successful request", func(t *testing.T) { var receivedAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/internal/handle_request" { - t.Errorf("Expected path /internal/handle_request, got %s", r.URL.Path) + if r.URL.Path != constants.BackendHandleRequestPath { + t.Errorf("Expected path %s, got %s", constants.BackendHandleRequestPath, r.URL.Path) } if r.Method != "POST" { t.Errorf("Expected POST, got %s", r.Method) } - if r.Header.Get("Content-Type") != "application/json" { - t.Error("Expected Content-Type: application/json") + if r.Header.Get("Content-Type") != constants.BackendContentType { + t.Errorf("Expected Content-Type: %s", constants.BackendContentType) } receivedAuth = r.Header.Get("Authorization") @@ -80,7 +82,7 @@ func TestSendAction(t *testing.T) { })) defer server.Close() - address := strings.TrimPrefix(server.URL, "http://") + address := strings.TrimPrefix(server.URL, constants.BackendHTTPScheme) cl := New(address, "test-password") resp, err := cl.SendAction("test.action", []byte(`[{"id":1}]`)) @@ -107,7 +109,7 @@ func TestSendAction(t *testing.T) { })) defer server.Close() - address := strings.TrimPrefix(server.URL, "http://") + address := strings.TrimPrefix(server.URL, constants.BackendHTTPScheme) cl := New(address, "test-password") resp, err := cl.SendAction("test.action", []byte(`[{"id":1}]`)) @@ -151,8 +153,8 @@ func TestSendAction(t *testing.T) { func TestSendMigrations(t *testing.T) { t.Run("successful migrations request", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/internal/migrations" { - t.Errorf("Expected path /internal/migrations, got %s", r.URL.Path) + if r.URL.Path != constants.BackendMigrationsPath { + t.Errorf("Expected path %s, got %s", constants.BackendMigrationsPath, r.URL.Path) } if r.Method != "POST" { t.Errorf("Expected POST, got %s", r.Method) @@ -173,7 +175,7 @@ func TestSendMigrations(t *testing.T) { })) defer server.Close() - address := strings.TrimPrefix(server.URL, "http://") + address := strings.TrimPrefix(server.URL, constants.BackendHTTPScheme) cl := New(address, "test-password") resp, err := cl.SendMigrations("stats") @@ -196,7 +198,7 @@ func TestSendMigrations(t *testing.T) { })) defer server.Close() - address := strings.TrimPrefix(server.URL, "http://") + address := strings.TrimPrefix(server.URL, constants.BackendHTTPScheme) cl := New(address, "test-password") resp, err := cl.SendMigrations("invalid") diff --git a/internal/utils/utils.go b/internal/utils/utils.go index e69ff84..6987575 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -11,11 +11,9 @@ import ( "github.com/OpenSlides/openslides-cli/internal/logger" ) -const fileMode fs.FileMode = 0666 - // CreateFile creates a file in the given directory with the given content. // Use a truthy value for force to override an existing file. -func CreateFile(dir string, force bool, name string, content []byte) error { +func CreateFile(dir string, force bool, name string, content []byte, perm fs.FileMode) error { p := path.Join(dir, name) pExists, err := fileExists(p) @@ -27,7 +25,7 @@ func CreateFile(dir string, force bool, name string, content []byte) error { return nil } - if err := os.WriteFile(p, content, fileMode); err != nil { + if err := os.WriteFile(p, content, perm); err != nil { return fmt.Errorf("creating and writing to file %q: %w", p, err) } return nil diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 8779eff..00769c9 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestReadFromFileOrStdin(t *testing.T) { @@ -118,7 +120,7 @@ func TestCreateFile(t *testing.T) { t.Run("create new file", func(t *testing.T) { content := []byte("test content") - err := CreateFile(tmpdir, false, "test.txt", content) + err := CreateFile(tmpdir, false, "test.txt", content, constants.StackFilePerm) if err != nil { t.Errorf("CreateFile() error = %v", err) } @@ -130,17 +132,26 @@ func TestCreateFile(t *testing.T) { if string(data) != string(content) { t.Errorf("File content = %s, want %s", string(data), string(content)) } + + // Verify permissions + fileInfo, err := os.Stat(filepath.Join(tmpdir, "test.txt")) + if err != nil { + t.Fatalf("failed to stat file: %v", err) + } + if fileInfo.Mode().Perm() != constants.StackFilePerm { + t.Errorf("File permissions = %v, want %v", fileInfo.Mode().Perm(), constants.StackFilePerm) + } }) t.Run("don't overwrite without force", func(t *testing.T) { filename := "existing.txt" original := []byte("original") - if err := CreateFile(tmpdir, false, filename, original); err != nil { + if err := CreateFile(tmpdir, false, filename, original, constants.StackFilePerm); err != nil { t.Fatalf("failed to create initial file: %v", err) } newContent := []byte("new content") - if err := CreateFile(tmpdir, false, filename, newContent); err != nil { + if err := CreateFile(tmpdir, false, filename, newContent, constants.StackFilePerm); err != nil { t.Fatalf("CreateFile() error = %v", err) } @@ -156,12 +167,12 @@ func TestCreateFile(t *testing.T) { t.Run("overwrite with force", func(t *testing.T) { filename := "force.txt" original := []byte("original") - if err := CreateFile(tmpdir, true, filename, original); err != nil { + if err := CreateFile(tmpdir, true, filename, original, constants.StackFilePerm); err != nil { t.Fatalf("failed to create initial file: %v", err) } newContent := []byte("new content") - if err := CreateFile(tmpdir, true, filename, newContent); err != nil { + if err := CreateFile(tmpdir, true, filename, newContent, constants.StackFilePerm); err != nil { t.Fatalf("CreateFile() error = %v", err) } @@ -173,4 +184,54 @@ func TestCreateFile(t *testing.T) { t.Error("File was not overwritten with force flag") } }) + + t.Run("create secret file with secret permissions", func(t *testing.T) { + filename := "secret.txt" + content := []byte("super secret") + err := CreateFile(tmpdir, false, filename, content, constants.SecretFilePerm) + if err != nil { + t.Errorf("CreateFile() error = %v", err) + } + + // Verify permissions + fileInfo, err := os.Stat(filepath.Join(tmpdir, filename)) + if err != nil { + t.Fatalf("failed to stat file: %v", err) + } + if fileInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Secret file permissions = %v, want %v", fileInfo.Mode().Perm(), constants.SecretFilePerm) + } + }) + + t.Run("different permissions for different file types", func(t *testing.T) { + // Create a manifest file with stack permissions + manifestFile := "deployment.yaml" + if err := CreateFile(tmpdir, false, manifestFile, []byte("manifest"), constants.StackFilePerm); err != nil { + t.Fatalf("failed to create manifest file: %v", err) + } + + // Create a secret file with secret permissions + secretFile := "password" + if err := CreateFile(tmpdir, false, secretFile, []byte("secret"), constants.SecretFilePerm); err != nil { + t.Fatalf("failed to create secret file: %v", err) + } + + // Verify manifest permissions (0644) + manifestInfo, err := os.Stat(filepath.Join(tmpdir, manifestFile)) + if err != nil { + t.Fatalf("failed to stat manifest: %v", err) + } + if manifestInfo.Mode().Perm() != constants.StackFilePerm { + t.Errorf("Manifest permissions = %v, want %v", manifestInfo.Mode().Perm(), constants.StackFilePerm) + } + + // Verify secret permissions (0600) + secretInfo, err := os.Stat(filepath.Join(tmpdir, secretFile)) + if err != nil { + t.Fatalf("failed to stat secret: %v", err) + } + if secretInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Secret permissions = %v, want %v", secretInfo.Mode().Perm(), constants.SecretFilePerm) + } + }) }