modified: app.py
modified: openapi.yaml modified: requirements.txt modified: test_agent.sh
This commit is contained in:
parent
5af4d5a7e4
commit
638d19fd77
4 changed files with 242 additions and 252 deletions
70
app.py
70
app.py
|
|
@ -1,8 +1,8 @@
|
|||
###############################################################################
|
||||
# app.py - PyraLogs Application with Enhanced Agent Management
|
||||
# - Prevents duplicate agent names within a namespace
|
||||
# - Handles agent revocation by updating agent status
|
||||
# - Provides JWT regeneration for existing agents
|
||||
# app.py - PyraLogs Application with Enhanced Secret Management
|
||||
# - Secrets are generated once, saved to the filesystem, and loaded thereafter
|
||||
# - Different secrets are used for session signing and JWT signing
|
||||
# - Includes previous enhancements for agent management and OpenAPI integration
|
||||
###############################################################################
|
||||
import os
|
||||
import base64
|
||||
|
|
@ -58,26 +58,61 @@ from jwt import PyJWTError
|
|||
# Set up logging
|
||||
################################################################################
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
################################################################################
|
||||
# Environment Variables and Defaults
|
||||
################################################################################
|
||||
|
||||
# If PYRALOGS_SECRET is missing, generate a random 64-character secret.
|
||||
pyralogs_secret = os.environ.get("PYRALOGS_SECRET", "")
|
||||
if not pyralogs_secret:
|
||||
pyralogs_secret = "".join(
|
||||
random.choices(string.ascii_letters + string.digits, k=64)
|
||||
)
|
||||
log.info(f"Generated random PYRALOGS_SECRET: {pyralogs_secret}")
|
||||
# Application directory
|
||||
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# JWT Secret Key
|
||||
JWT_SECRET = os.environ.get("PYRALOGS_JWT_SECRET", pyralogs_secret)
|
||||
# Paths to the secret files
|
||||
PYRALOGS_SECRET_FILE = os.path.join(APP_DIR, "pyralogs_secret.txt")
|
||||
JWT_SECRET_FILE = os.path.join(APP_DIR, "jwt_secret.txt")
|
||||
|
||||
|
||||
def get_or_create_secret(env_var_name, secret_file_path):
|
||||
"""
|
||||
Retrieves the secret from an environment variable, or loads it from the
|
||||
specified file. If neither is available, generates a new secret, saves
|
||||
it to the file, and returns it.
|
||||
"""
|
||||
# Check environment variable
|
||||
secret = os.environ.get(env_var_name, "")
|
||||
if secret:
|
||||
log.info(f"Using {env_var_name} from environment variable.")
|
||||
return secret
|
||||
|
||||
# Check if the secret file exists
|
||||
if os.path.exists(secret_file_path):
|
||||
with open(secret_file_path, "r") as f:
|
||||
secret = f.read().strip()
|
||||
if secret:
|
||||
log.info(f"Loaded {env_var_name} from {secret_file_path}")
|
||||
return secret
|
||||
else:
|
||||
log.warning(f"{secret_file_path} is empty. Generating new secret.")
|
||||
else:
|
||||
log.info(f"{secret_file_path} does not exist. Generating new secret.")
|
||||
|
||||
# Generate a new secret
|
||||
secret = "".join(random.choices(string.ascii_letters + string.digits, k=64))
|
||||
# Save the secret to the file
|
||||
with open(secret_file_path, "w") as f:
|
||||
f.write(secret)
|
||||
log.info(f"Generated and saved new {env_var_name} to {secret_file_path}")
|
||||
|
||||
return secret
|
||||
|
||||
|
||||
# Retrieve or generate the secrets
|
||||
pyralogs_secret = get_or_create_secret("PYRALOGS_SECRET", PYRALOGS_SECRET_FILE)
|
||||
JWT_SECRET = get_or_create_secret("PYRALOGS_JWT_SECRET", JWT_SECRET_FILE)
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
# Database URL can be overridden by environment variable
|
||||
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
default_main_db_url = f"sqlite:///{os.path.join(APP_DIR, 'main.db')}"
|
||||
DB_URL = os.environ.get("PYRALOGS_DB_URL", default_main_db_url)
|
||||
|
||||
|
|
@ -1026,7 +1061,7 @@ def view_namespace_logs_view(request):
|
|||
|
||||
def main(global_config=None, **settings):
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Set up the session factory
|
||||
session_factory = SignedCookieSessionFactory(
|
||||
|
|
@ -1048,6 +1083,10 @@ def main(global_config=None, **settings):
|
|||
config.include("pyramid_jinja2")
|
||||
config.include("pyramid_tm") # Include pyramid_tm for transaction management
|
||||
|
||||
config.include("pyramid_openapi3")
|
||||
config.pyramid_openapi3_spec('openapi.yaml', route='/openapi.yaml')
|
||||
config.pyramid_openapi3_add_explorer(route='/docs')
|
||||
|
||||
# Add .html.j2 extension for Jinja2 templates
|
||||
config.add_jinja2_renderer(".j2")
|
||||
config.add_jinja2_search_path("templates", name=".j2")
|
||||
|
|
@ -1062,7 +1101,6 @@ def main(global_config=None, **settings):
|
|||
)
|
||||
session_factory_ = sessionmaker(bind=engine)
|
||||
Base.metadata.bind = engine
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
DBSession = scoped_session(session_factory_)
|
||||
register(DBSession) # Register with zope.sqlalchemy
|
||||
|
|
|
|||
420
openapi.yaml
420
openapi.yaml
|
|
@ -1,25 +1,25 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: PyraFiles API
|
||||
title: PyraLogs API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
API specification for the PyraFiles application.
|
||||
PyraFiles allows users to register, authenticate, upload media files,
|
||||
and manage their media content.
|
||||
API specification for the PyraLogs application.
|
||||
PyraLogs allows users to register, authenticate, manage namespaces,
|
||||
generate JWT tokens for agents, and submit logs via a webhook.
|
||||
servers:
|
||||
- url: http://localhost:{port}
|
||||
- url: http://127.0.0.1:{port}
|
||||
description: Local development server
|
||||
variables:
|
||||
port:
|
||||
default: '6544'
|
||||
- url: https://upload.unturf.com
|
||||
description: prod for humans & agents to mingle.
|
||||
- url: https://logs.unturf.com
|
||||
description: Production server
|
||||
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
summary: Home Page
|
||||
description: Displays the home page.
|
||||
description: Displays the home page with public namespaces and user namespaces.
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response
|
||||
|
|
@ -106,7 +106,7 @@ paths:
|
|||
/auth/profile:
|
||||
get:
|
||||
summary: Display User Profile
|
||||
description: Shows the user's profile, including upload stats.
|
||||
description: Shows the user's profile, including owned namespaces.
|
||||
security:
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
|
|
@ -143,212 +143,64 @@ paths:
|
|||
'403':
|
||||
description: Unauthorized (user not logged in or guest mode)
|
||||
|
||||
/auth/download_db:
|
||||
/namespace/create:
|
||||
get:
|
||||
summary: Download User Database
|
||||
description: Allows the user to download their personal database file.
|
||||
summary: Display Namespace Creation Page
|
||||
description: Renders the form to create a new namespace.
|
||||
security:
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Database file downloaded
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'403':
|
||||
description: Unauthorized (user not logged in or unverified)
|
||||
'404':
|
||||
description: Database file not found
|
||||
|
||||
/auth/export_user_record:
|
||||
get:
|
||||
summary: Export User Record
|
||||
description: Exports the user's record as a JSON file.
|
||||
security:
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: User record JSON file downloaded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
'403':
|
||||
description: Unauthorized (user not logged in or unverified)
|
||||
|
||||
/admin/import_user_record:
|
||||
get:
|
||||
summary: Display Import User Record Page
|
||||
description: Renders a page to import a user record (Admin only).
|
||||
security:
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Import user record page rendered
|
||||
description: Namespace creation page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Forbidden (user not admin)
|
||||
description: Unauthorized (user not logged in)
|
||||
post:
|
||||
summary: Import User Record
|
||||
description: Processes uploaded user record file and imports the user (Admin only).
|
||||
summary: Create Namespace
|
||||
description: Processes the namespace creation form.
|
||||
security:
|
||||
- sessionAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user_record_file:
|
||||
type: string
|
||||
format: binary
|
||||
required:
|
||||
- user_record_file
|
||||
responses:
|
||||
'302':
|
||||
description: Redirects to the home page after successful import
|
||||
'400':
|
||||
description: Bad Request (e.g., invalid file)
|
||||
'403':
|
||||
description: Forbidden (user not admin)
|
||||
|
||||
/media/upload:
|
||||
get:
|
||||
summary: Display Media Upload Page
|
||||
description: Renders the media upload form.
|
||||
security:
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Upload media page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Unauthorized (user not logged in or unverified)
|
||||
post:
|
||||
summary: Upload Media
|
||||
description: Processes the uploaded media file.
|
||||
security:
|
||||
- sessionAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
media_file:
|
||||
type: string
|
||||
format: binary
|
||||
title:
|
||||
name:
|
||||
type: string
|
||||
is_public:
|
||||
type: string
|
||||
enum: ['on']
|
||||
required:
|
||||
- media_file
|
||||
- name
|
||||
responses:
|
||||
'302':
|
||||
description: Redirects to the media details page
|
||||
description: Redirects to the namespace management page
|
||||
'400':
|
||||
description: Bad Request (e.g., no file uploaded, unsupported media type)
|
||||
description: Bad Request (e.g., namespace name already exists)
|
||||
'403':
|
||||
description: Unauthorized (user not logged in or unverified)
|
||||
description: Unauthorized (user not logged in)
|
||||
|
||||
/media/list:
|
||||
/namespace/{namespace_short_id}/manage:
|
||||
get:
|
||||
summary: List Public Media
|
||||
description: Displays a list of public media from all users.
|
||||
responses:
|
||||
'200':
|
||||
description: Media list page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/media/user/{user_short_id}:
|
||||
get:
|
||||
summary: Display User's Media
|
||||
description: Shows all media uploaded by a specific user.
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the user
|
||||
responses:
|
||||
'200':
|
||||
description: User's media page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: User not found
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
|
||||
/media/{user_short_id}/{media_short_id}/details:
|
||||
get:
|
||||
summary: Display Media Details
|
||||
description: Shows details of a specific media item.
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the user
|
||||
- in: path
|
||||
name: media_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the media item
|
||||
responses:
|
||||
'200':
|
||||
description: Media details page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: Media or user not found
|
||||
'403':
|
||||
description: Forbidden (media not public and not owner)
|
||||
|
||||
/media/{user_short_id}/{media_short_id}/edit:
|
||||
get:
|
||||
summary: Display Media Edit Page
|
||||
description: Renders a form to edit media details (owner only).
|
||||
summary: Manage Namespace
|
||||
description: Displays the namespace management page (owners only).
|
||||
security:
|
||||
- sessionAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_short_id
|
||||
name: namespace_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the user
|
||||
- in: path
|
||||
name: media_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the media item
|
||||
description: The short ID of the namespace
|
||||
responses:
|
||||
'200':
|
||||
description: Media edit page rendered
|
||||
description: Namespace management page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
|
|
@ -356,112 +208,205 @@ paths:
|
|||
'403':
|
||||
description: Forbidden (not owner or not logged in)
|
||||
'404':
|
||||
description: Media not found
|
||||
description: Namespace not found
|
||||
|
||||
/namespace/{namespace_short_id}/update:
|
||||
post:
|
||||
summary: Edit Media
|
||||
description: Updates the media item (owner only).
|
||||
summary: Update Namespace
|
||||
description: Updates namespace properties (owners only).
|
||||
security:
|
||||
- sessionAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_short_id
|
||||
name: namespace_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the user
|
||||
- in: path
|
||||
name: media_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the media item
|
||||
description: The short ID of the namespace
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
is_public:
|
||||
type: string
|
||||
enum: ['on']
|
||||
media_file:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
'302':
|
||||
description: Redirects to the media details page
|
||||
'400':
|
||||
description: Bad Request (e.g., file too large)
|
||||
description: Redirects to the namespace management page
|
||||
'403':
|
||||
description: Forbidden (not owner)
|
||||
description: Forbidden (not owner or not logged in)
|
||||
'404':
|
||||
description: Media not found
|
||||
description: Namespace not found
|
||||
|
||||
/media/{user_short_id}/{media_short_id}/delete:
|
||||
/namespace/{namespace_short_id}/invite:
|
||||
post:
|
||||
summary: Delete Media
|
||||
description: Deletes the media item (owner only).
|
||||
summary: Invite User to Namespace
|
||||
description: Invites a user to the namespace with a specified role (owners only).
|
||||
security:
|
||||
- sessionAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_short_id
|
||||
name: namespace_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the user
|
||||
- in: path
|
||||
name: media_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the media item
|
||||
description: The short ID of the namespace
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
role:
|
||||
type: string
|
||||
enum: ['owner', 'editor', 'reader']
|
||||
required:
|
||||
- email
|
||||
- role
|
||||
responses:
|
||||
'302':
|
||||
description: Redirects to the user's media list
|
||||
description: Redirects to the namespace management page
|
||||
'400':
|
||||
description: Bad Request (e.g., invalid role)
|
||||
'403':
|
||||
description: Forbidden (not owner)
|
||||
description: Forbidden (not owner or not logged in)
|
||||
'404':
|
||||
description: Media not found
|
||||
description: Namespace not found
|
||||
|
||||
/media/{user_short_id}/{media_short_id}:
|
||||
get:
|
||||
summary: View Media
|
||||
description: Retrieves the media file for viewing or download.
|
||||
/namespace/{namespace_short_id}/generate_agent_jwt:
|
||||
post:
|
||||
summary: Generate Agent JWT
|
||||
description: Generates a JWT token for an agent (owners only).
|
||||
security:
|
||||
- sessionAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_short_id
|
||||
name: namespace_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the user
|
||||
- in: path
|
||||
name: media_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the media item
|
||||
- in: query
|
||||
name: download
|
||||
schema:
|
||||
type: string
|
||||
enum: ['true', 'false']
|
||||
description: Set to 'true' to trigger download
|
||||
description: The short ID of the namespace
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
agent_name:
|
||||
type: string
|
||||
required:
|
||||
- agent_name
|
||||
responses:
|
||||
'200':
|
||||
description: Media file retrieved
|
||||
description: Agent JWT generated and displayed
|
||||
content:
|
||||
'*/*':
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'400':
|
||||
description: Bad Request (e.g., agent name missing)
|
||||
'403':
|
||||
description: Forbidden (media not public and not owner)
|
||||
description: Forbidden (not owner or not logged in)
|
||||
'404':
|
||||
description: Media not found
|
||||
description: Namespace not found
|
||||
|
||||
/namespace/{namespace_short_id}/revoke_agent:
|
||||
post:
|
||||
summary: Revoke Agent
|
||||
description: Revokes an agent's access by invalidating their JWT (owners only).
|
||||
security:
|
||||
- sessionAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: namespace_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the namespace
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
agent_id:
|
||||
type: string
|
||||
required:
|
||||
- agent_id
|
||||
responses:
|
||||
'302':
|
||||
description: Redirects to the namespace management page
|
||||
'400':
|
||||
description: Bad Request (e.g., agent ID missing)
|
||||
'403':
|
||||
description: Forbidden (not owner or not logged in)
|
||||
'404':
|
||||
description: Namespace or agent not found
|
||||
|
||||
/namespace/{namespace_short_id}/logs:
|
||||
get:
|
||||
summary: View Namespace Logs
|
||||
description: Displays logs for the namespace (readers and above).
|
||||
security:
|
||||
- sessionAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: namespace_short_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The short ID of the namespace
|
||||
responses:
|
||||
'200':
|
||||
description: Logs page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Forbidden (user does not have access)
|
||||
'404':
|
||||
description: Namespace not found
|
||||
|
||||
/webhook:
|
||||
post:
|
||||
summary: Submit Log Entry
|
||||
description: Allows agents to submit log entries via JWT authentication.
|
||||
security:
|
||||
- agentAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
level:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
required:
|
||||
- message
|
||||
responses:
|
||||
'201':
|
||||
description: Log entry created
|
||||
'400':
|
||||
description: Bad Request (e.g., message missing)
|
||||
'401':
|
||||
description: Unauthorized (missing or invalid JWT)
|
||||
'404':
|
||||
description: Namespace or agent not found
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
|
|
@ -470,3 +415,8 @@ components:
|
|||
in: cookie
|
||||
name: session
|
||||
description: Session cookie for authenticated users
|
||||
agentAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: JWT authentication for agents
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ pyramid_retry
|
|||
|
||||
pyramid_jwt
|
||||
|
||||
pyramid_openapi3
|
||||
|
||||
pyramid_tm
|
||||
zope.sqlalchemy
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
# Variables
|
||||
BASE_URL="http://127.0.0.1:6544"
|
||||
JWT_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2VudF9pZCI6IjcxZmQ0ZjA0LTQ2NzgtNGEyNi05ZmU4LTkxNjU2OTNmOTBmOSIsImFnZW50X25hbWUiOiJ0ZXN0LTkwMDAiLCJuYW1lc3BhY2VfaWQiOiJhZGQwYWQ1YS0zNjE0LTRhODgtOTRmZC0xNGQ4ZDBkYjIxYjUiLCJ0b2tlbl92ZXJzaW9uIjoyLCJpYXQiOjE3MzY2NDUxMDF9.WPsitdEYcJ6gpvExO8BmddJdmqBlczqQAD1SzHB0Y1M"
|
||||
JWT_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2VudF9pZCI6IjcxZmQ0ZjA0LTQ2NzgtNGEyNi05ZmU4LTkxNjU2OTNmOTBmOSIsImFnZW50X25hbWUiOiJ0ZXN0LTkwMDAiLCJuYW1lc3BhY2VfaWQiOiJhZGQwYWQ1YS0zNjE0LTRhODgtOTRmZC0xNGQ4ZDBkYjIxYjUiLCJ0b2tlbl92ZXJzaW9uIjozLCJpYXQiOjE3MzY2NDYxNzV9.BzTXP6W6dcKQEwB970zTT5JPaJJsb0rybQv774wQy-s"
|
||||
|
||||
# Log message from the first argument
|
||||
LOG_MESSAGE="$1"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue