diff --git a/README.md b/README.md index f9e2713..1b4bf10 100644 --- a/README.md +++ b/README.md @@ -472,6 +472,27 @@ services: user: "1000:1000" ``` +### dip validate + +Validates your dip.yml configuration against the JSON schema. The schema validation helps ensure your configuration is correct and follows the expected format. + +```sh +dip validate +``` + +The validator will check: + +- Required properties are present +- Property types are correct +- Values match expected patterns +- No unknown properties are used + +If validation fails, you'll get detailed error messages indicating what needs to be fixed. + +You can skip validation by setting `DIP_SKIP_VALIDATION` environment variable. + +Add `# yaml-language-server: $schema=https://raw.githubusercontent.com/bibendi/dip/refs/heads/master/schema.json` to the top of your dip.yml to get schema validation in VSCode. Read more about [YAML Language Server](https://github.com/redhat-developer/vscode-yaml?tab=readme-ov-file#associating-schemas). + ## Changelog https://github.com/bibendi/dip/releases diff --git a/dip.gemspec b/dip.gemspec index 6c1463f..da257cd 100644 --- a/dip.gemspec +++ b/dip.gemspec @@ -27,7 +27,7 @@ Gem::Specification.new do |spec| # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. - spec.files = Dir.glob("lib/**/*") + Dir.glob("exe/*") + %w[LICENSE.txt README.md] + spec.files = Dir.glob("lib/**/*") + Dir.glob("exe/*") + %w[LICENSE.txt README.md schema.json] spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] @@ -35,6 +35,10 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 2.7" spec.add_dependency "thor", ">= 0.20", "< 2" + spec.add_dependency "json-schema", "~> 5" + # public_suffix >= 6.0 requires Ruby >= 3.0, so we need to specify an upper bound + # to maintain compatibility with Ruby 2.7 + spec.add_dependency "public_suffix", ">= 2.0.2", "< 6.0" spec.add_development_dependency "bundler", ">= 1.15" spec.add_development_dependency "pry-byebug", "~> 3" diff --git a/dip.yml b/dip.yml index 661a1eb..8486cd2 100644 --- a/dip.yml +++ b/dip.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=./schema.json + version: '7' compose: diff --git a/examples/dip.yml b/examples/dip.yml new file mode 100644 index 0000000..37f82d1 --- /dev/null +++ b/examples/dip.yml @@ -0,0 +1,131 @@ +version: '8.1.0' + +environment: + RAILS_ENV: development + NODE_ENV: development + DATABASE_URL: postgres://user:password@db:5432/myapp_development + REDIS_URL: redis://redis:6379/0 + PORT: ${PORT:-3000} + APP_PORT: ${PORT:-3000} + +compose: + files: + - docker-compose.yml + - docker-compose.override.yml + project_name: myapp_project + command: docker compose + +interaction: + rails: + description: Run Rails commands + service: web + command: bundle exec rails + default_args: server -p 3000 -b 0.0.0.0 + environment: + RAILS_LOG_TO_STDOUT: "true" + compose: + method: run + compose_method: up + run_options: + - service-ports + - rm + profiles: + - web + - development + shell: true + entrypoint: /docker-entrypoint.sh + runner: docker_compose + subcommands: + console: + description: Start Rails console + command: console + routes: + description: Show Rails routes + command: routes + db: + description: Database related commands + subcommands: + migrate: + description: Run database migrations + command: db:migrate + seed: + description: Seed the database + command: db:seed + + npm: + description: Run npm commands + service: frontend + command: npm + compose: + method: run + profiles: + - frontend + + psql: + description: Connect to PostgreSQL database + service: db + command: psql -h db -U user myapp_development + compose: + method: run + environment: + PGPASSWORD: password + + rspec: + description: Run RSpec tests + service: web + command: bundle exec rspec + environment: + RAILS_ENV: test + compose: + method: run + run_options: + - rm + profiles: + - test + + shell: + description: Start a shell in the web container + service: web + command: /bin/bash + compose: + method: run + run_options: + - rm + + k8s: + description: Run kubectl commands + command: kubectl + runner: kubectl + entrypoint: kubectl + shell: false + + brakeman: + description: Check brakeman sast + command: docker run another-image ... + + rake: + description: Run Rake tasks + service: web + command: bundle exec rake + +provision: + - dip compose down --volumes + - dip compose build + - dip rails db:create + - dip rails db:migrate + - dip rails db:seed + - dip npm install + - dip validate + +kubectl: + namespace: myapp-development + +modules: + - production + +infra: + redis: + git: https://github.com/mycompany/redis-config.git + ref: main + elasticsearch: + path: ./infra/elasticsearch diff --git a/lib/dip/cli.rb b/lib/dip/cli.rb index 15e66df..c644f1f 100644 --- a/lib/dip/cli.rb +++ b/lib/dip/cli.rb @@ -5,7 +5,7 @@ module Dip class CLI < Thor - TOP_LEVEL_COMMANDS = %w[help version ls compose up stop down run provision ssh infra console].freeze + TOP_LEVEL_COMMANDS = %w[help version ls compose up stop down run provision ssh infra console validate] class << self # Hackery. Take the run method away from Thor so that we can redefine it. @@ -117,6 +117,15 @@ def provision end end + desc "validate", "Validate the dip.yml file against the schema" + def validate + Dip.config.validate + puts "dip.yml is valid" + rescue Dip::Error => e + warn "Validation failed: #{e.message}" + exit 1 + end + require_relative "cli/ssh" desc "ssh", "ssh-agent container commands" subcommand :ssh, Dip::CLI::SSH diff --git a/lib/dip/config.rb b/lib/dip/config.rb index 393af11..3437a94 100644 --- a/lib/dip/config.rb +++ b/lib/dip/config.rb @@ -3,6 +3,7 @@ require "yaml" require "erb" require "pathname" +require "json-schema" require "dip/version" require "dip/ext/hash" @@ -112,6 +113,24 @@ def to_h end end + def validate + raise Dip::Error, "Config file path is not set" if file_path.nil? + raise Dip::Error, "Config file not found: #{file_path}" unless File.exist?(file_path) + + schema_path = File.join(File.dirname(__FILE__), "../../schema.json") + raise Dip::Error, "Schema file not found: #{schema_path}" unless File.exist?(schema_path) + + data = YAML.load_file(file_path) + schema = JSON.parse(File.read(schema_path)) + JSON::Validator.validate!(schema, data) + rescue Psych::SyntaxError => e + raise Dip::Error, "Invalid YAML syntax in config file: #{e.message}" + rescue JSON::Schema::ValidationError => e + data_display = data ? data.to_yaml.gsub("\n", "\n ") : "nil" + error_message = "Schema validation failed: #{e.message}\nInput data:\n #{data_display}" + raise Dip::Error, error_message + end + private attr_reader :work_dir @@ -129,8 +148,8 @@ def config unless Gem::Version.new(Dip::VERSION) >= Gem::Version.new(config.fetch(:version)) raise VersionMismatchError, "Your dip version is `#{Dip::VERSION}`, " \ - "but config requires minimum version `#{config[:version]}`. " \ - "Please upgrade your dip!" + "but config requires minimum version `#{config[:version]}`. " \ + "Please upgrade your dip!" end base_config = {} @@ -155,6 +174,12 @@ def config base_config.deep_merge!(self.class.load_yaml(override_finder.file_path)) if override_finder.exist? @config = CONFIG_DEFAULTS.merge(base_config) + + unless ENV.key?("DIP_SKIP_VALIDATION") + validate + end + + @config end def config_missing_error(config_key) diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..bc3b774 --- /dev/null +++ b/schema.json @@ -0,0 +1,224 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Dip Configuration Schema", + "description": "Schema for the dip.yml configuration file", + "type": "object", + "additionalProperties": false, + "definitions": { + "environment_vars": { + "type": "object", + "description": "Defines environment variables", + "additionalProperties": { + "type": "string" + }, + "examples": [ + { "RAILS_ENV": "development" }, + { "DATABASE_URL": "postgres://user:password@db:5432/myapp_development" }, + { "PORT": "${PORT:-3000}" } + ] + }, + "interaction_command": { + "type": "object", + "description": "Configuration for an interaction command", + "additionalProperties": false, + "properties": { + "description": { + "type": "string", + "description": "Describes the command", + "examples": ["Run Rails commands", "Connect to PostgreSQL database"] + }, + "service": { + "type": "string", + "description": "Specifies the service associated with the command", + "examples": ["web", "frontend", "db"] + }, + "command": { + "type": "string", + "description": "Represents the command to be executed", + "examples": ["bundle exec rails", "npm", "psql -h db -U user myapp_development"] + }, + "default_args": { + "type": "string", + "description": "Default arguments for the command", + "examples": ["server -p 3000 -b 0.0.0.0"] + }, + "environment": { + "$ref": "#/definitions/environment_vars" + }, + "compose": { + "type": "object", + "description": "Allows specifying Docker Compose options", + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "description": "Specifies the Docker Compose method (e.g., up, run)", + "examples": ["run", "up"] + }, + "compose_method": { + "type": "string", + "description": "Specifies an alternative Docker Compose method to use in compose commands", + "examples": ["up"] + }, + "run_options": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Options to pass to the 'docker-compose run' command", + "examples": [["service-ports", "rm"]] + }, + "profiles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Docker Compose profiles to use", + "examples": [["web", "development"], ["frontend"], ["test"]] + } + } + }, + "shell": { + "type": "boolean", + "description": "Enables or disables shell interpolation" + }, + "entrypoint": { + "type": "string", + "description": "Specifies the command entrypoint" + }, + "runner": { + "type": "string", + "description": "Specifies the runner (e.g., docker_compose, kubectl)" + }, + "subcommands": { + "type": "object", + "description": "Contains subcommands with the same structure as main commands", + "patternProperties": { + "^[a-zA-Z0-9_]+$": { + "$ref": "#/definitions/interaction_command" + } + }, + "minProperties": 1, + "additionalProperties": false + } + } + } + }, + "properties": { + "version": { + "type": "string", + "description": "Specifies the minimum required version of Dip", + "examples": ["8.1.0"] + }, + "compose": { + "type": "object", + "description": "Contains Docker Compose configuration", + "properties": { + "files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of strings representing paths to Docker Compose files", + "examples": [["docker-compose.yml", "docker-compose.override.yml"]] + }, + "project_name": { + "type": "string", + "description": "Specifies the project name for Docker Compose", + "examples": ["app"] + }, + "command": { + "type": "string", + "description": "Specifies an alternative Docker Compose command", + "examples": ["docker compose"] + }, + "method": { + "type": "string", + "description": "Specifies the Docker Compose method to use" + } + } + }, + "interaction": { + "type": "object", + "description": "Defines the commands and their configurations", + "patternProperties": { + "^[a-zA-Z0-9_]+$": { + "$ref": "#/definitions/interaction_command" + } + }, + "additionalProperties": false + }, + "provision": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Lists the commands to be executed for provisioning", + "examples": [ + [ + "dip compose down --volumes", + "dip compose build", + "dip rails db:migrate", + "dip npm install" + ] + ] + }, + "environment": { + "$ref": "#/definitions/environment_vars" + }, + "kubectl": { + "type": "object", + "description": "Contains Kubernetes configuration", + "additionalProperties": false, + "properties": { + "namespace": { + "type": "string", + "description": "Specifies the Kubernetes namespace to use", + "examples": ["app"] + } + } + }, + "modules": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Paths to module configuration files", + "examples": [["production"]] + }, + "infra": { + "type": "object", + "description": "Contains infrastructure services configuration", + "additionalProperties": false, + "patternProperties": { + "^[a-zA-Z0-9_]+$": { + "type": "object", + "additionalProperties": false, + "properties": { + "git": { + "type": "string", + "pattern": "^(git@|git://|https?://)[\\w\\d\\.@:\\-/]+$", + "description": "Git repository URL for the infrastructure component", + "examples": ["https://github.com/mycompany/redis-config.git"] + }, + "ref": { + "type": "string", + "description": "Specifies the Git reference (branch, tag, or commit) to use", + "examples": ["main"] + }, + "path": { + "type": "string", + "description": "Local path to the infrastructure component", + "examples": ["./infra/elasticsearch"] + } + }, + "oneOf": [ + { "required": ["git", "ref"] }, + { "required": ["path"] } + ] + } + } + } + }, + "required": ["version", "interaction"] +} diff --git a/spec/fixtures/invalid-with-schema/dip.yml b/spec/fixtures/invalid-with-schema/dip.yml new file mode 100644 index 0000000..e4d998d --- /dev/null +++ b/spec/fixtures/invalid-with-schema/dip.yml @@ -0,0 +1,7 @@ +environment: {} + +compose: {} + +interaction: {} + +provision: [] diff --git a/spec/fixtures/no-schema/dip.yml b/spec/fixtures/no-schema/dip.yml new file mode 100644 index 0000000..4f9c0a7 --- /dev/null +++ b/spec/fixtures/no-schema/dip.yml @@ -0,0 +1,9 @@ +version: '7' + +environment: {} + +compose: {} + +interaction: {} + +provision: [] diff --git a/spec/lib/dip/config_spec.rb b/spec/lib/dip/config_spec.rb index cdda41e..f2c5ac7 100644 --- a/spec/lib/dip/config_spec.rb +++ b/spec/lib/dip/config_spec.rb @@ -97,4 +97,36 @@ ) end end + + describe "#validate" do + context "when schema is valid" do + it "does not raise an error" do + expect { subject.validate }.not_to raise_error + end + end + + context "when schema is invalid", :env do + let(:env) { {"DIP_FILE" => fixture_path("invalid-with-schema/dip.yml")} } + + it "raises a Dip::Error" do + expect { subject.validate }.to raise_error(Dip::Error, /Schema validation failed/) + end + end + + context "when config file is not found", :env do + let(:env) { {"DIP_FILE" => "no.yml"} } + + it "raises a Dip::Error" do + expect { subject.validate }.to raise_error(Dip::Error, /Config file not found/) + end + end + + context "when schema file is not found", :env do + let(:env) { {"DIP_FILE" => fixture_path("no-schema", "dip.yml")} } + + it "does not raise an error" do + expect { subject.validate }.not_to raise_error + end + end + end end