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 @@
+
+
+
+
+
+
+ Version History — {{ filename }}
+
+
+
+
+
+
+
+
+
+
+ Failed to load versions: {{ loadError }}
+
+
+
+
+
+ No version history available for this script.
+
+
+
+
+
+
+
+ {{
+ v.version_id === compareVersionId
+ ? 'mdi-bookmark'
+ : 'mdi-bookmark-outline'
+ }}
+
+
+ Diff to this version
+
+
+
+ Version {{ versions.length - idx }}
+
+
+ Current
+
+
+
+
+ {{ formatTimestamp(v.last_modified) }}
+
+
+
+ Restore
+
+
+
+
+ Restoring a version creates a new version.
+
+
+
+
+
+ Select a version on the left to diff against the current version.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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