diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2975329 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,77 @@ +# Version control +.git +.gitignore +.gitattributes + +# Python artifacts +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.venv +.venv/ +.python-version +pip-log.txt +pip-delete-this-directory.txt + +# Development and testing +tests/ +.pytest_cache/ +.coverage +.coverage.* +coverage.xml +htmlcov/ +.tox/ +.nox/ +.cache +.mypy_cache/ +.ruff_cache/ +.pyright/ +.pre-commit-config.yaml +.env.example + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Documentation (optional, remove if needed in image) +README.md +docs/README.md + +# CI/CD +.github/ + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Logs +*.log +logs/ + +# Temporary files +.tmp/ +temp/ +*.tmp + +# Node modules (if any) +node_modules/ + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore \ No newline at end of file diff --git a/.github/workflows/create-prod-image.yaml b/.github/workflows/create-prod-image.yaml new file mode 100644 index 0000000..8c56c90 --- /dev/null +++ b/.github/workflows/create-prod-image.yaml @@ -0,0 +1,117 @@ +name: "Production CI/CD - Create Release Image" + +on: + push: + tags: + # Run on every tag matching this pattern (e.g. v1.4.0) + - v[0-9]*.[0-9]*.[0-9]* + - "!v[0-9]*.[0-9]*.[0-9]*-rc.[0-9]*" + +permissions: + contents: read + +jobs: + quality_checks: + uses: ./.github/workflows/ci.yaml + + prepare_metadata: + runs-on: ubuntu-latest + needs: quality_checks + outputs: + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + steps: + - name: Checkout code + uses: actions/checkout@v5.0.0 + with: + ref: ${{ github.ref }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5.8.0 + with: + images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + tags: | + type=ref,event=tag,enable=true + + flavor: | + latest=true + + labels: | + org.opencontainers.image.title=${{ github.event.repository.name }} + org.opencontainers.image.description=${{ github.event.repository.description }} + org.opencontainers.image.url=${{ github.event.repository.html_url }} + org.opencontainers.image.source=${{ github.event.repository.clone_url }} + org.opencontainers.image.version=${{ github.ref_name }} + org.opencontainers.image.revision=${{ github.sha }} + + build_image: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + environment: production + needs: [quality_checks, prepare_metadata] + outputs: + image-url: ghcr.io/${{ github.repository_owner}}/${{ github.repository }}:${{ github.ref_name }} + image-id: ${{ steps.build.outputs.imageid }} + digest: ${{ steps.build.outputs.digest }} + + steps: + - name: Checkout code + uses: actions/checkout@v5.0.0 + with: + ref: ${{ github.ref }} + + - name: Generate env file + run: | + echo "BASE_URL=${{ vars.BASE_URL }}" >> .env + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.11.1 + with: + platforms: linux/amd64 + + - name: Restore Docker cache + uses: actions/cache@v4.2.4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Login to GHCR + uses: docker/login-action@v3.5.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + id: build + uses: docker/build-push-action@v6.18.0 + with: + context: . + push: true + tags: ${{ needs.prepare_metadata.outputs.tags }} + labels: ${{ needs.prepare_metadata.outputs.labels }} + platforms: linux/amd64 + provenance: false + sbom: false + cache-from: | + type=gha + type=local,src=/tmp/.buildx-cache + + cache-to: | + type=gha,mode=max + type=local,dest=/tmp/.buildx-cache-updated,mode=max + + build-args: | + BUILDKIT_INLINE_CACHE=1 + + - name: Update cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-updated /tmp/.buildx-cache diff --git a/.github/workflows/create-staging-image.yaml b/.github/workflows/create-staging-image.yaml new file mode 100644 index 0000000..7f9be0e --- /dev/null +++ b/.github/workflows/create-staging-image.yaml @@ -0,0 +1,117 @@ +name: "Staging CI/CD - Create Release Candidate Image" + +on: + push: + tags: + # Run on every tag matching this pattern (e.g. v1.4.0-rc.1) + - "v[0-9]*.[0-9]*.[0-9]*-rc.[0-9]*" + +permissions: + contents: read + +jobs: + quality_checks: + uses: ./.github/workflows/ci.yaml + + prepare_metadata: + runs-on: ubuntu-latest + needs: quality_checks + outputs: + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + steps: + - name: Checkout code + uses: actions/checkout@v5.0.0 + with: + ref: ${{ github.ref }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5.8.0 + with: + images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + tags: | + type=ref,event=tag,enable=true + type=raw,value=staging-latest + + flavor: | + latest=false + + labels: | + org.opencontainers.image.title=${{ github.event.repository.name }} + org.opencontainers.image.description=${{ github.event.repository.description }} + org.opencontainers.image.url=${{ github.event.repository.html_url }} + org.opencontainers.image.source=${{ github.event.repository.clone_url }} + org.opencontainers.image.version=${{ github.ref_name }} + org.opencontainers.image.revision=${{ github.sha }} + + build_image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + environment: staging + needs: [quality_checks, prepare_metadata] + outputs: + image-url: ghcr.io/${{ github.repository_owner}}/${{ github.repository }}:${{ github.ref_name }} + image-id: ${{ steps.build.outputs.imageid }} + digest: ${{ steps.build.outputs.digest }} + + steps: + - name: Checkout code + uses: actions/checkout@v5.0.0 + with: + ref: ${{ github.ref }} + + - name: Generate env file + run: | + echo "BASE_URL=${{ vars.BASE_URL }}" >> .env + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.11.1 + with: + platforms: linux/amd64 + + - name: Restore Docker cache + uses: actions/cache@v4.2.4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Login to GHCR + uses: docker/login-action@v3.5.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + id: build + uses: docker/build-push-action@v6.18.0 + with: + context: . + push: true + tags: ${{ needs.prepare_metadata.outputs.tags }} + labels: ${{ needs.prepare_metadata.outputs.labels }} + platforms: linux/amd64 + provenance: false + sbom: false + cache-from: | + type=gha + type=local,src=/tmp/.buildx-cache + + cache-to: | + type=gha,mode=max + type=local,dest=/tmp/.buildx-cache-updated,mode=max + + build-args: | + BUILDKIT_INLINE_CACHE=1 + + - name: Update cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-updated /tmp/.buildx-cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..85cab20 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Multi-stage build for database schema spec generator +# Stage 1: Build the schemas +FROM ghcr.io/astral-sh/uv:0.8.13-alpine3.22 AS builder + +# Set working directory +WORKDIR /app + +# Copy dependency files first for better caching +COPY pyproject.toml uv.lock ./ + +# Install dependencies +RUN uv sync --frozen --no-install-project --no-dev + +# Copy source code and input files +COPY main.py ./ +COPY database_schema_spec/ ./database_schema_spec/ +COPY docs/ ./docs/ +COPY .env ./ + +# Generate the schemas +RUN uv run python main.py + +# Stage 2: Final lightweight image with only the output +FROM alpine:3.22 AS final + +# Create output directory +RUN mkdir -p /output + +# Copy generated schemas from builder stage +COPY --from=builder /app/output/ /output/ + +# Optional: Set a default command to list contents (for debugging) +CMD ["ls", "-la", "/output"] diff --git a/README.md b/README.md index a7addd9..b33c00c 100644 --- a/README.md +++ b/README.md @@ -150,5 +150,5 @@ output/ ├── vmap.json └── postgresql/ └── 15.0/ - └── unified_schema.json + └── spec.json ``` diff --git a/database_schema_spec/cli/generator.py b/database_schema_spec/cli/generator.py index f152943..60d8057 100644 --- a/database_schema_spec/cli/generator.py +++ b/database_schema_spec/cli/generator.py @@ -130,6 +130,34 @@ def generate_variant(self, variant: DatabaseVariantSpec) -> Path: base_schema, variant ) + # Inject dynamic $id derived from BASE_URL for the final output + id_field = config.json_schema_fields.id_field + schema_field = config.json_schema_fields.schema_field + spec_url = self.output_manager._get_spec_url( + variant.engine, variant.version, config.base_url + ) + # Set/override $id + unified_schema[id_field] = spec_url + + # Reorder top-level keys to ensure `$id` appears immediately after `$schema` when present + if isinstance(unified_schema, dict): + reordered: dict[str, object] = {} + # If $schema exists, place it first + if schema_field in unified_schema: + reordered[schema_field] = unified_schema[schema_field] + reordered[id_field] = unified_schema[id_field] + for k, v in unified_schema.items(): + if k not in (schema_field, id_field): + reordered[k] = v + unified_schema = reordered # type: ignore[assignment] + else: + # If no $schema, put $id first then the rest in original order + reordered[id_field] = unified_schema[id_field] + for k, v in unified_schema.items(): + if k != id_field: + reordered[k] = v + unified_schema = reordered # type: ignore[assignment] + # Validate the resulting schema validation_result = self.validator.validate_schema(unified_schema) if not validation_result.is_valid: diff --git a/docs/examples/example_1.json b/docs/examples/example_1.json index 9331ea6..b4d84e6 100644 --- a/docs/examples/example_1.json +++ b/docs/examples/example_1.json @@ -6,10 +6,10 @@ "schema": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "E-commerce Platform", + "description": null, "snapshot": { "id": "123e4567-e89b-12d3-a456-426614174000", "label": "v1.0.0", - "description": null, "parent_id": null, "created_at": "2024-01-15T10:30:00" }, diff --git a/docs/examples/example_2.json b/docs/examples/example_2.json index c222546..91a40ae 100644 --- a/docs/examples/example_2.json +++ b/docs/examples/example_2.json @@ -6,10 +6,10 @@ "schema": { "id": "8a2e8f50-b29c-42d4-a817-556644330001", "name": "Inventory Management System", + "description": null, "snapshot": { "id": "987fcdeb-51d2-43a1-b123-456789abcdef", "label": "v2.1.0", - "description": null, "parent_id": "550e8400-e29b-41d4-a716-446655440000", "created_at": "2024-03-20T14:45:00" }, diff --git a/docs/examples/example_3.json b/docs/examples/example_3.json index 70e85fe..88f9ccf 100644 --- a/docs/examples/example_3.json +++ b/docs/examples/example_3.json @@ -6,10 +6,10 @@ "schema": { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "name": "Healthcare Management System", + "description": "Comprehensive healthcare management with patient records and appointments", "snapshot": { "id": "456def78-9abc-45e6-f789-012345678901", "label": "v3.2.1", - "description": "Comprehensive healthcare management with patient records and appointments", "parent_id": "8a2e8f50-b29c-42d4-a817-556644330001", "created_at": "2024-05-15T09:20:00" }, diff --git a/docs/schemas/base/schema.json b/docs/schemas/base/schema.json index 8c8e534..a7ff2ff 100644 --- a/docs/schemas/base/schema.json +++ b/docs/schemas/base/schema.json @@ -20,6 +20,15 @@ "Human Resources Portal" ] }, + "description": { + "type": ["string", "null"], + "description": "Description of the snapshot", + "examples": [ + "Initial schema for the e-commerce platform", + "Schema update to add new tables for inventory tracking", + null + ] + }, "snapshot": { "type": "object", "properties": { @@ -39,15 +48,6 @@ "maxLength": 64, "examples": ["v1.0.0", "v1.1.0", "v2.0.0"] }, - "description": { - "type": ["string", "null"], - "description": "Description of the snapshot", - "examples": [ - "Initial schema setup", - "Added new tables", - "Updated column types" - ] - }, "parent_id": { "type": ["string", "null"], "format": "uuid", @@ -61,7 +61,7 @@ "examples": ["2023-10-01T12:00:00", "2023-10-02T15:30:00"] } }, - "required": ["id", "label", "description", "parent_id", "created_at"], + "required": ["id", "label", "parent_id", "created_at"], "additionalProperties": false }, "tables": { @@ -69,6 +69,6 @@ "description": "List of tables in the schema" } }, - "required": ["id", "name", "snapshot", "tables"], + "required": ["id", "name", "description", "snapshot", "tables"], "additionalProperties": false } diff --git a/docs/schemas/engines/postgresql/v15.0/spec.json b/docs/schemas/engines/postgresql/v15.0/spec.json index f93cf53..e9afd1c 100644 --- a/docs/schemas/engines/postgresql/v15.0/spec.json +++ b/docs/schemas/engines/postgresql/v15.0/spec.json @@ -1,6 +1,5 @@ { "title": "PostgreSQL 15.0 Schema Rules", - "$id": "https://schemas.bfloo.com/postgresql/15.0/spec.json", "properties": { "schema": { "properties": { diff --git a/docs/specs.json b/docs/specs.json index 438f1de..be553fa 100644 --- a/docs/specs.json +++ b/docs/specs.json @@ -1,6 +1,5 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://schemas.bfloo.com/database-schema-spec/v1/specs.json", "title": "Database Schema Specification", "description": "Standardized JSON schema specification for database structure definition and validation. Provides a consistent format for representing database schemas across applications with built-in validation rules", "type": "object", diff --git a/pyproject.toml b/pyproject.toml index 04268c8..ecdcacf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "jsonschema>=4.25.0", "pydantic>=2.11.7", "pydantic-settings>=2.10.1", - "python-dotenv>=1.1.1", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 0b2c531..4110c67 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -99,7 +99,6 @@ dependencies = [ { name = "jsonschema" }, { name = "pydantic" }, { name = "pydantic-settings" }, - { name = "python-dotenv" }, ] [package.dev-dependencies] @@ -117,7 +116,6 @@ requires-dist = [ { name = "jsonschema", specifier = ">=4.25.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, - { name = "python-dotenv", specifier = ">=1.1.1" }, ] [package.metadata.requires-dev]