Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/actions/build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ inputs:
description: "Branch deployed"
required: false
default: ""
build_revision:
description: "Source revision included in the image"
required: false
default: ""
build_ref:
description: "Source ref included in the image"
required: false
default: ""
build_source:
description: "Source repository included in the image"
required: false
default: ""
runs:
using: "composite"
steps:
Expand All @@ -46,6 +58,10 @@ runs:
images: ${{ env.REGISTRY_IMAGE }}
labels: |
{{ inputs.labels }}
- name: Prepare build metadata
id: build_metadata
shell: bash
run: echo "built_at=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to Docker Hub
Expand All @@ -68,6 +84,11 @@ runs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }}
build-args: |
TESLAMATE_BUILD_DATE=${{ steps.build_metadata.outputs.built_at }}
TESLAMATE_BUILD_REF=${{ inputs.build_ref }}
TESLAMATE_BUILD_REVISION=${{ inputs.build_revision }}
TESLAMATE_BUILD_SOURCE=${{ inputs.build_source }}
# Disable BuildKit-side attestations so outputs.digest is the bare image
# manifest digest. With provenance enabled, BuildKit wraps the image in
# an OCI index whose digest does NOT appear in the multi-arch manifest
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/buildx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ jobs:
repository: ${{ github.repository }}
github_token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ github.head_ref || github.ref_name }}
build_revision: ${{ github.event.pull_request.head.sha || github.sha }}
build_ref: ${{ github.head_ref || github.ref_name }}
build_source: ${{ github.event.pull_request.head.repo.full_name || github.repository }}

teslamate_merge:
runs-on: ubuntu-24.04
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ghcr_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ jobs:
repository: ${{ github.repository }}
github_token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ github.head_ref || github.ref_name }}
build_revision: ${{ github.event.pull_request.head.sha || github.sha }}
build_ref: ${{ github.head_ref || github.ref_name }}
build_source: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
labels: |
org.opencontainers.image.version=${{ github.ref || github.ref_name }}

Expand Down
13 changes: 11 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,18 @@ RUN SKIP_LOCALE_DOWNLOAD=true mix release --path /opt/built

FROM debian:trixie-slim AS app

ARG TESLAMATE_BUILD_DATE
ARG TESLAMATE_BUILD_REF
ARG TESLAMATE_BUILD_REVISION
ARG TESLAMATE_BUILD_SOURCE

ENV LANG=C.UTF-8 \
SRTM_CACHE=/opt/app/.srtm_cache \
HOME=/opt/app
HOME=/opt/app \
TESLAMATE_BUILD_DATE=${TESLAMATE_BUILD_DATE} \
TESLAMATE_BUILD_REF=${TESLAMATE_BUILD_REF} \
TESLAMATE_BUILD_REVISION=${TESLAMATE_BUILD_REVISION} \
TESLAMATE_BUILD_SOURCE=${TESLAMATE_BUILD_SOURCE}

WORKDIR $HOME

Expand All @@ -72,7 +81,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
USER nonroot:nonroot
COPY --chown=nonroot:nonroot --chmod=555 entrypoint.sh /
COPY --from=builder --chown=nonroot:nonroot --chmod=555 /opt/built .
RUN mkdir $SRTM_CACHE
RUN mkdir -p "$SRTM_CACHE" data/logs

EXPOSE 4000

Expand Down
19 changes: 19 additions & 0 deletions assets/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
@forward "bulma/sass/grid";
@forward "bulma/sass/helpers";
@forward "bulma/sass/layout/container";
@forward "bulma/sass/layout/level";
@forward "bulma/sass/layout/media";
@forward "bulma/sass/layout/section";

// Import the themes so that all CSS variables have a value
Expand Down Expand Up @@ -89,6 +91,23 @@ a:hover {
display: none;
}

.operations-wrap {
overflow-wrap: anywhere;
}

.operations-log-view {
max-height: 28rem;
overflow: auto;
padding: 1rem;
border: 1px solid var(--bulma-border);
border-radius: 6px;
background: var(--bulma-scheme-main-bis);
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 0.75rem;
line-height: 1.45;
}

nav.navbar {
z-index: 401;

Expand Down
53 changes: 53 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,64 @@ defmodule Util do
def get_env(varname, defaults \\ []) do
System.get_env(varname, defaults[config_env()])
end

def fetch_non_empty_env!(varname) do
case System.get_env(varname) do
value when is_binary(value) ->
if String.trim(value) == "", do: raise("#{varname} must not be empty"), else: value

nil ->
raise "environment variable #{varname} is missing"
end
end
end

config :teslamate,
default_geofence: System.get_env("DEFAULT_GEOFENCE")

config :teslamate, :build_info,
revision: System.get_env("TESLAMATE_BUILD_REVISION"),
ref: System.get_env("TESLAMATE_BUILD_REF"),
source: System.get_env("TESLAMATE_BUILD_SOURCE"),
built_at: System.get_env("TESLAMATE_BUILD_DATE")

file_logging_enabled =
config_env() != :test and System.get_env("TESLAMATE_FILE_LOGGING_ENABLED") == "true"

maintenance_actions_enabled =
config_env() != :test and System.get_env("TESLAMATE_MAINTENANCE_ACTIONS_ENABLED") == "true"

operations_auth_required = file_logging_enabled or maintenance_actions_enabled

operations_credentials =
if operations_auth_required do
username = Util.fetch_non_empty_env!("TESLAMATE_OPERATIONS_USERNAME")

if String.contains?(username, ":") do
raise "TESLAMATE_OPERATIONS_USERNAME must not contain ':'"
end

[
username: username,
password: Util.fetch_non_empty_env!("TESLAMATE_OPERATIONS_PASSWORD")
]
else
[]
end

config :teslamate, :file_logging,
enabled: file_logging_enabled,
path: System.get_env("TESLAMATE_FILE_LOGGING_PATH", "data/logs/teslamate.log"),
max_bytes: 5_000_000,
max_files: 3,
filesync_interval: 10_000

config :teslamate, :maintenance_actions, enabled: maintenance_actions_enabled

config :teslamate,
:operations_auth,
[required: operations_auth_required] ++ operations_credentials

case System.get_env("DATABASE_SOCKET_DIR") do
nil ->
config :teslamate, TeslaMate.Repo,
Expand Down
17 changes: 16 additions & 1 deletion lib/teslamate/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,24 @@ defmodule TeslaMate.Application do

require Logger

alias TeslaMate.{BuildInfo, FileLog}

def start(_type, _args) do
file_logging = FileLog.install()
build_info = BuildInfo.current()

Logger.info("System Info: Erlang/OTP #{otp_release()} (#{emu_flavor()})")
Logger.info("Version: #{Application.spec(:teslamate, :vsn) || "???"}")
Logger.info("Version: #{build_info.version}")

if BuildInfo.metadata?(build_info) do
Logger.info(BuildInfo.log_line(build_info))
end

case file_logging do
:ok -> Logger.info("File logging enabled")
{:error, reason} -> Logger.warning("File logging unavailable: #{reason}")
:disabled -> :ok
end

# Disable log entries
:ok = :telemetry.detach({Phoenix.Logger, [:phoenix, :socket_connected]})
Expand Down
105 changes: 105 additions & 0 deletions lib/teslamate/build_info.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
defmodule TeslaMate.BuildInfo do
@moduledoc """
Returns validated identity for the running TeslaMate build.

Build metadata is supplied by image builders. Missing or malformed values are
omitted so local and source builds continue to report only the application
version.
"""

@revision_regex ~r/\A[0-9a-fA-F]{7,64}\z/
@source_regex ~r/\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/

@type t :: %{
version: String.t(),
revision: String.t() | nil,
ref: String.t() | nil,
source: String.t() | nil,
built_at: String.t() | nil
}

@spec current(keyword()) :: t()
def current(opts \\ []) do
config = Keyword.get(opts, :config, Application.get_env(:teslamate, :build_info, []))

%{
version:
opts
|> Keyword.get(:version, Application.spec(:teslamate, :vsn) || "unknown")
|> to_string()
|> clean_text(64)
|> fallback("unknown"),
revision: config |> get_value(:revision) |> clean_revision(),
ref: config |> get_value(:ref) |> clean_text(255),
source: config |> get_value(:source) |> clean_source(),
built_at: config |> get_value(:built_at) |> clean_datetime()
}
end

@spec metadata?(t()) :: boolean()
def metadata?(build_info) do
Enum.any?([build_info.revision, build_info.ref, build_info.source, build_info.built_at])
end

@spec log_line(t()) :: String.t()
def log_line(build_info \\ current()) do
details =
[
source: build_info.source,
ref: build_info.ref,
revision: build_info.revision,
built_at: build_info.built_at
]
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Enum.map_join(" ", fn {key, value} -> "#{key}=#{value}" end)

if details == "", do: "Version: #{build_info.version}", else: "Build: #{details}"
end

defp get_value(config, key) when is_list(config), do: Keyword.get(config, key)
defp get_value(config, key) when is_map(config), do: Map.get(config, key)
defp get_value(_config, _key), do: nil

defp clean_revision(value) do
case clean_text(value, 64) do
revision when is_binary(revision) ->
if Regex.match?(@revision_regex, revision), do: String.downcase(revision)

nil ->
nil
end
end

defp clean_source(value) do
case clean_text(value, 200) do
source when is_binary(source) ->
if Regex.match?(@source_regex, source), do: source

nil ->
nil
end
end

defp clean_datetime(value) do
with datetime when is_binary(datetime) <- clean_text(value, 64),
{:ok, parsed, _offset} <- DateTime.from_iso8601(datetime) do
DateTime.to_iso8601(parsed)
else
_ -> nil
end
end

defp clean_text(value, max_length) when is_binary(value) do
value = String.trim(value)

if value != "" and String.length(value) <= max_length and String.printable?(value) and
not String.contains?(value, ["\n", "\r"]) do
value
end
end

defp clean_text(_value, _max_length), do: nil

defp fallback(nil, default), do: default
defp fallback(value, _default), do: value
end
Loading
Loading