The App structure is defined in the common/types.go
file, as it is a widely used structure.
Each App struct also comes with a weighed semaphore TransitionLock
property which serves as a way to 'lock' the app while it's transitioning. As well as a StateLock
mutex property which serves as a way to prevent race conditions when the App struct is being accessed by different go routines.
App data is stored on seperate levels: in-memory and in the database. All CRUD operations for apps are managed by the AppStore
struct, which is defined in apps/app_store.go
.
The AppStore
serves as an abstraction layer for these three storage levels. For instance, if an app is not found in memory when accessed, it will be loaded from the database, and vice versa. This abstraction simplifies the process of managing database and in-memory states manually.
Whenever an app's state is updated locally (both in-memory and in the database), it is also updated remotely in the database. This synchronization is also managed by the AppStore struct.
Apps can also be added to the AppStore
using a TransitionPayload
received through a Crossbar RPC. For example, whenever a new app is installed and a state transition is triggered via a Crossbar RPC, the app will be created in the AppStore
both in-memory and in the database.
Additionally, transition payloads are loaded during the agent's boot process to populate the in-memory app storage.
The update scripts function as a one-time .sql
file that executes each time the database is loaded at agent startup. The process does not verify if a script fails or succeeds; it simply executes each .sql
file and disregards any errors that occur. Typically, an error will be thrown if the script has been executed previously, resulting in a no-op (no operation).
Update scripts can be added by simply creating a new .sql
file in the persistence/update-scripts
directory.
All persistence-related functionalities are handled in the persistence
package, with the main file being persistence/database.go
.
The Database
interface, located in persistence/types.go
, serves as an abstraction for a generic database compatible with the agent's database model. The interface is designed to allow for the creation of other implementations of this database API in the future, such as a different database besides SQLite.
The implementation of the Database interface that we use throughout the app is the AppStateDatabase
. The AppStateDatabase
serves as an abstraction for the SQLite API that we use in order to save app states and app data on disk.
The agent allows users to manage their network settings and configurations. An abstraction for this API exists as the Network
interface, which resides in the network/network.go
file. Currently, there is only one implementation of this interface, which is for the NetworkManager API.
Upon agent launch, we check whether the operating system is Linux. If it is, we enable the NetworkManager API implementation; otherwise, we apply a dummy implementation that returns false values for all operations. Reference in code
When implementations for other operating systems are added, the dummy implementation can be replaced with a proper implementation of the Network
interface.
We also include the NetworkManager API, copied from the gonetworkmanager Github repository. Since we needed to apply our own custom changes, we decided to incorporate it directly into the agent's source code. The NetworkManager API uses the D-Bus API to manage the network.
The Messenger
interface serves as an abstraction layer for the communication protocol used to interface with the agent externally. This interface is defined in the messenger/types.go
file. Currently, it is implemented using WAMP (Web Application Messaging Protocol).
To implement WAMP, we use the Nexus client.
The Container
interface serves as an abstraction layer for any container-related operations. Currently, we have implemented this interface using the Docker SDK. This approach allows the agent to potentially support other containerization software in the future, such as Podman.
Since there is no official Docker Compose API, we have manually implemented and exposed an API that interacts with Docker Compose using the docker compose
command-line tool.
We interface with the Compose CLI using the built-in exec
Go API and provide the output of each command as a string channel.
The implementation of the Compose API can be found in the container/compose.go
file.
In the config package, we define various elements such as the command line arguments and their default values. During the initialization of the CLI arguments, we also specify the default folder locations and log file paths.
The main.go
and agent.go
files are the entry points of the application. In the main.go
file, we instantiate an Agent
struct that serves as the API for the entire application.
The main.go
file is responsible for loading the .flock
file, initializing the agent in offline mode, checking if the Docker Daemon is enabled, and initializing the connection callback for the agent itself.
The Agent
struct is initialized using the following components:
Agent{
Config: generalConfig,
System: &systemAPI,
External: &external,
LogManager: &logManager,
Network: networkInstance,
TerminalManager: &terminalManager,
TunnelManager: &tunnelManager,
AppManager: appManager,
StateObserver: &stateObserver,
StateMachine: &stateMachine,
Filesystem: &filesystem,
Container: container,
Messenger: mainSession,
LogMessenger: mainSession,
Database: database,
}
The Agent
struct contains a Connect
handler responsible for establishing the WAMP connection, updating the remote and local databases, enabling the frp tunnels, and downloading new versions of the agent and FRP tunnel. Essentially, it handles all initialization tasks that require a remote WAMP connection.
At the end of the Connect
handler, the device status is set to CONNECTED
, commonly referred to as "green."
All Crossbar endpoints on the agent are registered in the api/external.go
file. In said file we have a map of all registered endpoints with a topic mapped to the function that executes it.
The topics for the exposed endpoints that are registered on the agent itself are stored in the messenger/topics/exposed.go
file.
To create a new crossbar RPC, you must first add a new .go
file in the api
folder with the following parameters and return value:
func (ex *External) exampleRPC(ctx context.Context, response messenger.Result) (*messenger.InvokeResult, error) {
return &messenger.InvokeResult{}, nil
}
What is returned represents the response that is eventually sent out to the caller.
Afterwards the function must added to the map in the api/external.go
file using the corresponding topic. The full topic (which contains the serial number is automatically added when the agent is started).
The stateTransitionMap
is assigned and defined in the apps/state_machine.go
file. All states and their transition functions can be found in the getTransitionFunc
function.
The application state constants are defined in the common/constants.go
file. Since Go does not allow circular dependencies, elements that are reused across the entire app must be defined at a top level. New states must, therefore, be defined there.
To add a new state transition, you can create a new file in the apps folder and then create a state transition function with the following parameters and return value:
func (sm *StateMachine) exampleStateTransitionFunction(payload common.TransitionPayload, app *common.App, releaseBuild bool) error {
}
The state of the app can be changed during this transition function using the sm.setState()
function. For example, you can change the app to an intermediate state. Whenever the transition has ended, you must manually call sm.setState()
to determine the final state after the state transition has completed.