added build cloud install and uninstall

This commit is contained in:
Chris
2025-08-18 18:47:41 -07:00
parent 20fab37cbe
commit 5570a2fa14
17 changed files with 379 additions and 114 deletions

View File

@@ -4,22 +4,23 @@ module Clusters
class InstallBuildCloud
extend LightService::Action
expects :cluster
expects :build_cloud
promises :build_cloud
executed do |context|
cluster = context.cluster
build_cloud = context.build_cloud
# Check if build cloud is already installed
if cluster.build_cloud.present? && !cluster.build_cloud.uninstalled?
context.fail_and_return!("Build cloud is already installed on this cluster")
if build_cloud.pending? || build_cloud.failed?
build_cloud.installing!
else
build_cloud.updating!
end
if cluster.build_cloud.uninstalled?
cluster.build_cloud.update(error_message: nil, status: :installing)
if build_cloud.uninstalled?
build_cloud.update(error_message: nil, status: :installing)
end
# Create BuildCloud record (namespace will use default from migration)
build_cloud = K8::BuildCloudManager.install_to(cluster)
build_cloud = K8::BuildCloudManager.install(build_cloud)
context.build_cloud = build_cloud
end

View File

@@ -35,6 +35,11 @@ class ProjectForks::ForkProject
if file.nil?
file = client.get_file('.canine.yml.erb', pull_request.branch)
end
if parent_project.build_configuration.present?
new_build_configuration = parent_project.build_configuration.dup
new_build_configuration.project = child_project
new_build_configuration.save!
end
if file.present?
# Parse and store the config

View File

@@ -1,11 +1,52 @@
class Clusters::BuildCloudsController < Clusters::BaseController
include StorageHelper
def show
@build_cloud = @cluster.build_cloud
if @build_cloud.blank?
redirect_to edit_cluster_path(@cluster), alert: "No build cloud found for this cluster"
return
end
render partial: "clusters/build_clouds/show", locals: { cluster: @cluster }
end
def edit
@build_cloud = @cluster.build_cloud
if @build_cloud.blank?
redirect_to edit_cluster_path(@cluster), alert: "No build cloud found for this cluster"
return
end
render partial: "clusters/build_clouds/edit", locals: { cluster: @cluster, build_cloud: @build_cloud }
end
def update
@build_cloud = @cluster.build_cloud
if @build_cloud.blank?
redirect_to edit_cluster_path(@cluster), alert: "No build cloud found for this cluster"
return
end
if @build_cloud.update(build_cloud_params)
Clusters::InstallBuildCloudJob.perform_later(@build_cloud)
render partial: "clusters/build_clouds/show", locals: { cluster: @cluster }
else
render partial: "clusters/build_clouds/edit", locals: { cluster: @cluster, build_cloud: @build_cloud }
end
end
def create
if @cluster.build_cloud.present? && !@cluster.build_cloud.uninstalled?
redirect_to edit_cluster_path(@cluster), alert: "Build cloud is already installed on this cluster"
return
end
Clusters::InstallBuildCloudJob.perform_later(@cluster)
build_cloud = @cluster.create_build_cloud!
Clusters::InstallBuildCloudJob.perform_later(build_cloud)
redirect_to edit_cluster_path(@cluster), notice: "Build cloud installation started. This may take a few minutes..."
end
@@ -25,4 +66,10 @@ class Clusters::BuildCloudsController < Clusters::BaseController
Clusters::DestroyBuildCloudJob.perform_later(@cluster)
redirect_to edit_cluster_path(@cluster), notice: "Build cloud removal started. This may take a few minutes..."
end
private
def build_cloud_params
params.require(:build_cloud).permit(:replicas, :cpu_requests, :cpu_limits, :memory_requests, :memory_limits)
end
end

View File

@@ -2,16 +2,10 @@ module Clusters
class InstallBuildCloudJob < ApplicationJob
queue_as :default
def perform(cluster)
Rails.logger.info("Starting build cloud installation for cluster #{cluster.name}")
result = Clusters::InstallBuildCloud.execute(cluster: cluster)
if result.success?
Rails.logger.info("Successfully installed build cloud for cluster #{cluster.name}")
else
Rails.logger.error("Failed to install build cloud for cluster #{cluster.name}: #{result.message}")
end
def perform(build_cloud)
Clusters::InstallBuildCloud.execute(build_cloud:)
rescue StandardError => e
build_cloud.update(error_message: e.message, status: :failed)
end
end
end

View File

@@ -3,11 +3,16 @@
# Table name: build_clouds
#
# id :bigint not null, primary key
# cpu_limits :bigint default(2000)
# cpu_requests :bigint default(500)
# driver_version :string
# error_message :text
# installation_metadata :jsonb
# installed_at :datetime
# memory_limits :bigint default(4294967296)
# memory_requests :bigint default(536870912)
# namespace :string default("canine-k8s-builder"), not null
# replicas :integer default(2)
# status :integer default("pending"), not null
# webhook_url :string
# created_at :datetime not null
@@ -37,7 +42,8 @@ class BuildCloud < ApplicationRecord
active: 2,
failed: 3,
uninstalling: 4,
uninstalled: 5
uninstalled: 5,
updating: 6
}
# Broadcast updates when the build cloud changes

View File

@@ -1,38 +1,21 @@
class K8::BuildCloudManager
include K8::Kubeconfig
include StorageHelper
BUILDKIT_BUILDER_NAME = 'canine-k8s-builder'
attr_reader :connection, :build_cloud
def self.get_buildkit_version(build_cloud_manager)
# Try to extract version from buildx inspect output
status = runner.call("docker buildx inspect #{K8::BuildCloudManager::BUILDKIT_BUILDER_NAME}")
if status.success?
$1
else
"unknown"
end
rescue StandardError
"unknown"
end
def self.install_to(cluster)
def self.install(build_cloud)
params = {
installation_metadata: {
started_at: Time.current,
builder_name: K8::BuildCloudManager::BUILDKIT_BUILDER_NAME
}
}
build_cloud = if cluster.build_cloud.present?
cluster.build_cloud.update!(params)
cluster.build_cloud
else
cluster.create_build_cloud!(params)
end
begin
# Initialize the K8::BuildCloud service with the build_cloud model
build_cloud_manager = K8::BuildCloudManager.new(cluster, build_cloud)
build_cloud_manager = K8::BuildCloudManager.new(build_cloud.cluster, build_cloud)
# Run the setup
build_cloud_manager.setup!
@@ -43,14 +26,14 @@ class K8::BuildCloudManager
build_cloud.update!(
status: :active,
installed_at: Time.current,
driver_version: get_buildkit_version(build_cloud_manager),
driver_version: build_cloud_manager.get_buildkit_version,
installation_metadata: build_cloud.installation_metadata.merge(
completed_at: Time.current,
builder_ready: true
)
)
Rails.logger.info("Successfully installed build cloud on cluster #{cluster.name}")
Rails.logger.info("Successfully installed build cloud on cluster #{build_cloud.cluster.name}")
else
raise "Builder was created but is not ready"
end
@@ -78,6 +61,19 @@ class K8::BuildCloudManager
@build_cloud = build_cloud
end
def get_buildkit_version
local_runner = Cli::RunAndReturnOutput.new
output = local_runner.call("docker buildx inspect #{K8::BuildCloudManager::BUILDKIT_BUILDER_NAME}")
if output
result = parse_inspect_output(output)
result[:version]
else
"unknown"
end
rescue StandardError
"unknown"
end
def namespace
build_cloud.namespace
end
@@ -138,11 +134,11 @@ class K8::BuildCloudManager
command += "--name #{BUILDKIT_BUILDER_NAME} "
command += "--driver kubernetes "
command += "--driver-opt namespace=#{namespace} "
command += "--driver-opt replicas=2 "
command += "--driver-opt requests.cpu=500m "
command += "--driver-opt requests.memory=512Mi "
command += "--driver-opt limits.cpu=2000m "
command += "--driver-opt limits.memory=4Gi "
command += "--driver-opt replicas=#{build_cloud.replicas} "
command += "--driver-opt requests.cpu=#{integer_to_compute(build_cloud.cpu_requests)} "
command += "--driver-opt requests.memory=#{integer_to_memory(build_cloud.memory_requests)} "
command += "--driver-opt limits.cpu=#{integer_to_compute(build_cloud.cpu_limits)} "
command += "--driver-opt limits.memory=#{integer_to_memory(build_cloud.memory_limits)} "
command += "--use"
runner.call(command, envs: { "KUBECONFIG" => kubeconfig_file.path })
end
@@ -205,4 +201,17 @@ class K8::BuildCloudManager
# This is necessary for the include K8::Kubeconfig module
connection.kubeconfig
end
def parse_inspect_output(text)
version = nil
text.each_line do |line|
if line.start_with?("BuildKit version:")
version = line.split(":", 2)[1].strip
break
end
end
{ "version" => version }.with_indifferent_access
end
end

View File

@@ -0,0 +1,90 @@
<%= turbo_frame_tag dom_id(cluster, "build_cloud") do %>
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title">Edit Build Cloud Configuration</h3>
<%= form_with model: build_cloud, url: cluster_build_cloud_path(cluster), method: :patch do |form| %>
<div class="space-y-4">
<div class="form-control">
<%= form.label :replicas, class: "label" do %>
<span class="label-text font-medium">Replicas</span>
<% end %>
<%= form.number_field :replicas,
class: "input input-bordered w-full",
min: 1, max: 10,
placeholder: "2" %>
<div class="label">
<span class="label-text-alt text-gray-500">Number of builder replicas (1-10)</span>
</div>
</div>
<div class="form-control">
<%= form.label :cpu_requests, class: "label" do %>
<span class="label-text font-medium">CPU Requests (millicores)</span>
<% end %>
<%= form.number_field :cpu_requests,
class: "input input-bordered w-full",
min: 100, step: 100,
placeholder: "500" %>
<div class="label">
<span class="label-text-alt text-gray-500">Current: <%= integer_to_compute(build_cloud.cpu_requests) %></span>
</div>
</div>
<div class="form-control">
<%= form.label :cpu_limits, class: "label" do %>
<span class="label-text font-medium">CPU Limits (millicores)</span>
<% end %>
<%= form.number_field :cpu_limits,
class: "input input-bordered w-full",
min: 100, step: 100,
placeholder: "2000" %>
<div class="label">
<span class="label-text-alt text-gray-500">Current: <%= integer_to_compute(build_cloud.cpu_limits) %></span>
</div>
</div>
<div class="form-control">
<%= form.label :memory_requests, class: "label" do %>
<span class="label-text font-medium">Memory Requests (bytes)</span>
<% end %>
<%= form.number_field :memory_requests,
class: "input input-bordered w-full",
min: 134217728, step: 134217728,
placeholder: "536870912" %>
<div class="label">
<span class="label-text-alt text-gray-500">Current: <%= integer_to_memory(build_cloud.memory_requests) %> (536870912 = 512Mi)</span>
</div>
</div>
<div class="form-control">
<%= form.label :memory_limits, class: "label" do %>
<span class="label-text font-medium">Memory Limits (bytes)</span>
<% end %>
<%= form.number_field :memory_limits,
class: "input input-bordered w-full",
min: 134217728, step: 134217728,
placeholder: "4294967296" %>
<div class="label">
<span class="label-text-alt text-gray-500">Current: <%= integer_to_memory(build_cloud.memory_limits) %> (4294967296 = 4Gi)</span>
</div>
</div>
</div>
<div class="card-actions justify-end mt-6">
<%= link_to cluster_build_cloud_path(cluster),
class: "btn btn-ghost",
data: { turbo_frame: dom_id(cluster, "build_cloud") } do %>
Cancel
<% end %>
<%= form.submit "Save Configuration", class: "btn btn-primary" %>
</div>
<% end %>
<div class="alert alert-warning mt-4">
<iconify-icon icon="lucide:alert-triangle" height="20"></iconify-icon>
<span>Changes will require reinstalling the build cloud to take effect.</span>
</div>
</div>
</div>
<% end %>

View File

@@ -5,7 +5,18 @@
<hr class="mt-3 mb-4 border-t border-base-300" />
<div class="mt-6">
<% if cluster.build_cloud.present? %>
<%= render "clusters/build_clouds/show", cluster: cluster %>
<%= turbo_frame_tag dom_id(cluster, "build_cloud"), src: cluster_build_cloud_path(cluster), loading: "lazy" do %>
<div class="card bg-base-200">
<div class="card-body">
<div class="flex justify-center items-center py-8">
<div class="flex flex-col items-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="mt-4 text-gray-600">Loading build cloud status...</p>
</div>
</div>
</div>
</div>
<% end %>
<% else %>
<%= render "clusters/build_clouds/install", cluster: cluster %>
<% end %>

View File

@@ -1,82 +1,129 @@
<%= turbo_frame_tag dom_id(cluster, "build_cloud") do %>
<% build_cloud = cluster.build_cloud %>
<div class="card bg-base-200">
<div class="card bg-base-200 border border-base-300 shadow-sm">
<div class="card-body">
<h3 class="card-title">Build Cloud Status</h3>
<!-- Header with status badge -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<iconify-icon icon="lucide:cloud" height="24" class="text-primary"></iconify-icon>
<h3 class="card-title text-lg">Build Cloud</h3>
</div>
<%= render "clusters/build_clouds/status", build_cloud: build_cloud %>
</div>
<% if build_cloud.installing? %>
<div class="flex justify-center items-center py-8">
<div class="flex justify-center items-center py-6">
<div class="flex flex-col items-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="mt-4 text-gray-600">Installing build cloud...</p>
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-3 text-sm text-base-content/70">Installing build cloud...</p>
</div>
</div>
<% else %>
<div class="space-y-2">
<div class="flex justify-between">
<span class="font-medium">Status:</span>
<%= render "clusters/build_clouds/status", build_cloud: build_cloud %>
<!-- Main info grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<!-- Left column - Basic info -->
<div class="space-y-2">
<% if build_cloud.namespace.present? %>
<div class="flex items-center gap-2">
<iconify-icon icon="lucide:box" height="16" class="text-base-content/60"></iconify-icon>
<span class="text-base-content/70">Namespace:</span>
<code class="text-sm bg-base-100 px-2 py-1 rounded"><%= build_cloud.namespace %></code>
</div>
<% end %>
<% if build_cloud.driver_version.present? %>
<div class="flex items-center gap-2">
<iconify-icon icon="lucide:git-branch" height="16" class="text-base-content/60"></iconify-icon>
<span class="text-base-content/70">Version:</span>
<span class="font-mono text-sm"><%= build_cloud.driver_version %></span>
</div>
<% end %>
<% if build_cloud.installed_at.present? %>
<div class="flex items-center gap-2">
<iconify-icon icon="lucide:calendar" height="16" class="text-base-content/60"></iconify-icon>
<span class="text-base-content/70">Installed:</span>
<span class="text-sm"><%= time_ago_in_words(build_cloud.installed_at) %> ago</span>
</div>
<% end %>
</div>
<% if build_cloud.namespace.present? %>
<div class="flex justify-between">
<span class="font-medium">Namespace:</span>
<span><%= build_cloud.namespace %></span>
<!-- Right column - Resource configuration -->
<div class="bg-base-100/50 rounded-lg p-3">
<div class="flex items-center gap-2 mb-3">
<iconify-icon icon="lucide:settings" height="16" class="text-base-content/60"></iconify-icon>
<span class="font-medium">Configuration</span>
</div>
<% end %>
<% if build_cloud.driver_version.present? %>
<div class="flex justify-between">
<span class="font-medium">Driver Version:</span>
<span><%= build_cloud.driver_version %></span>
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="flex justify-between">
<span class="text-base-content/70">Replicas:</span>
<span class="font-mono"><%= build_cloud.replicas %></span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">CPU:</span>
<span class="font-mono"><%= integer_to_compute(build_cloud.cpu_requests) %>/<%= integer_to_compute(build_cloud.cpu_limits) %></span>
</div>
<div class="flex justify-between col-span-2">
<span class="text-base-content/70">Memory:</span>
<span class="font-mono"><%= integer_to_memory(build_cloud.memory_requests) %>/<%= integer_to_memory(build_cloud.memory_limits) %></span>
</div>
</div>
<% end %>
<% if build_cloud.installed_at.present? %>
<div class="flex justify-between">
<span class="font-medium">Installed:</span>
<span><%= build_cloud.installed_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
<% end %>
<% if build_cloud.failed? && build_cloud.error_message.present? %>
<div class="alert alert-error mt-4">
<iconify-icon icon="lucide:alert-circle" height="20"></iconify-icon>
<span><%= build_cloud.error_message %></span>
</div>
<% end %>
</div>
</div>
<% if build_cloud.active? %>
<div class="card-actions justify-end mt-4">
<%= button_to cluster_build_cloud_path(cluster),
method: :delete,
class: "btn btn-error btn-sm",
data: { turbo_confirm: "This will remove BuildKit from your cluster. Are you sure?" } do %>
<iconify-icon icon="lucide:trash-2" height="16"></iconify-icon>
Remove Build Cloud
<% end %>
</div>
<% elsif build_cloud.uninstalling? %>
<div class="card-actions justify-end mt-4">
<button class="btn btn-error btn-sm" disabled>
<span class="loading loading-spinner loading-xs"></span>
Removing...
</button>
</div>
<% elsif build_cloud.uninstalled? %>
<div class="card-actions justify-end mt-4">
<%= button_to cluster_build_cloud_path(cluster),
method: :post,
class: "btn btn-primary btn-sm",
data: { turbo_confirm: "This will reinstall BuildKit on your cluster. Continue?" } do %>
<iconify-icon icon="lucide:cloud" height="16"></iconify-icon>
Reinstall Build Cloud
<% end %>
<!-- Error message (if any) -->
<% if build_cloud.failed? && build_cloud.error_message.present? %>
<div class="alert alert-error mb-4">
<iconify-icon icon="lucide:alert-circle" height="16"></iconify-icon>
<span><%= build_cloud.error_message %></span>
</div>
<% end %>
<% end %>
<div class="collapse">
<input aria-label="Accordion radio" type="checkbox" name="accordion" class="w-full">
<div class="collapse-title p-0 underline m-0">Show logs</div>
<div class="collapse-content p-0 mt-2">
<%= render "log_outputs/logs", loggable: build_cloud %>
<!-- Actions -->
<div class="flex items-center justify-between">
<!-- Action buttons -->
<div class="flex gap-2">
<% if build_cloud.active? %>
<%= link_to edit_cluster_build_cloud_path(cluster),
class: "btn btn-outline btn-sm",
data: { turbo_frame: dom_id(cluster, "build_cloud") } do %>
<iconify-icon icon="lucide:settings" height="16"></iconify-icon>
Edit
<% end %>
<%= button_to cluster_build_cloud_path(cluster),
method: :delete,
class: "btn btn-error btn-sm",
data: { turbo_confirm: "Remove BuildKit from your cluster?" } do %>
<iconify-icon icon="lucide:trash-2" height="16"></iconify-icon>
Remove
<% end %>
<% elsif build_cloud.uninstalling? %>
<button class="btn btn-error btn-sm" disabled>
<span class="loading loading-spinner loading-xs"></span>
Removing...
</button>
<% elsif build_cloud.uninstalled? || build_cloud.failed? %>
<%= button_to cluster_build_cloud_path(cluster),
method: :post,
class: "btn btn-primary btn-sm",
data: { turbo_confirm: "Reinstall BuildKit on your cluster?" } do %>
<iconify-icon icon="lucide:refresh-cw" height="16"></iconify-icon>
<%= build_cloud.failed? ? "Retry" : "Reinstall" %>
<% end %>
<% end %>
</div>
</div>
</div>
<!-- Logs section -->
<div class="collapse mt-4">
<input aria-label="Accordion radio" type="checkbox" name="accordion" class="w-full">
<div class="collapse-title p-0 m-0 font-medium py-2 px-0">
<iconify-icon icon="lucide:file-text" height="16"></iconify-icon>
<span class="ml-2">View Logs</span>
</div>
<div class="collapse-content p-0 mt-2">
<%= render "log_outputs/logs", loggable: build_cloud %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>

View File

@@ -12,10 +12,15 @@
"secondary"
when "failed"
"error"
when "updating"
"warning"
end
%>
<div class="text-right">
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<%= build_cloud.status.humanize %>
<% if build_cloud.updating? || build_cloud.installing? %>
<iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
<% end %>
</div>
</div>

View File

@@ -79,7 +79,7 @@ Rails.application.routes.draw do
get :logs
end
resource :metrics, only: [ :show ], module: :clusters
resource :build_cloud, only: [ :create, :destroy ], module: :clusters
resource :build_cloud, only: [ :show, :edit, :update, :create, :destroy ], module: :clusters
member do
post :test_connection
post :retry_install

View File

@@ -9,6 +9,11 @@ class CreateBuildClouds < ActiveRecord::Migration[7.2]
t.jsonb :installation_metadata, default: {}
t.datetime :installed_at
t.text :error_message
t.integer :replicas, default: 2
t.bigint :cpu_requests, default: 500
t.bigint :cpu_limits, default: 2000
t.bigint :memory_requests, default: 536870912 # 512Mi in bytes
t.bigint :memory_limits, default: 4294967296 # 4Gi in bytes
t.timestamps
end

5
db/schema.rb generated
View File

@@ -91,6 +91,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_08_18_215548) do
t.jsonb "installation_metadata", default: {}
t.datetime "installed_at"
t.text "error_message"
t.integer "replicas", default: 2
t.bigint "cpu_requests", default: 500
t.bigint "cpu_limits", default: 2000
t.bigint "memory_requests", default: 536870912
t.bigint "memory_limits", default: 4294967296
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["cluster_id"], name: "index_build_clouds_on_cluster_id"

View File

@@ -69,6 +69,37 @@ RSpec.describe ProjectForks::ForkProject do
end
end
context 'with build configuration' do
let!(:build_configuration) do
create(:build_configuration,
project: parent_project,
driver: :docker)
end
it 'duplicates the build configuration for the child project' do
result = described_class.execute(parent_project:, pull_request:)
expect(result).to be_success
child_project = result.project_fork.child_project
child_build_config = child_project.build_configuration
expect(child_build_config).to be_present
expect(child_build_config.driver).to eq(build_configuration.driver)
expect(child_build_config.project).to eq(child_project)
expect(child_build_config.id).not_to eq(build_configuration.id)
end
end
context 'without build configuration' do
it 'does not create a build configuration for the child project' do
result = described_class.execute(parent_project:, pull_request:)
expect(result).to be_success
child_project = result.project_fork.child_project
expect(child_project.build_configuration).to be_nil
end
end
context 'with canine config file' do
let(:canine_config_content) do
Git::Common::File.new(

View File

@@ -3,11 +3,16 @@
# Table name: build_clouds
#
# id :bigint not null, primary key
# cpu_limits :bigint default(2000)
# cpu_requests :bigint default(500)
# driver_version :string
# error_message :text
# installation_metadata :jsonb
# installed_at :datetime
# memory_limits :bigint default(4294967296)
# memory_requests :bigint default(536870912)
# namespace :string default("canine-k8s-builder"), not null
# replicas :integer default(2)
# status :integer default("pending"), not null
# webhook_url :string
# created_at :datetime not null

View File

@@ -23,6 +23,5 @@ FactoryBot.define do
factory :build_configuration do
project { nil }
driver { 1 }
cluster { nil }
end
end

View File

@@ -3,11 +3,16 @@
# Table name: build_clouds
#
# id :bigint not null, primary key
# cpu_limits :bigint default(2000)
# cpu_requests :bigint default(500)
# driver_version :string
# error_message :text
# installation_metadata :jsonb
# installed_at :datetime
# memory_limits :bigint default(4294967296)
# memory_requests :bigint default(536870912)
# namespace :string default("canine-k8s-builder"), not null
# replicas :integer default(2)
# status :integer default("pending"), not null
# webhook_url :string
# created_at :datetime not null