diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2ba2ba3..03a6e69 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -23,4 +23,4 @@ jobs: uses: golangci/golangci-lint-action@v3 with: # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: v1.56.2 + version: v1.61.0 diff --git a/README.md b/README.md index 590560c..3f8cc4f 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,895 @@ [![License](https://img.shields.io/github/license/massdriver-cloud/airlock)](https://github.com/massdriver-cloud/airlock/blob/master/LICENSE) ## Overview -Generate JSON Schema from various sources (OpenTofu, Helm) + +Translate between JSON Schema and common IaC languages (opentofu, helm, bicep) ## Getting Started ### Prerequisites -- Go version 1.21 + +- Go version 1.22 ### Installation + To install this package, simply run: ```bash go get -u github.com/massdriver-cloud/airlock +``` + +### Usage + +#### OpenTofu + +OpenTofu -> JSON Schema: + +```bash +airlock opentofu input /path/to/module +``` + +
+ Example + +`main.tf`: + +```terraform +provider "aws" { + region = var.region +} + +variable "bucket_name" { + description = "The name of the S3 bucket." + type = string +} + +variable "region" { + description = "The AWS region to create the S3 bucket in." + type = string + default = "us-east-1" +} + +variable "enable_versioning" { + description = "Enable versioning on the S3 bucket." + type = bool + default = false +} + +variable "acl" { + description = "The access control list for the S3 bucket." + type = string + default = "private" +} + +resource "aws_s3_bucket" "example" { + bucket = var.bucket_name + acl = var.acl + + versioning { + enabled = var.enable_versioning + } + + lifecycle_rule { + id = "delete-old-versions" + enabled = true + + noncurrent_version_expiration { + days = 30 + } + } + + tags = { + Name = var.bucket_name + Environment = "Dev" + } +} + +output "bucket_id" { + value = aws_s3_bucket.example.id +} + +output "bucket_arn" { + value = aws_s3_bucket.example.arn +} +``` + +JSON output: + +```json +{ + "properties": { + "bucket_name": { + "type": "string", + "title": "bucket_name", + "description": "The name of the S3 bucket." + }, + "region": { + "type": "string", + "title": "region", + "description": "The AWS region to create the S3 bucket in.", + "default": "us-east-1" + }, + "enable_versioning": { + "type": "boolean", + "title": "enable_versioning", + "description": "Enable versioning on the S3 bucket.", + "default": false + }, + "acl": { + "type": "string", + "title": "acl", + "description": "The access control list for the S3 bucket.", + "default": "private" + } + }, + "required": [ + "acl", + "bucket_name", + "enable_versioning", + "region" + ] +} +``` + +
+ +JSON Schema -> OpenTofu: + +```bash +airlock opentofu output /path/to/schema.json +``` + +
+ Example + +`schema.json`: + +```json +{ + "properties": { + "form": { + "title": "Form", + "type": "object", + "required": [ + "firstName", + "lastName" + ], + "properties": { + "firstName": { + "type": "string", + "title": "First name" + }, + "lastName": { + "type": "string", + "title": "Last name" + }, + "age": { + "type": "integer", + "title": "Age" + }, + "bio": { + "type": "string", + "title": "Bio" + }, + "password": { + "type": "string", + "title": "Password", + "minLength": 3 + }, + "telephone": { + "type": "string", + "title": "Telephone", + "minLength": 10 + } + } + } + } +} +``` + +OpenTofu output: + +```terraform +variable "form" { + type = object({ + firstName = string + lastName = string + age = optional(number) + bio = optional(string) + password = optional(string) + telephone = optional(string) + }) + default = null +} +``` + +
+ +#### Helm + +Helm -> JSON Schema: + +```bash +airlock helm input /path/to/values.yaml +``` + +
+ Example + +`values.yaml`: + +```yaml +name: my-app + +image: + repository: my-app-repo/my-app + tag: latest + pullPolicy: IfNotPresent + +service: + enabled: true + type: ClusterIP + port: 80 + targetPort: 8080 + +ingress: + enabled: false + path: / + hosts: + - host: my-app.local + paths: + - / + tls: [] + +replicaCount: 1 + +resources: + requests: + cpu: "100m" + memory: "256Mi" + limits: + cpu: "500m" + memory: "512Mi" + +env: + - name: DATABASE_URL + value: postgres://user:password@postgres:5432/mydb + - name: APP_SECRET + value: supersecretkey + +logging: + level: info + +persistence: + enabled: false + storageClass: "standard" + accessModes: + - ReadWriteOnce + size: 1Gi + +annotations: {} + +nodeSelector: {} + +tolerations: [] + +affinity: {} +``` + +JSON Schema output: + +```json +{ + "properties": { + "name": { + "type": "string", + "title": "name", + "description": "Application name", + "default": "my-app" + }, + "image": { + "properties": { + "repository": { + "type": "string", + "title": "repository", + "default": "my-app-repo/my-app" + }, + "tag": { + "type": "string", + "title": "tag", + "default": "latest" + }, + "pullPolicy": { + "type": "string", + "title": "pullPolicy", + "default": "IfNotPresent" + } + }, + "type": "object", + "required": [ + "repository", + "tag", + "pullPolicy" + ], + "title": "image", + "description": "Image configuration" + }, + "service": { + "properties": { + "enabled": { + "type": "boolean", + "title": "enabled", + "default": true + }, + "type": { + "type": "string", + "title": "type", + "default": "ClusterIP" + }, + "port": { + "type": "integer", + "title": "port", + "default": 80 + }, + "targetPort": { + "type": "integer", + "title": "targetPort", + "default": 8080 + } + }, + "type": "object", + "required": [ + "enabled", + "type", + "port", + "targetPort" + ], + "title": "service", + "description": "Service configuration" + }, + "ingress": { + "properties": { + "enabled": { + "type": "boolean", + "title": "enabled", + "default": false + }, + "path": { + "type": "string", + "title": "path", + "default": "/" + }, + "hosts": { + "items": { + "properties": { + "host": { + "type": "string", + "title": "host", + "default": "my-app.local" + }, + "paths": { + "items": { + "type": "string", + "default": "/" + }, + "type": "array", + "title": "paths", + "default": [ + "/" + ] + } + }, + "type": "object", + "required": [ + "host", + "paths" + ] + }, + "type": "array", + "title": "hosts", + "default": [ + { + "host": "my-app.local", + "paths": [ + "/" + ] + } + ] + }, + "tls": { + "items": { + "properties": { + "secretName": { + "type": "string", + "title": "secretName", + "default": "my-app-tls" + }, + "hosts": { + "items": { + "type": "string", + "default": "my-app.local" + }, + "type": "array", + "title": "hosts", + "default": [ + "my-app.local" + ] + } + }, + "type": "object", + "required": [ + "secretName", + "hosts" + ] + }, + "type": "array", + "title": "tls", + "default": [ + { + "hosts": [ + "my-app.local" + ], + "secretName": "my-app-tls" + } + ] + } + }, + "type": "object", + "required": [ + "enabled", + "path", + "hosts", + "tls" + ], + "title": "ingress", + "description": "Ingress configuration" + }, + "replicaCount": { + "type": "integer", + "title": "replicaCount", + "description": "Replicas configuration", + "default": 1 + }, + "resources": { + "properties": { + "requests": { + "properties": { + "cpu": { + "type": "string", + "title": "cpu", + "default": "100m" + }, + "memory": { + "type": "string", + "title": "memory", + "default": "256Mi" + } + }, + "type": "object", + "required": [ + "cpu", + "memory" + ], + "title": "requests" + }, + "limits": { + "properties": { + "cpu": { + "type": "string", + "title": "cpu", + "default": "500m" + }, + "memory": { + "type": "string", + "title": "memory", + "default": "512Mi" + } + }, + "type": "object", + "required": [ + "cpu", + "memory" + ], + "title": "limits" + } + }, + "type": "object", + "required": [ + "requests", + "limits" + ], + "title": "resources", + "description": "Resource requests and limits" + }, + "env": { + "items": { + "properties": { + "name": { + "type": "string", + "title": "name", + "default": "DATABASE_URL" + }, + "value": { + "type": "string", + "title": "value", + "default": "postgres://user:password@postgres:5432/mydb" + } + }, + "type": "object", + "required": [ + "name", + "value" + ] + }, + "type": "array", + "title": "env", + "description": "Environment variables", + "default": [ + { + "name": "DATABASE_URL", + "value": "postgres://user:password@postgres:5432/mydb" + }, + { + "name": "APP_SECRET", + "value": "supersecretkey" + } + ] + }, + "logging": { + "properties": { + "level": { + "type": "string", + "title": "level", + "default": "info" + } + }, + "type": "object", + "required": [ + "level" + ], + "title": "logging", + "description": "Logging configuration" + }, + "persistence": { + "properties": { + "enabled": { + "type": "boolean", + "title": "enabled", + "default": false + }, + "storageClass": { + "type": "string", + "title": "storageClass", + "default": "standard" + }, + "accessModes": { + "items": { + "type": "string", + "default": "ReadWriteOnce" + }, + "type": "array", + "title": "accessModes", + "default": [ + "ReadWriteOnce" + ] + }, + "size": { + "type": "string", + "title": "size", + "default": "1Gi" + } + }, + "type": "object", + "required": [ + "enabled", + "storageClass", + "accessModes", + "size" + ], + "title": "persistence", + "description": "Persistent storage configuration" + }, + "annotations": { + "properties": {}, + "type": "object", + "title": "annotations", + "description": "Custom annotations" + }, + "nodeSelector": { + "properties": {}, + "type": "object", + "title": "nodeSelector", + "description": "Node selector" + }, + "tolerations": { + "items": { + "properties": { + "key": { + "type": "string", + "title": "key", + "default": "key1" + }, + "operator": { + "type": "string", + "title": "operator", + "default": "Equal" + }, + "value": { + "type": "string", + "title": "value", + "default": "value1" + }, + "effect": { + "type": "string", + "title": "effect", + "default": "NoSchedule" + } + }, + "type": "object", + "required": [ + "key", + "operator", + "value", + "effect" + ] + }, + "type": "array", + "title": "tolerations", + "description": "Tolerations", + "default": [ + { + "effect": "NoSchedule", + "key": "key1", + "operator": "Equal", + "value": "value1" + }, + { + "effect": "NoExecute", + "key": "key2", + "operator": "Exists" + } + ] + }, + "affinity": { + "properties": {}, + "type": "object", + "title": "affinity", + "description": "Affinity settings" + } + }, + "type": "object", + "required": [ + "name", + "image", + "service", + "ingress", + "replicaCount", + "resources", + "env", + "logging", + "persistence", + "annotations", + "nodeSelector", + "tolerations", + "affinity" + ] +} +``` + +
+ +#### Bicep + +Bicep -> JSON Schema: + +```bash +airlock bicep input /path/to/template.bicep +``` + +
+ Example + +`template.bicep`: + +```bicep +@description('The name of the resource group.') +param resourceGroupName string + +@description('The location where the storage account will be deployed.') +param location string = resourceGroup().location + +@description('The name of the storage account.') +@secure() +param storageAccountName string + +@description('The SKU for the storage account.') +@allowed([ + 'Standard_LRS' + 'Standard_GRS' + 'Standard_RAGRS' + 'Standard_ZRS' + 'Premium_LRS' +]) +param sku string = 'Standard_LRS' + +@description('The kind of storage account.') +@allowed([ + 'StorageV2' + 'Storage' + 'BlobStorage' + 'FileStorage' + 'BlockBlobStorage' +]) +param kind string = 'StorageV2' + +resource storageAccount 'Microsoft.Storage/storageAccounts@2021-09-01' = { + name: storageAccountName + location: location + sku: { + name: sku + } + kind: kind + properties: { + supportsHttpsTrafficOnly: true + } +} + +output storageAccountId string = storageAccount.id +output storageAccountPrimaryEndpoints object = storageAccount.properties.primaryEndpoints +``` + +JSON Schema output: + +```json +{ + "properties": { + "storageAccountName": { + "type": "string", + "format": "password", + "title": "storageAccountName", + "description": "The name of the storage account." + }, + "kind": { + "type": "string", + "enum": [ + "StorageV2", + "Storage", + "BlobStorage", + "FileStorage", + "BlockBlobStorage" + ], + "title": "kind", + "description": "The kind of storage account.", + "default": "StorageV2" + }, + "location": { + "type": "string", + "title": "location", + "description": "The location where the storage account will be deployed.", + "default": "[resourceGroup().location]" + }, + "resourceGroupName": { + "type": "string", + "title": "resourceGroupName", + "description": "The name of the resource group." + }, + "sku": { + "type": "string", + "enum": [ + "Standard_LRS", + "Standard_GRS", + "Standard_RAGRS", + "Standard_ZRS", + "Premium_LRS" + ], + "title": "sku", + "description": "The SKU for the storage account.", + "default": "Standard_LRS" + } + }, + "type": "object", + "required": [ + "kind", + "location", + "resourceGroupName", + "sku", + "storageAccountName" + ] +} +``` + +
+ +JSON Schema -> Bicep: + +```bash +airlock bicep output /path/to/schema.json +``` + +
+ Example + +`schema.json`: + +```json +{ + "properties": { + "firstName": { + "title": "First name", + "type": "string" + }, + "lastName": { + "title": "Last name", + "type": "string" + }, + "phoneNumber": { + "title": "Phone number", + "type": "string", + "minLength": 9, + "maxLength": 12 + }, + "email": { + "title": "Email", + "type": "string", + "minLength": 3 + }, + "age": { + "title": "Age", + "type": "integer", + "minimum": 1 + }, + "ssn": { + "title": "SSN", + "type": "string", + "format": "password", + "minLength": 9, + "maxLength": 9 + }, + "color": { + "title": "Favorite color", + "type": "string", + "enum": [ + "Blue", + "Red", + "Yellow", + "Other" + ] + }, + "active": { + "title": "User is active", + "description": "Is the user currently active?", + "type": "boolean", + "default": false + } + } +} +``` + +Bicep output: + +```bicep +param firstName string +param lastName string +@minLength(9) +@maxLength(12) +param phoneNumber string +@minLength(3) +param email string +@minValue(1) +param age int +@minLength(9) +@maxLength(9) +@secure() +param ssn string +@allowed([ + 'Blue' + 'Red' + 'Yellow' + 'Other' +]) +param color string +@sys.description('Is the user currently active?') +param active bool = false +``` + +
diff --git a/cmd/root.go b/cmd/root.go index 4da84a6..3dfca2f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,11 +12,11 @@ import ( // This has 4 spaces at the beginning to make it look nice in md. It // turns it into a code block which preserves spaces/returns var rootCmdHelp = ` - █████ ██████ ██████ ██ ██████ ██████ ██ ██ -██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ -███████ ██ ██████ ██ ██ ██ ██ █████ -██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ -██ ██ ██████ ██ ██ ██████ ██████ ██████ ██ ██ + ___ ________ __ ____ ________ __ + / | / _/ __ \/ / / __ \/ ____/ //_/ + / /| | / // /_/ / / / / / / / / ,< + / ___ |_/ // _, _/ /___/ /_/ / /___/ /| | + /_/ |_/___/_/ |_/_____/\____/\____/_/ |_| Translate between JSON Schema and common IaC languages ` diff --git a/cmd/version.go b/cmd/version.go index e5842b9..a78f40c 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -21,7 +21,7 @@ func NewCmdVersion() *cobra.Command { func runVersion(cmd *cobra.Command, args []string) { latestVersion, err := version.GetLatestVersion() if err != nil { - fmt.Errorf("could not check for newer version, skipping.\nurl: %s\nerror: %w\n", version.LatestReleaseURL, err) + fmt.Printf("could not check for newer version, skipping.\nurl: %s\nerror: %v\n", version.LatestReleaseURL, err) } isOld, _ := version.CheckForNewerVersionAvailable(latestVersion)