diff --git a/compose.yaml b/compose.yaml index ee634c90c6..22d108ac4d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,6 +22,7 @@ services: image: "${OPENC3_REGISTRY}/${OPENC3_NAMESPACE}/openc3-buckets${OPENC3_IMAGE_SUFFIX}:${OPENC3_TAG}" volumes: - "openc3-object-v:/data" + - "openc3-object-versions-v:/versions" - "./cacert.pem:/devel/cacert.pem:z" restart: "unless-stopped" logging: @@ -34,6 +35,7 @@ services: ROOT_SECRET_KEY: "${OPENC3_BUCKET_PASSWORD}" OPENC3_SR_BUCKET_USERNAME: "${OPENC3_SR_BUCKET_USERNAME}" OPENC3_SR_BUCKET_PASSWORD: "${OPENC3_SR_BUCKET_PASSWORD}" + VGW_VERSIONING_DIR: "/versions" SSL_CERT_FILE: "/devel/cacert.pem" CURL_CA_BUNDLE: "/devel/cacert.pem" REQUESTS_CA_BUNDLE: "/devel/cacert.pem" @@ -326,6 +328,7 @@ volumes: openc3-redis-v: {} openc3-redis-ephemeral-v: {} openc3-object-v: {} + openc3-object-versions-v: {} openc3-gems-v: {} openc3-cmd-tlm-api-tmp-v: {} openc3-script-runner-api-tmp-v: {} diff --git a/openc3-buckets/Dockerfile b/openc3-buckets/Dockerfile index a9c60480c0..9eb7f173b6 100644 --- a/openc3-buckets/Dockerfile +++ b/openc3-buckets/Dockerfile @@ -34,9 +34,9 @@ RUN apk update && \ chmod +x /usr/bin/docker-entrypoint.sh && \ addgroup -g ${GROUP_ID} -S ${IMAGE_GROUP} && \ adduser -u ${USER_ID} -G ${IMAGE_GROUP} -s /bin/ash -S ${IMAGE_USER} && \ - mkdir /data && \ - chown -R ${USER_ID}:${IMAGE_GROUP} /data && \ - chmod -R 777 /data + mkdir /data /versions && \ + chown -R ${USER_ID}:${IMAGE_GROUP} /data /versions && \ + chmod -R 777 /data /versions # Switch to user USER ${USER_ID}:${GROUP_ID} diff --git a/openc3-buckets/Dockerfile-ubi b/openc3-buckets/Dockerfile-ubi index dcc47b4131..cc4d0615a3 100644 --- a/openc3-buckets/Dockerfile-ubi +++ b/openc3-buckets/Dockerfile-ubi @@ -31,9 +31,9 @@ RUN microdnf update -y && \ microdnf clean all && \ rm -rf /var/cache/yum && \ chmod +x /usr/bin/docker-entrypoint.sh && \ - mkdir -p /data && \ - chown 1001:1001 /data && \ - chmod -R 777 /data + mkdir -p /data /versions && \ + chown 1001:1001 /data /versions && \ + chmod -R 777 /data /versions USER 1001 EXPOSE 9000 diff --git a/openc3-buckets/docker-entrypoint.sh b/openc3-buckets/docker-entrypoint.sh index 00890013cb..de32696d98 100644 --- a/openc3-buckets/docker-entrypoint.sh +++ b/openc3-buckets/docker-entrypoint.sh @@ -24,6 +24,13 @@ if [ "${1}" = "versitygw" ] || [ "${1#-}" != "$1" ]; then # Create IAM directory if it doesn't exist mkdir -p "${VGW_IAM_DIR}" + # versitygw posix backend requires the versioning dir to live OUTSIDE the + # data root (it errors with "the root directory contains the directory ..." + # otherwise), so this must be a separately mounted path. + if [ -n "${VGW_VERSIONING_DIR}" ]; then + mkdir -p "${VGW_VERSIONING_DIR}" + fi + # Pre-create ScriptRunner user account if credentials are provided and different from root # versitygw expects accounts in a users.json file with accessAccounts structure if [ -n "${OPENC3_SR_BUCKET_USERNAME}" ] && [ -n "${OPENC3_SR_BUCKET_PASSWORD}" ]; then @@ -55,9 +62,15 @@ EOF fi # If no arguments provided, use defaults with IAM enabled - # Note: --iam-dir is a global option that must come BEFORE the backend command + # Note: --iam-dir is a global option that must come BEFORE the backend command; + # --versioning-dir is a posix backend flag, so it goes after "posix" and before + # the data dir argument. if [ $# -eq 0 ]; then - set -- versitygw --iam-dir "${VGW_IAM_DIR}" "${VGW_BACKEND}" "${VGW_BACKEND_ARG}" + if [ "${VGW_BACKEND}" = "posix" ] && [ -n "${VGW_VERSIONING_DIR}" ]; then + set -- versitygw --iam-dir "${VGW_IAM_DIR}" "${VGW_BACKEND}" --versioning-dir "${VGW_VERSIONING_DIR}" "${VGW_BACKEND_ARG}" + else + set -- versitygw --iam-dir "${VGW_IAM_DIR}" "${VGW_BACKEND}" "${VGW_BACKEND_ARG}" + fi else set -- versitygw "$@" fi diff --git a/openc3-cosmos-cmd-tlm-api/app/controllers/info_controller.rb b/openc3-cosmos-cmd-tlm-api/app/controllers/info_controller.rb index c65bcfd710..57a4824faa 100644 --- a/openc3-cosmos-cmd-tlm-api/app/controllers/info_controller.rb +++ b/openc3-cosmos-cmd-tlm-api/app/controllers/info_controller.rb @@ -17,7 +17,12 @@ rescue LoadError class InfoController < ApplicationController def info - render json: { version: OPENC3_VERSION, license: 'OpenC3', enterprise: false } + render json: { + version: OPENC3_VERSION, + license: 'OpenC3', + enterprise: false, + local_mode: !ENV['OPENC3_LOCAL_MODE'].to_s.empty? + } end end end diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue index 686ecfd209..c4e7b23c02 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue @@ -117,7 +117,7 @@ mdi-connection -
+
- Reload File - Back to New Script + Reload File
+ { + this.showVersionHistory = true + }, + }, ], }, ] @@ -1312,6 +1336,14 @@ export default { this.updateOverridesCount() + Api.get('/openc3-api/info') + .then((response) => { + this.localMode = !!response.data?.local_mode + }) + .catch(() => { + this.localMode = false + }) + // Make NEW_FILENAME available to the template this.NEW_FILENAME = NEW_FILENAME @@ -3074,6 +3106,9 @@ class TestSuite(Suite): return Api.post(`/script-api/scripts/${this.filename}/lock`) } }, + onVersionRestored: function () { + this.reloadFile() + }, unlockFile: function () { if ( this.filename !== NEW_FILENAME && diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptVersionHistoryDialog.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptVersionHistoryDialog.vue new file mode 100644 index 0000000000..69a0194f9b --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptVersionHistoryDialog.vue @@ -0,0 +1,453 @@ + + + + + + + diff --git a/openc3-cosmos-script-runner-api/app/controllers/scripts_controller.rb b/openc3-cosmos-script-runner-api/app/controllers/scripts_controller.rb index 089f1cbd7b..19a704ef4e 100644 --- a/openc3-cosmos-script-runner-api/app/controllers/scripts_controller.rb +++ b/openc3-cosmos-script-runner-api/app/controllers/scripts_controller.rb @@ -88,8 +88,10 @@ def create args = params.permit(:text, breakpoints: []) args[:scope] = scope args[:name] = name - Script.create(args) + args[:username] = username() + write_result = Script.create(args) results = {} + results['version_id'] = write_result if write_result.is_a?(String) if ((File.extname(name) == '.py') and (params[:text] =~ PYTHON_SUITE_REGEX)) or ((File.extname(name) != '.py') and (params[:text] =~ SUITE_REGEX)) results_suites, results_error, success = Script.process_suite(name, params[:text], username: username(), scope: scope) results['suites'] = results_suites @@ -205,4 +207,109 @@ def delete_all_breakpoints OpenC3::Store.del("#{scope}__script-breakpoints") head :ok end + + # GET /scripts/*name/versions — list S3 versions for this script body, + # newest-first. Each entry carries version_id, size, last_modified, is_latest, + # and etag. + def versions + return unless authorization('script_view') + scope, name = sanitize_params([:scope, :name], :allow_forward_slash => true) + return unless scope + bucket_name = ENV.fetch('OPENC3_CONFIG_BUCKET', 'config') + key = "#{scope}/targets_modified/#{name}" + bucket = OpenC3::Bucket.getClient() + s3 = bucket.list_object_versions(bucket: bucket_name, prefix: key) + versions = s3[:versions].select { |v| v.key == key }.map do |v| + { + version_id: v.version_id, + size: v.size, + last_modified: v.last_modified, + is_latest: v.is_latest, + etag: v.etag + } + end + delete_markers = s3[:delete_markers].select { |dm| dm.key == key }.map do |dm| + { version_id: dm.version_id, last_modified: dm.last_modified, is_latest: dm.is_latest, deleted: true } + end + render json: { versions: versions, delete_markers: delete_markers } + rescue => e + log_error(e) + render json: { status: 'error', message: e.message }, status: :internal_server_error + end + + # GET /scripts/*name/version?version_id=... — return body of a specific version. + def version_body + return unless authorization('script_view') + scope, name = sanitize_params([:scope, :name], :allow_forward_slash => true) + return unless scope + version_id = params[:version_id] + if version_id.nil? || version_id.empty? + render json: { status: 'error', message: 'version_id required' }, status: :bad_request + return + end + bucket = OpenC3::Bucket.getClient() + begin + resp = bucket.get_object( + bucket: ENV.fetch('OPENC3_CONFIG_BUCKET', 'config'), + key: "#{scope}/targets_modified/#{name}", + version_id: version_id + ) + rescue Aws::Errors::ServiceError => e + # Backends (real AWS S3, etc.) reject lookups with versionIds that + # aren't valid for the bucket — for example null-version markers + # returned by list_object_versions on a bucket whose versioning was + # only just enabled. Surface as 404 rather than 500. + OpenC3::Logger.warn("get_object(version_id=#{version_id}) failed for #{scope}/#{name}: #{e.message}", scope: scope) + render json: { status: 'error', message: "Version unavailable: #{e.message}" }, status: :not_found + return + end + if resp && resp.body + body = File.extname(name) == '.bin' ? (resp.body.binmode; resp.body.read) : resp.body.read.force_encoding('UTF-8') + render plain: body + else + head :not_found + end + rescue => e + log_error(e) + render json: { status: 'error', message: e.message }, status: :internal_server_error + end + + # POST /scripts/*name/restore body {version_id} — re-PUT the body of an + # older version as a new current version. Caller must hold the edit lock + # the same way a normal save does. + def restore + return unless authorization('script_edit') + scope, name = sanitize_params([:scope, :name], :allow_forward_slash => true) + return unless scope + version_id = params[:version_id] + if version_id.nil? || version_id.empty? + render json: { status: 'error', message: 'version_id required' }, status: :bad_request + return + end + + bucket = OpenC3::Bucket.getClient() + bucket_name = ENV.fetch('OPENC3_CONFIG_BUCKET', 'config') + key = "#{scope}/targets_modified/#{name}" + begin + src = bucket.get_object(bucket: bucket_name, key: key, version_id: version_id) + rescue Aws::Errors::ServiceError => e + OpenC3::Logger.warn("restore get_object(version_id=#{version_id}) failed for #{scope}/#{name}: #{e.message}", scope: scope) + render json: { status: 'error', message: "Version unavailable: #{e.message}" }, status: :not_found + return + end + if src.nil? || src.body.nil? + head :not_found + return + end + body = src.body.read + + write_result = OpenC3::TargetFile.create(scope, name, body, username: username()) + new_version_id = write_result.is_a?(String) ? write_result : nil + + OpenC3::Logger.info("Script restored: #{name} from #{version_id}", scope: scope, user: username()) + render json: { version_id: new_version_id, restored_from_version_id: version_id } + rescue => e + log_error(e) + render json: { status: 'error', message: e.message }, status: :internal_server_error + end end diff --git a/openc3-cosmos-script-runner-api/config/routes.rb b/openc3-cosmos-script-runner-api/config/routes.rb index 6ea164b263..03c9384913 100644 --- a/openc3-cosmos-script-runner-api/config/routes.rb +++ b/openc3-cosmos-script-runner-api/config/routes.rb @@ -21,6 +21,13 @@ get "/ping" => "scripts#ping" get "/scripts" => "scripts#index" delete "/scripts/temp_files" => "scripts#delete_temp" + # Specific GET routes must come BEFORE the catchall body route, since + # Rails routes match in declaration order and *name is greedy — without + # this ordering a request to /scripts/foo.rb/versions matches body with + # name="foo.rb/versions". + get "/scripts/*name/versions" => "scripts#versions", format: false, defaults: { format: 'html' } + get "/scripts/*name/version" => "scripts#version_body", format: false, defaults: { format: 'html' } + # Catchall body route — must be last among GETs. get "/scripts/*name" => "scripts#body", format: false, defaults: { format: 'html' } post "/scripts/*name/run(/:disconnect)" => "scripts#run", format: false, defaults: { format: 'html' } post "/scripts/*name/delete" => "scripts#destroy", format: false, defaults: { format: 'html' } @@ -29,6 +36,7 @@ post "/scripts/*name/syntax" => "scripts#syntax" post "/scripts/*name/mnemonics" => "scripts#mnemonics" post "/scripts/*name/instrumented" => "scripts#instrumented" + post "/scripts/*name/restore" => "scripts#restore", format: false, defaults: { format: 'html' } # Must be last so /run, /delete, etc will match first post "/scripts/*name" => "scripts#create", format: false, defaults: { format: 'html' } diff --git a/openc3-cosmos-script-runner-api/spec/controllers/scripts_controller_spec.rb b/openc3-cosmos-script-runner-api/spec/controllers/scripts_controller_spec.rb index 4f1c2275a4..88091a1df6 100644 --- a/openc3-cosmos-script-runner-api/spec/controllers/scripts_controller_spec.rb +++ b/openc3-cosmos-script-runner-api/spec/controllers/scripts_controller_spec.rb @@ -128,8 +128,9 @@ it "does not pass params which aren't permitted" do expect(Script).to receive(:create) do |params| - # Check that we don't pass extra params - expect(params.keys).to eql(%w[text breakpoints scope name]) + # Check that we don't pass extra params (username is added by the + # controller from the auth header, not from the request body) + expect(params.keys).to eql(%w[text breakpoints scope name username]) end post :create, params: {scope: "DEFAULT", name: "script.rb", text: "text", breakpoints: [1], other: "nope"} expect(response).to have_http_status(:ok) diff --git a/openc3-cosmos-script-runner-api/spec/models/script_spec.rb b/openc3-cosmos-script-runner-api/spec/models/script_spec.rb index b23b4bf54f..f810e97882 100644 --- a/openc3-cosmos-script-runner-api/spec/models/script_spec.rb +++ b/openc3-cosmos-script-runner-api/spec/models/script_spec.rb @@ -99,7 +99,7 @@ } allow(Script).to receive(:body).and_return(nil) - expect(OpenC3::TargetFile).to receive(:create).with("DEFAULT", "new_script.rb", "puts 'Hello'") + expect(OpenC3::TargetFile).to receive(:create).with("DEFAULT", "new_script.rb", "puts 'Hello'", username: nil) Script.create(params) @@ -117,7 +117,7 @@ } allow(Script).to receive(:body).and_return("puts 'Original'") - expect(OpenC3::TargetFile).to receive(:create).with("DEFAULT", "existing_script.rb", "puts 'Updated'") + expect(OpenC3::TargetFile).to receive(:create).with("DEFAULT", "existing_script.rb", "puts 'Updated'", username: nil) Script.create(params) @@ -153,7 +153,7 @@ } allow(Script).to receive(:body).and_return(nil) - expect(OpenC3::TargetFile).to receive(:create).with("DEFAULT", "script.rb", "puts 'Hello'") + expect(OpenC3::TargetFile).to receive(:create).with("DEFAULT", "script.rb", "puts 'Hello'", username: nil) # Pre-store breakpoints OpenC3::Store.hset("DEFAULT__script-breakpoints", "script.rb", [5, 10].to_json) diff --git a/openc3/bin/openc3cli b/openc3/bin/openc3cli index 59bb3b5143..165b1b52c8 100755 --- a/openc3/bin/openc3cli +++ b/openc3/bin/openc3cli @@ -1443,6 +1443,9 @@ if not ARGV[0].nil? # argument(s) given end # Always ensure the scriptrunner policy is in place since it is required for script execution client.ensure_scriptrunner_policy(ENV['OPENC3_CONFIG_BUCKET'], ENV['OPENC3_LOGS_BUCKET']) + # Enable versioning on the config bucket so script edits, plugin uploads, + # and other config writes accumulate version history. Idempotent. + client.ensure_versioning_enabled(ENV['OPENC3_CONFIG_BUCKET']) if ENV['OPENC3_CONFIG_BUCKET'] when 'runmigrations' if ARGV[1] == '--help' || ARGV[1] == '-h' diff --git a/openc3/lib/openc3/utilities/aws_bucket.rb b/openc3/lib/openc3/utilities/aws_bucket.rb index cccf6a59ae..f8076027b4 100644 --- a/openc3/lib/openc3/utilities/aws_bucket.rb +++ b/openc3/lib/openc3/utilities/aws_bucket.rb @@ -115,6 +115,7 @@ def ensure_scriptrunner_policy(config_bucket, logs_bucket) "Principal": ["#{sr_username}"], "Action": [ "s3:ListBucket", + "s3:ListBucketVersions", "s3:GetBucketLocation" ], "Resource": "#{@aws_arn}:s3:::#{config_bucket}" @@ -123,7 +124,10 @@ def ensure_scriptrunner_policy(config_bucket, logs_bucket) "Sid": "ScriptRunnerReadTargets", "Effect": "Allow", "Principal": ["#{sr_username}"], - "Action": "s3:GetObject", + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion" + ], "Resource": "#{@aws_arn}:s3:::#{config_bucket}/*" }, { @@ -191,6 +195,18 @@ def ensure_scriptrunner_policy(config_bucket, logs_bucket) end end + # Idempotently enable bucket versioning. Versitygw needs the gateway started + # with --versioning-dir for this to succeed; if it isn't, versitygw responds + # with VersioningNotConfigured and we surface a warning rather than failing + # bucket bootstrap. + def ensure_versioning_enabled(bucket) + options = { bucket: bucket, versioning_configuration: { status: 'Enabled' } } + options[:checksum_algorithm] = "SHA256" if @use_checksum + @client.put_bucket_versioning(options) + rescue Aws::S3::Errors::NotImplemented, Aws::S3::Errors::ServiceError, Aws::S3::Errors::InternalError => e + Logger.warn("put_bucket_versioning for #{bucket} not supported by S3 backend: #{e.message}") + end + def exist?(bucket) @client.head_bucket({ bucket: bucket }) true @@ -204,17 +220,54 @@ def delete(bucket) end end - def get_object(bucket:, key:, path: nil, range: nil) + def get_object(bucket:, key:, path: nil, range: nil, version_id: nil) + args = { bucket: bucket, key: key, range: range } + args[:version_id] = version_id if version_id if path - @client.get_object(bucket: bucket, key: key, response_target: path, range: range) + @client.get_object(**args, response_target: path) else - @client.get_object(bucket: bucket, key: key, range: range) + @client.get_object(**args) end # If the key is not found return nil rescue Aws::S3::Errors::NoSuchKey nil end + # Lists all versions (and delete markers) of objects matching a prefix. + # Each Versions entry has: key, version_id, is_latest, last_modified, size, etag + # Each DeleteMarkers entry has: key, version_id, is_latest, last_modified + # Returns { versions: [...], delete_markers: [...] }. + def list_object_versions(bucket:, prefix: nil, max_request: 1000, max_total: 100_000) + key_marker = nil + version_id_marker = nil + versions = [] + delete_markers = [] + while true + resp = @client.list_object_versions({ + bucket: bucket, + max_keys: max_request, + prefix: prefix, + key_marker: key_marker, + version_id_marker: version_id_marker + }) + versions.concat(resp.versions || []) + delete_markers.concat(resp.delete_markers || []) + break if (versions.length + delete_markers.length) >= max_total + break unless resp.is_truncated + key_marker = resp.next_key_marker + version_id_marker = resp.next_version_id_marker + end + { versions: versions, delete_markers: delete_markers } + rescue Aws::S3::Errors::NoSuchBucket + raise NotFound, "Bucket '#{bucket}' does not exist." + rescue Aws::S3::Errors::NotImplemented, Aws::S3::Errors::InternalError => e + # Backend (e.g. versitygw started without --versioning-dir) doesn't + # support versioning. Treat as "no versions available" so callers like + # the Version History dialog render an empty list rather than 500. + Logger.warn("list_object_versions on #{bucket} not supported by backend: #{e.message}") + { versions: [], delete_markers: [] } + end + def list_objects(bucket:, prefix: nil, max_request: 1000, max_total: 100_000) token = nil result = [] @@ -287,11 +340,10 @@ def list_files(bucket:, path:, only_directories: false, metadata: false) end # get metadata for a specific object - def head_object(bucket:, key:) - @client.head_object({ - bucket: bucket, - key: key - }) + def head_object(bucket:, key:, version_id: nil) + args = { bucket: bucket, key: key } + args[:version_id] = version_id if version_id + @client.head_object(args) rescue Aws::S3::Errors::NotFound raise NotFound, "Object '#{bucket}/#{key}' does not exist." end @@ -331,8 +383,10 @@ def check_object(bucket:, key:, retries: true) false end - def delete_object(bucket:, key:) - @client.delete_object(bucket: bucket, key: key) + def delete_object(bucket:, key:, version_id: nil) + args = { bucket: bucket, key: key } + args[:version_id] = version_id if version_id + @client.delete_object(args) rescue Exception => e Logger.error("Error deleting object bucket: #{bucket}, key: #{key}: #{e.message}") end diff --git a/openc3/lib/openc3/utilities/bucket.rb b/openc3/lib/openc3/utilities/bucket.rb index 324aab7400..37e721b770 100644 --- a/openc3/lib/openc3/utilities/bucket.rb +++ b/openc3/lib/openc3/utilities/bucket.rb @@ -45,6 +45,13 @@ def ensure_scriptrunner_policy(config_bucket, logs_bucket) # No-op by default, implemented by AwsBucket for local mode end + # Idempotently enable bucket versioning. Implementations should swallow + # backend-not-supported errors so missing versioning support never blocks + # bucket bootstrap. + def ensure_versioning_enabled(bucket) + raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" + end + def exist?(bucket) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end @@ -53,7 +60,7 @@ def delete(bucket) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end - def get_object(bucket:, key:, path: nil, range: nil) + def get_object(bucket:, key:, path: nil, range: nil, version_id: nil) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end @@ -61,6 +68,12 @@ def list_objects(bucket:, prefix: nil, max_request: nil, max_total: nil) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end + # List all object versions (and delete markers) under a key prefix. + # Returns an array of hashes — see AwsBucket implementation for the shape. + def list_object_versions(bucket:, prefix: nil, max_request: nil, max_total: nil) + raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" + end + def list_files(bucket:, path:, only_directories: false, metadata: false) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end @@ -73,7 +86,7 @@ def check_object(bucket:, key:, retries: true) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end - def delete_object(bucket:, key:) + def delete_object(bucket:, key:, version_id: nil) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end diff --git a/openc3/lib/openc3/utilities/script.rb b/openc3/lib/openc3/utilities/script.rb index 3885075fae..4ecde01dc8 100644 --- a/openc3/lib/openc3/utilities/script.rb +++ b/openc3/lib/openc3/utilities/script.rb @@ -162,11 +162,15 @@ def self.process_suite(name, contents, new_process: true, username: nil, scope:) return stdout_results, stderr_results, success end + # Returns the new S3 VersionId (String) when the bucket write produced a new + # version, true when no new version was needed, false on failure, or nil when + # the text was unchanged from the existing body and no write happened. def self.create(params) + write_result = nil existing = body(params[:scope], params[:name]) # Commit if there is no existing or something has changed if existing.nil? or existing != params[:text] - super(params[:scope], params[:name], params[:text]) + write_result = super(params[:scope], params[:name], params[:text], username: params[:username]) end breakpoints = params[:breakpoints] if breakpoints @@ -177,6 +181,7 @@ def self.create(params) breakpoints.as_json().to_json(allow_nan: true)) end end + write_result end def self.delete_temp(scope) diff --git a/openc3/lib/openc3/utilities/target_file.rb b/openc3/lib/openc3/utilities/target_file.rb index 65b39e96d9..140c69fe0d 100644 --- a/openc3/lib/openc3/utilities/target_file.rb +++ b/openc3/lib/openc3/utilities/target_file.rb @@ -120,26 +120,31 @@ def self.body(scope, name) end end - def self.create(scope, name, text, content_type: 'text/plain') + # Returns the new S3 VersionId (String) on success when the bucket is + # versioned, true on success when not versioned, or false on failure. + def self.create(scope, name, text, content_type: 'text/plain', username: nil) return false unless text if ENV['OPENC3_LOCAL_MODE'] OpenC3::LocalMode.put_target_file("#{scope}/targets_modified/#{name}", text, scope: scope) end client = Bucket.getClient() - client.put_object( + metadata = username ? { 'username' => username } : nil + resp = client.put_object( # Use targets_modified to save modifications # This keeps the original target clean (read-only) bucket: ENV['OPENC3_CONFIG_BUCKET'], key: "#{scope}/targets_modified/#{name}", body: text, content_type: content_type, + metadata: metadata, ) # Wait for the object to exist if client.check_object( bucket: ENV['OPENC3_CONFIG_BUCKET'], key: "#{scope}/targets_modified/#{name}", ) - true + version_id = resp.respond_to?(:version_id) ? resp.version_id : nil + (version_id && !version_id.empty?) ? version_id : true else false end