This commit is contained in:
Chris
2025-11-14 14:23:14 -08:00
12 changed files with 322 additions and 28 deletions

View File

@@ -0,0 +1,86 @@
import { Controller } from "@hotwired/stimulus"
import { EditorView, basicSetup } from "codemirror"
import { EditorState } from "@codemirror/state"
import { yaml } from "@codemirror/lang-yaml"
import { oneDark } from "@codemirror/theme-one-dark"
export default class extends Controller {
static targets = ["file", "content", "filename", "editor"]
connect() {
this.setupEditor()
}
disconnect() {
if (this.editorView) {
this.editorView.destroy()
}
}
setupEditor() {
const initialContent = this.contentTarget.value || ''
// Create the editor state with YAML syntax highlighting, dark theme, and read-only
const state = EditorState.create({
doc: initialContent,
extensions: [
basicSetup,
yaml(),
oneDark,
EditorState.readOnly.of(true),
EditorView.theme({
"&": {
fontSize: "14px",
border: "1px solid #374151",
borderRadius: "0.5rem",
height: "450px"
},
".cm-content": {
padding: "12px"
},
".cm-scroller": {
fontFamily: "'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace",
overflow: "auto"
}
})
]
})
// Create the editor view
this.editorView = new EditorView({
state,
parent: this.editorTarget
})
// Hide the original textarea
this.contentTarget.style.display = 'none'
}
selectFile(event) {
const fileButton = event.currentTarget
const manifestKey = fileButton.dataset.manifestKey
// Update active state
this.fileTargets.forEach(file => {
file.classList.remove("active")
})
fileButton.classList.add("active")
// Update content display
const content = fileButton.dataset.manifestContent
// Update CodeMirror editor
if (this.editorView) {
this.editorView.dispatch({
changes: {
from: 0,
to: this.editorView.state.doc.length,
insert: content
}
})
}
// Update filename display
this.filenameTarget.textContent = manifestKey
}
}

View File

@@ -11,6 +11,9 @@ class Projects::DeploymentJob < ApplicationJob
project = deployment.project project = deployment.project
connection = K8::Connection.new(project, user, allow_anonymous: true) connection = K8::Connection.new(project, user, allow_anonymous: true)
kubectl = create_kubectl(deployment, connection) kubectl = create_kubectl(deployment, connection)
kubectl.register_after_apply do |yaml_content|
deployment.add_manifest(yaml_content)
end
# Create namespace # Create namespace
apply_namespace(project, kubectl) apply_namespace(project, kubectl)

View File

@@ -3,6 +3,7 @@
# Table name: deployments # Table name: deployments
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# manifests :jsonb
# status :integer default("in_progress"), not null # status :integer default("in_progress"), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
@@ -24,4 +25,19 @@ class Deployment < ApplicationRecord
after_update_commit do after_update_commit do
self.build.broadcast_build self.build.broadcast_build
end end
def add_manifest(yaml)
manifest = YAML.safe_load(yaml)
kind = manifest["kind"]&.downcase
name = manifest.dig("metadata", "name")
manifest_key = "#{kind}/#{name}"
self.manifests ||= {}
self.manifests[manifest_key] = yaml
save!
end
def has_manifests?
manifests.keys.any?
end
end end

View File

@@ -11,6 +11,11 @@ class K8::Kubectl
raise "Kubeconfig is required" raise "Kubeconfig is required"
end end
@runner = runner @runner = runner
@after_apply_blocks = []
end
def register_after_apply(&block)
@after_apply_blocks << block
end end
def apply_yaml(yaml_content) def apply_yaml(yaml_content)
@@ -25,6 +30,10 @@ class K8::Kubectl
runner.call(command, envs: { "KUBECONFIG" => kubeconfig_file.path }) runner.call(command, envs: { "KUBECONFIG" => kubeconfig_file.path })
end end
end end
@after_apply_blocks.each do |block|
block.call(yaml_content)
end
end end
def call(command) def call(command)

View File

@@ -7,25 +7,25 @@
<hr class="mt-3 mb-4 border-t border-base-300" /> <hr class="mt-3 mb-4 border-t border-base-300" />
<%= render "edit_form", cluster: @cluster %> <%= render "edit_form", cluster: @cluster %>
<div class="mt-6" data-controller="kubeconfig-editor"> <div class="mt-6" data-controller="content-toggle">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold">Credentials</h2> <h2 class="text-2xl font-bold">Credentials</h2>
<button type="button" <button type="button"
class="btn btn-sm btn-outline" class="btn btn-sm btn-outline"
data-action="click->kubeconfig-editor#toggleEdit" data-action="click->content-toggle#toggleEdit"
data-kubeconfig-editor-target="editButton"> data-content-toggle-target="editButton">
<iconify-icon icon="lucide:pen" height="16"></iconify-icon> <iconify-icon icon="lucide:pen" height="16"></iconify-icon>
Edit Edit
</button> </button>
</div> </div>
<hr class="mt-3 mb-4 border-t border-base-300" /> <hr class="mt-3 mb-4 border-t border-base-300" />
<div data-kubeconfig-editor-target="placeholder" class="p-4 bg-base-200 rounded-lg"> <div data-content-toggle-target="placeholder" class="p-4 bg-base-200 rounded-lg">
<p class="text-sm text-gray-500">Kubeconfig is hidden for security. Click edit to view and modify.</p> <p class="text-sm text-gray-500">Kubeconfig is hidden for security. Click edit to view and modify.</p>
</div> </div>
<div data-kubeconfig-editor-target="editorContainer" class="hidden"> <div data-content-toggle-target="editorContainer" class="hidden">
<%= form_with(model: @cluster, url: cluster_path(@cluster), method: :patch) do |form| %> <%= form_with(model: @cluster, url: cluster_path(@cluster), method: :patch) do |form| %>
<%= form.hidden_field :kubeconfig_yaml_format, value: "true" %> <%= form.hidden_field :kubeconfig_yaml_format, value: "true" %>
<div class="form-group" data-controller="yaml-editor"> <div class="form-group" data-controller="yaml-editor">
@@ -40,9 +40,9 @@
</div> </div>
<div class="form-footer flex gap-2"> <div class="form-footer flex gap-2">
<%= form.submit "Save", class: "btn btn-primary" %> <%= form.submit "Save", class: "btn btn-primary" %>
<button type="button" <button type="button"
class="btn btn-outline" class="btn btn-outline"
data-action="click->kubeconfig-editor#cancelEdit"> data-action="click->content-toggle#cancelEdit">
Cancel Cancel
</button> </button>
</div> </div>

View File

@@ -0,0 +1,45 @@
<div data-controller="manifest-browser" class="grid grid-cols-12 gap-4">
<!-- Left Column: File List -->
<div class="col-span-3 flex flex-col">
<div class="px-4 py-3">
<h3 class="text-sm font-bold text-base-content/70">Files</h3>
</div>
<div class="border border-base-300 rounded-lg overflow-y-auto" style="height: 450px;">
<div class="p-4">
<ul class="menu menu-sm p-0 gap-1">
<% deployment.manifests.keys.each_with_index do |manifest_key, index| %>
<li>
<button
type="button"
data-manifest-browser-target="file"
data-manifest-key="<%= manifest_key %>"
data-manifest-content="<%= ERB::Util.html_escape(deployment.manifests[manifest_key]) %>"
data-action="click->manifest-browser#selectFile"
class="<%= 'active' if index == 0 %>"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span class="truncate"><%= manifest_key %></span>
</button>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<!-- Right Column: Content Display -->
<div class="col-span-9 flex flex-col">
<div class="px-4 py-3">
<h3 class="text-sm font-bold text-base-content/70" data-manifest-browser-target="filename"><%= deployment.manifests.keys.first %></h3>
</div>
<div>
<textarea
data-manifest-browser-target="content"
class="hidden"
><%= deployment.manifests.values.first %></textarea>
<div data-manifest-browser-target="editor" style="height: 450px;"></div>
</div>
</div>
</div>

View File

@@ -23,15 +23,50 @@
</div> </div>
</div> </div>
<hr class="mt-2 mb-6 border-base-content/10" /> <hr class="mt-2 mb-6 border-base-content/10" />
<h2 class="text-xl font-bold mb-4">Build Logs</h2> <h2 class="text-2xl font-bold">Build Logs</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<div class="my-4"> <div class="my-4">
<%= render "log_outputs/logs", loggable: @build %> <%= render "log_outputs/logs", loggable: @build %>
</div> </div>
<% if @build.deployment %> <% if @build.deployment %>
<h2 class="text-xl font-bold mb-4">Release Logs</h2> <div class="mt-6">
<div class="my-4"> <h2 class="text-2xl font-bold">Release Logs</h2>
<%= render "log_outputs/logs", loggable: @build.deployment %> <hr class="mt-3 mb-4 border-t border-base-300" />
<div class="my-4">
<%= render "log_outputs/logs", loggable: @build.deployment %>
</div>
</div>
<% end %>
<% if @build.deployment&.has_manifests? %>
<div class="mt-6" data-controller="content-toggle">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold">Deployment Manifests</h2>
<button type="button"
class="btn btn-sm btn-outline"
data-action="click->content-toggle#toggleEdit"
data-content-toggle-target="editButton">
<iconify-icon icon="lucide:eye" height="16"></iconify-icon>
View
</button>
</div>
<hr class="mt-3 mb-4 border-t border-base-300" />
<div data-content-toggle-target="placeholder" class="p-4 bg-base-200 rounded-lg">
<p class="text-sm text-gray-500">Click view to see the Kubernetes manifests that were deployed.</p>
</div>
<div data-content-toggle-target="editorContainer" class="hidden">
<%= render "projects/deployments/manifest_browser", deployment: @build.deployment %>
<div class="mt-4">
<button type="button"
class="btn btn-outline btn-sm"
data-action="click->content-toggle#cancelEdit">
Close
</button>
</div>
</div>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>

View File

@@ -0,0 +1,5 @@
class AddKubernetesManifestsToDeployments < ActiveRecord::Migration[7.2]
def change
add_column :deployments, :manifests, :jsonb, default: {}
end
end

17
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_11_10_152921) do ActiveRecord::Schema[7.2].define(version: 2025_11_14_025053) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -171,6 +171,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_10_152921) do
t.integer "status", default: 0, null: false t.integer "status", default: 0, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.jsonb "manifests", default: {}
t.index ["build_id"], name: "index_deployments_on_build_id", unique: true t.index ["build_id"], name: "index_deployments_on_build_id", unique: true
end end
@@ -425,6 +426,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_10_152921) do
t.text "postdestroy_command" t.text "postdestroy_command"
t.bigint "project_fork_cluster_id" t.bigint "project_fork_cluster_id"
t.integer "project_fork_status", default: 0 t.integer "project_fork_status", default: 0
t.string "docker_command"
t.index ["cluster_id"], name: "index_projects_on_cluster_id" t.index ["cluster_id"], name: "index_projects_on_cluster_id"
end end
@@ -445,19 +447,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_10_152921) do
t.index ["user_id"], name: "index_providers_on_user_id" t.index ["user_id"], name: "index_providers_on_user_id"
end end
create_table "resource_constraints", force: :cascade do |t|
t.string "constrainable_type", null: false
t.bigint "constrainable_id", null: false
t.bigint "cpu_request"
t.bigint "cpu_limit"
t.bigint "memory_request"
t.bigint "memory_limit"
t.integer "gpu_request"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["constrainable_type", "constrainable_id"], name: "index_resource_constraints_on_constrainable"
end
create_table "services", force: :cascade do |t| create_table "services", force: :cascade do |t|
t.bigint "project_id", null: false t.bigint "project_id", null: false
t.integer "service_type", null: false t.integer "service_type", null: false

View File

@@ -3,6 +3,7 @@
# Table name: deployments # Table name: deployments
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# manifests :jsonb
# status :integer default("in_progress"), not null # status :integer default("in_progress"), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null

View File

@@ -0,0 +1,105 @@
# == Schema Information
#
# Table name: deployments
#
# id :bigint not null, primary key
# manifests :jsonb
# status :integer default("in_progress"), not null
# created_at :datetime not null
# updated_at :datetime not null
# build_id :bigint not null
#
# Indexes
#
# index_deployments_on_build_id (build_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (build_id => builds.id)
#
require 'rails_helper'
RSpec.describe Deployment, type: :model do
let(:build) { create(:build) }
let(:deployment) { create(:deployment, build: build) }
describe '#add_manifest' do
let(:deployment_yaml) do
<<~YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-app
spec:
replicas: 1
YAML
end
let(:service_yaml) do
<<~YAML
apiVersion: v1
kind: Service
metadata:
name: test-service
spec:
ports:
- port: 80
YAML
end
let(:configmap_yaml) do
<<~YAML
apiVersion: v1
kind: ConfigMap
metadata:
name: test-config
data:
key: value
YAML
end
it 'stores manifest with key format kind/name' do
expect(deployment.has_manifests?).to be false
deployment.add_manifest(deployment_yaml)
expect(deployment.manifests['deployment/test-app']).to eq(deployment_yaml)
expect(deployment.has_manifests?).to be true
end
it 'stores multiple manifests with different keys' do
deployment.add_manifest(deployment_yaml)
deployment.add_manifest(service_yaml)
deployment.add_manifest(configmap_yaml)
expect(deployment.manifests['deployment/test-app']).to eq(deployment_yaml)
expect(deployment.manifests['service/test-service']).to eq(service_yaml)
expect(deployment.manifests['configmap/test-config']).to eq(configmap_yaml)
end
it 'overwrites manifest if same kind/name is added again' do
deployment.add_manifest(deployment_yaml)
updated_yaml = <<~YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-app
spec:
replicas: 3
YAML
deployment.add_manifest(updated_yaml)
expect(deployment.manifests['deployment/test-app']).to eq(updated_yaml)
expect(deployment.manifests.keys.length).to eq(1)
end
it 'persists the changes to the database' do
deployment.add_manifest(deployment_yaml)
deployment.reload
expect(deployment.manifests['deployment/test-app']).to eq(deployment_yaml)
end
end
end